@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +204 -0
  2. package/README.md +1 -1
  3. package/bin/den +8 -0
  4. package/docs/distributions.md +283 -12
  5. package/package.json +4 -2
  6. package/src/pillars/journeys/sandbox.ts +2 -5
  7. package/src/pillars/security/discover.ts +17 -1
  8. package/src/pillars/security/run.ts +31 -3
  9. package/src/shared/agent-actor.ts +77 -0
  10. package/src/shared/den-config.ts +122 -0
  11. package/src/shared/edge/status.ts +74 -0
  12. package/src/shared/environment-manager/health.ts +54 -8
  13. package/src/shared/environment-manager/load-applications.ts +31 -1
  14. package/src/shared/environment-manager/log-tail.ts +5 -4
  15. package/src/shared/environment-manager/opctl.ts +2 -1
  16. package/src/shared/environment-manager/service-container.ts +47 -0
  17. package/src/shared/environment-manager/service-runner.ts +19 -2
  18. package/src/shared/environment-manager/sync-targets.ts +66 -0
  19. package/src/shared/environment-manager/up-plan.ts +25 -1
  20. package/src/shared/manifest/merge-override.ts +75 -0
  21. package/src/shared/manifest/placement.ts +333 -0
  22. package/src/shared/manifest/read-manifest.ts +156 -38
  23. package/src/shared/manifest/schema.ts +14 -0
  24. package/src/shared/manifest/validate-layers.ts +229 -0
  25. package/src/shared/manifest/validate-manifest.ts +66 -6
  26. package/src/shared/paths.ts +12 -0
  27. package/src/shared/redact.ts +107 -0
  28. package/src/shared/stacks/manifest-commands.ts +30 -0
  29. package/src/shared/stacks/status.ts +99 -0
  30. package/src/shared/types/index.ts +23 -0
  31. package/src/ui/cli/applications-command.ts +34 -2
  32. package/src/ui/cli/edge-command.ts +9 -19
  33. package/src/ui/cli/graph-command.ts +50 -4
  34. package/src/ui/cli/index.ts +184 -55
  35. package/src/ui/cli/mcp-connect.ts +350 -0
  36. package/src/ui/cli/mcp-main.ts +13 -0
  37. package/src/ui/cli/mcp-stdout-guard.ts +10 -0
  38. package/src/ui/cli/named-target.ts +11 -0
  39. package/src/ui/cli/stack-command.ts +13 -40
  40. package/src/ui/cli/test-runner.ts +8 -1
  41. package/src/ui/mcp/api.ts +286 -0
  42. package/src/ui/mcp/connect.ts +304 -0
  43. package/src/ui/mcp/guards.ts +145 -0
  44. package/src/ui/mcp/server.ts +149 -0
  45. package/src/ui/mcp/tools.ts +644 -0
  46. package/src/ui/server/audit.ts +137 -0
  47. package/src/ui/server/changelog-parser.ts +11 -0
  48. package/src/ui/server/graph-prefs.ts +89 -0
  49. package/src/ui/server/health-row.ts +15 -0
  50. package/src/ui/server/index.ts +256 -61
  51. package/src/ui/server/lifecycle-exec.ts +188 -0
  52. package/src/ui/server/log-history.ts +4 -3
  53. package/src/ui/server/public/environments.html +4 -2
  54. package/src/ui/server/public/graph-model.js +416 -0
  55. package/src/ui/server/public/graph.css +980 -0
  56. package/src/ui/server/public/graph.js +986 -0
  57. package/src/ui/server/public/header.js +7 -5
  58. package/src/ui/server/public/local-dev.html +130 -2191
  59. package/src/ui/server/public/logs.html +11 -4
  60. package/src/ui/server/public/panel.js +790 -0
  61. package/src/ui/server/public/services.html +6 -1
  62. package/src/ui/server/readonly-routes.ts +209 -0
  63. package/src/ui/server/ui-assets.ts +73 -0
  64. package/src/ui/server/workspace-routes.ts +39 -8
@@ -0,0 +1,644 @@
1
+ /**
2
+ * The MCP tools. Read tools are always registered; action tools only when the
3
+ * org names them in `mcp.actions`. Every tool is a thin call to the web API
4
+ * (see api.ts); the guards (guards.ts) run first, and all text returned is
5
+ * passed through the secret redactor.
6
+ */
7
+ import { z } from "zod";
8
+ import {
9
+ MCP_ACTIONS,
10
+ type McpAction,
11
+ type ResolvedMcpConfig,
12
+ } from "../../shared/den-config.js";
13
+ import { redactSecrets } from "../../shared/redact.js";
14
+ import {
15
+ DenHttpError,
16
+ DenWebDown,
17
+ type DenApi,
18
+ type Query,
19
+ type StreamResult,
20
+ } from "./api.js";
21
+ import {
22
+ Refusal,
23
+ assertLifecycleCmd,
24
+ assertLocalEnv,
25
+ assertLogsEnv,
26
+ assertRunTestsAllowed,
27
+ assertRunsLocally,
28
+ assertSecurityPlanOk,
29
+ assertSyncableRepo,
30
+ type LifecycleCmd,
31
+ type ServiceEntryLike,
32
+ type SecurityPlanLike,
33
+ } from "./guards.js";
34
+
35
+ export interface ToolContext {
36
+ api: DenApi;
37
+ cfg: ResolvedMcpConfig;
38
+ /** The CLI name, for messages (`<cli> web up`). */
39
+ cli: string;
40
+ }
41
+
42
+ type Args = Record<string, unknown>;
43
+
44
+ export interface DenTool {
45
+ name: string;
46
+ title: string;
47
+ description: string;
48
+ action: boolean;
49
+ shape: z.ZodRawShape;
50
+ run(args: Args, ctx: ToolContext): Promise<unknown>;
51
+ }
52
+
53
+ const ENVS = [
54
+ "local",
55
+ "dev",
56
+ "sandbox",
57
+ "staging",
58
+ "beta",
59
+ "prod",
60
+ "production",
61
+ ] as const;
62
+ const PILLARS = [
63
+ "synthetic",
64
+ "integration",
65
+ "performance",
66
+ "smoke",
67
+ "journeys",
68
+ "security",
69
+ "suites",
70
+ ] as const;
71
+
72
+ const envArg = z.enum(ENVS).optional().describe("Environment (default local)");
73
+ const workspaceArg = z
74
+ .string()
75
+ .optional()
76
+ .describe("Den workspace (default: the focused one)");
77
+
78
+ const str = (v: unknown): string | undefined =>
79
+ typeof v === "string" && v.length > 0 ? v : undefined;
80
+ const envOf = (args: Args): string => str(args.env) ?? "local";
81
+
82
+ /** Keep the last `n` lines of a stream's output. */
83
+ function tail(r: StreamResult, n: number): string[] {
84
+ return r.lines.slice(-n);
85
+ }
86
+
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
+
123
+ function streamSummary(r: StreamResult, keep: number) {
124
+ return {
125
+ ended: endedLabel(r),
126
+ ...(r.exitCode !== undefined ? { exitCode: r.exitCode } : {}),
127
+ lines: r.lines.length,
128
+ output: tail(r, keep),
129
+ };
130
+ }
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
+
143
+ // ── Read tools ──────────────────────────────────────────────────────────────
144
+
145
+ const read = (
146
+ name: string,
147
+ title: string,
148
+ description: string,
149
+ shape: z.ZodRawShape,
150
+ run: DenTool["run"],
151
+ ): DenTool => ({ name, title, description, action: false, shape, run });
152
+
153
+ const READ_TOOLS: DenTool[] = [
154
+ read(
155
+ "status",
156
+ "Service health",
157
+ "Health of every service in the workspace (what `status` shows): up/down, mode, and what was checked: `url` for an HTTP check, `check` for a shell command.",
158
+ { env: envArg, workspace: workspaceArg },
159
+ (a, { api }) =>
160
+ api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
161
+ ),
162
+ read(
163
+ "services",
164
+ "Services",
165
+ "The workspace's services: repo, service, mode (managed or dev-managed), lifecycle commands, dependencies.",
166
+ { env: envArg, workspace: workspaceArg },
167
+ (a, { api }) =>
168
+ api.get("/api/services", { env: envOf(a), workspace: str(a.workspace) }),
169
+ ),
170
+ read(
171
+ "applications",
172
+ "Applications",
173
+ "Org > Application > Repo > Services for a workspace (what `apps --json` shows).",
174
+ { workspace: workspaceArg },
175
+ (a, { api }) =>
176
+ api.get("/api/applications", { workspace: str(a.workspace) }),
177
+ ),
178
+ read(
179
+ "graph",
180
+ "Dependency graph",
181
+ "The derived dependency graph and its startup order; `seed` narrows it to one repo's closure.",
182
+ {
183
+ seed: z.string().optional().describe("Repo whose closure to show"),
184
+ workspace: workspaceArg,
185
+ },
186
+ (a, { api }) =>
187
+ api.get("/api/graph", { seed: str(a.seed), workspace: str(a.workspace) }),
188
+ ),
189
+ read(
190
+ "workspaces",
191
+ "Workspaces",
192
+ "Workspaces, which are active and which is focused.",
193
+ {},
194
+ (_a, { api }) => api.get("/api/workspaces"),
195
+ ),
196
+ read(
197
+ "environments",
198
+ "Environments",
199
+ "The environment names this org enables (names only, never their values).",
200
+ {},
201
+ (_a, { api }) => api.get("/api/environments"),
202
+ ),
203
+ read(
204
+ "logs_search",
205
+ "Search logs",
206
+ "Search the full log history of services (repo/service targets) for text. Local sources only unless mcp.logs.remote is on. Secrets are redacted.",
207
+ {
208
+ q: z.string().min(2).describe("Text to find (2+ characters)"),
209
+ targets: z
210
+ .array(z.string())
211
+ .min(1)
212
+ .max(40)
213
+ .describe("repo/service names"),
214
+ env: envArg,
215
+ },
216
+ async (a, { api, cfg }) => {
217
+ const env = envOf(a);
218
+ assertLogsEnv(env, cfg);
219
+ return api.get("/api/logs/search", {
220
+ q: String(a.q),
221
+ env,
222
+ targets: (a.targets as string[]).join(","),
223
+ });
224
+ },
225
+ ),
226
+ read(
227
+ "logs_tail",
228
+ "Tail logs",
229
+ "The last lines of one service's log (no follow). Local sources only unless mcp.logs.remote is on. Secrets are redacted.",
230
+ {
231
+ repo: z.string(),
232
+ service: z.string(),
233
+ lines: z
234
+ .number()
235
+ .int()
236
+ .min(1)
237
+ .max(1000)
238
+ .optional()
239
+ .describe("Lines to return (default 200)"),
240
+ env: envArg,
241
+ },
242
+ async (a, { api, cfg }) => {
243
+ const env = envOf(a);
244
+ assertLogsEnv(env, cfg);
245
+ const n = (a.lines as number | undefined) ?? 200;
246
+ const query: Query = { repo: String(a.repo), service: String(a.service) };
247
+ const r =
248
+ env === "local"
249
+ ? await api.stream("/api/service/taillog", query, {
250
+ maxLines: 5000,
251
+ timeoutMs: 8000,
252
+ idleMs: 1500,
253
+ })
254
+ : await api.stream(
255
+ "/api/service/logs",
256
+ { ...query, env },
257
+ {
258
+ maxLines: 5000,
259
+ timeoutMs: 15000,
260
+ idleMs: 3000,
261
+ },
262
+ );
263
+ return { repo: a.repo, service: a.service, env, lines: tail(r, n) };
264
+ },
265
+ ),
266
+ read(
267
+ "tests_available",
268
+ "Available tests",
269
+ "The test modules of a pillar, and which repos (branch + SHA) they come from.",
270
+ { pillar: z.enum(PILLARS) },
271
+ async (a, { api }) => ({
272
+ modules: await api.get("/api/modules", { pillar: String(a.pillar) }),
273
+ sources: await api.get("/api/test-sources", { pillar: String(a.pillar) }),
274
+ }),
275
+ ),
276
+ read(
277
+ "security_latest",
278
+ "Latest security scans",
279
+ "The newest security (ZAP) scan summary per mode for an environment.",
280
+ { env: envArg },
281
+ (a, { api }) => api.get("/api/security/latest", { env: envOf(a) }),
282
+ ),
283
+ read(
284
+ "release_status",
285
+ "Release status",
286
+ "Recent CD runs per repo and, with env, the deployed-version table for it. Read only: there is no deploy tool.",
287
+ {
288
+ env: z
289
+ .string()
290
+ .optional()
291
+ .describe("Release environment for the verify table"),
292
+ },
293
+ async (a, { api }) => ({
294
+ runs: await api.get("/api/release/runs"),
295
+ ...(str(a.env)
296
+ ? { status: await api.get("/api/release/status", { env: str(a.env) }) }
297
+ : {}),
298
+ }),
299
+ ),
300
+ read(
301
+ "manifest_validate",
302
+ "Validate a manifest",
303
+ "Validate a service manifest: a repo path (or file), or the YAML content itself.",
304
+ {
305
+ path: z.string().optional().describe("Repo directory or manifest file"),
306
+ content: z.string().optional().describe("Manifest YAML to validate"),
307
+ },
308
+ async (a, { api }) => {
309
+ if (str(a.content) !== undefined)
310
+ return api.get("/api/manifest/validate", {}, "POST", {
311
+ content: a.content,
312
+ });
313
+ if (str(a.path) === undefined)
314
+ throw new Refusal("give a path or the manifest content.");
315
+ return api.get("/api/manifest/validate", { path: str(a.path) });
316
+ },
317
+ ),
318
+ read(
319
+ "stacks",
320
+ "Stacks",
321
+ "Compose stacks (what `stack ls/ps/health` show): name, project, routes and container health.",
322
+ {
323
+ workspace: workspaceArg,
324
+ health: z
325
+ .boolean()
326
+ .optional()
327
+ .describe("Check container health (default true)"),
328
+ },
329
+ (a, { api }) =>
330
+ api.get("/api/stacks", {
331
+ workspace: str(a.workspace),
332
+ health: a.health === false ? "0" : "1",
333
+ }),
334
+ ),
335
+ read(
336
+ "edge_status",
337
+ "Edge status",
338
+ "The HTTPS edge: up or down, its routes and its cert names.",
339
+ {},
340
+ (_a, { api }) => api.get("/api/edge"),
341
+ ),
342
+ ];
343
+
344
+ // ── Action tools ────────────────────────────────────────────────────────────
345
+
346
+ /** Find the /api/services entry an action names (repo + optional service). */
347
+ async function findService(
348
+ api: DenApi,
349
+ repo: string,
350
+ service: string | undefined,
351
+ workspace: string | undefined,
352
+ ): Promise<ServiceEntryLike & { repo: string; service: string }> {
353
+ const list = (await api.get("/api/services", {
354
+ env: "local",
355
+ workspace,
356
+ })) as (ServiceEntryLike & {
357
+ repo: string;
358
+ service: string;
359
+ isEntrypoint?: boolean;
360
+ })[];
361
+ const inRepo = list.filter((s) => s.repo === repo);
362
+ const hit = service
363
+ ? inRepo.find((s) => s.service === service)
364
+ : inRepo.length === 1
365
+ ? inRepo[0]
366
+ : inRepo.find((s) => s.isEntrypoint);
367
+ if (!hit)
368
+ throw new Refusal(
369
+ inRepo.length === 0
370
+ ? `no service in repo "${repo}" in the workspace.`
371
+ : `name the service: ${repo} has ${inRepo.map((s) => s.service).join(", ")}.`,
372
+ );
373
+ return hit;
374
+ }
375
+
376
+ function lifecycleTool(cmd: LifecycleCmd): DenTool {
377
+ const verb = { up: "Start", down: "Stop", restart: "Restart" }[cmd];
378
+ return {
379
+ name: `service_${cmd}`,
380
+ title: `${verb} a service`,
381
+ description: `${verb} one locally run service (its manifest's local "${cmd}"; env local only).`,
382
+ action: true,
383
+ shape: {
384
+ repo: z.string(),
385
+ service: z
386
+ .string()
387
+ .optional()
388
+ .describe("Service in a multi-service repo"),
389
+ workspace: workspaceArg,
390
+ env: z.string().optional().describe("Must be local (the default)"),
391
+ },
392
+ async run(a, { api }) {
393
+ assertLifecycleCmd(cmd);
394
+ const env = envOf(a);
395
+ assertLocalEnv(env);
396
+ const workspace = str(a.workspace);
397
+ const entry = await findService(
398
+ api,
399
+ String(a.repo),
400
+ str(a.service),
401
+ workspace,
402
+ );
403
+ assertRunsLocally(entry, cmd);
404
+ const r = await api.stream(
405
+ "/api/service/exec",
406
+ {
407
+ repo: entry.repo,
408
+ service: entry.service,
409
+ cmd,
410
+ env: "local",
411
+ ...(workspace ? { workspace } : {}),
412
+ },
413
+ {
414
+ method: "GET",
415
+ maxLines: 2000,
416
+ timeoutMs: 180_000,
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",
421
+ },
422
+ );
423
+ const ready = r.events.find((e) => e.type === "ready");
424
+ return actionResult(r, 200, {
425
+ service: `${entry.repo}/${entry.service}`,
426
+ cmd,
427
+ ...(ready
428
+ ? { ready: true, healthChecked: ready.checked === true }
429
+ : {}),
430
+ });
431
+ },
432
+ };
433
+ }
434
+
435
+ const ACTION_TOOLS: Record<McpAction, DenTool> = {
436
+ service_up: lifecycleTool("up"),
437
+ service_down: lifecycleTool("down"),
438
+ service_restart: lifecycleTool("restart"),
439
+ workspace_sync: {
440
+ name: "workspace_sync",
441
+ title: "Sync managed repos",
442
+ description:
443
+ "Bring managed clones up to date (down, pull, setup; never starts them). Never touches a dev-managed checkout.",
444
+ action: true,
445
+ shape: {
446
+ repo: z.string().optional().describe("One managed repo (default: all)"),
447
+ },
448
+ async run(a, { api }) {
449
+ const repo = str(a.repo);
450
+ if (repo) {
451
+ const services = (await api.get("/api/services", {
452
+ env: "local",
453
+ })) as ServiceEntryLike[];
454
+ assertSyncableRepo(repo, services);
455
+ }
456
+ const r = await api.stream("/api/workspace/sync", repo ? { repo } : {}, {
457
+ method: "POST",
458
+ maxLines: 5000,
459
+ timeoutMs: 15 * 60_000,
460
+ });
461
+ return actionResult(r, 300);
462
+ },
463
+ },
464
+ run_tests: {
465
+ name: "run_tests",
466
+ title: "Run tests",
467
+ description:
468
+ "Run a test pillar (optionally some modules) in an allowed env (mcp.run_tests.envs, default local; never production). Security runs must pass the owned-host plan; mode full is local only.",
469
+ action: true,
470
+ shape: {
471
+ pillar: z.enum(PILLARS),
472
+ modules: z.array(z.string()).optional(),
473
+ env: envArg,
474
+ mode: z
475
+ .enum(["baseline", "api", "full"])
476
+ .optional()
477
+ .describe("Security scan mode (default baseline)"),
478
+ },
479
+ async run(a, { api, cfg }) {
480
+ const pillar = String(a.pillar);
481
+ const env = envOf(a);
482
+ const modules = (a.modules as string[] | undefined) ?? [];
483
+ const mode =
484
+ pillar === "security" ? (str(a.mode) ?? "baseline") : undefined;
485
+ assertRunTestsAllowed({ pillar, env, mode }, cfg);
486
+ if (pillar === "security") {
487
+ for (const service of modules.length ? modules : [undefined]) {
488
+ const plan = (await api.get("/api/security/plan", {
489
+ env,
490
+ mode,
491
+ ...(service ? { service } : {}),
492
+ })) as SecurityPlanLike;
493
+ assertSecurityPlanOk(plan);
494
+ }
495
+ }
496
+ const r = await api.stream(
497
+ "/api/run",
498
+ {
499
+ pillar,
500
+ ...(modules.length ? { module: modules } : {}),
501
+ env,
502
+ ...(mode ? { mode } : {}),
503
+ },
504
+ { method: "GET", maxLines: 20_000, timeoutMs: 60 * 60_000 },
505
+ );
506
+ return actionResult(r, 300, { pillar, env });
507
+ },
508
+ },
509
+ };
510
+
511
+ export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
512
+ export const ACTION_TOOL_NAMES: readonly McpAction[] = MCP_ACTIONS;
513
+
514
+ /** The tools to register: every read tool, plus the actions the org named. */
515
+ export function toolsFor(cfg: ResolvedMcpConfig): DenTool[] {
516
+ return [
517
+ ...READ_TOOLS,
518
+ ...MCP_ACTIONS.filter((a) => cfg.actions.includes(a)).map(
519
+ (a) => ACTION_TOOLS[a],
520
+ ),
521
+ ];
522
+ }
523
+
524
+ /**
525
+ * The Den version that added each web route a tool reads. The web server keeps
526
+ * running its old code until it is restarted, so an MCP server can be newer
527
+ * than the web server it talks to.
528
+ */
529
+ export const ROUTE_SINCE: Readonly<Record<string, string>> = {
530
+ "/api/stacks": "0.2.0",
531
+ "/api/edge": "0.2.0",
532
+ "/api/security/plan": "0.2.0",
533
+ };
534
+
535
+ /** `a` is older than `b`, by major.minor.patch (a pre-release counts as its release). */
536
+ export function versionBefore(a: string, b: string): boolean {
537
+ const core = (v: string) =>
538
+ v
539
+ .split("-")[0]
540
+ .split(".")
541
+ .map((n) => parseInt(n, 10) || 0);
542
+ const [x, y] = [core(a), core(b)];
543
+ for (let i = 0; i < 3; i++)
544
+ if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
545
+ return false;
546
+ }
547
+
548
+ /** The running web server's version, or undefined when it reports none. */
549
+ async function webServerVersion(api: DenApi): Promise<string | undefined> {
550
+ try {
551
+ const info = (await api.get("/api/server-info")) as { version?: unknown };
552
+ return typeof info?.version === "string" ? info.version : undefined;
553
+ } catch (e) {
554
+ if (e instanceof DenHttpError) return undefined;
555
+ throw e;
556
+ }
557
+ }
558
+
559
+ /**
560
+ * What a 404 means for `tool`. For a route newer than the web server (read
561
+ * from /api/server-info only now, once a call has 404'd), the restart that
562
+ * fixes it; otherwise the plain error.
563
+ */
564
+ async function notFoundMessage(
565
+ e: DenHttpError,
566
+ tool: string,
567
+ ctx: ToolContext,
568
+ ): Promise<string> {
569
+ const plain = `Error: ${tool}: the web server answered 404 for ${e.method} ${e.path}: ${e.body}`;
570
+ const since = ROUTE_SINCE[e.path];
571
+ if (!since) return plain;
572
+ const version = await webServerVersion(ctx.api);
573
+ if (version !== undefined && !versionBefore(version, since)) return plain;
574
+ return (
575
+ `web server is Den ${version ?? `< ${since}`}; ` +
576
+ `${tool} needs >= ${since}: run ${ctx.cli} web restart`
577
+ );
578
+ }
579
+
580
+ /**
581
+ * Run one tool: its result as (redacted) text, or an error naming the guard
582
+ * that refused, how to start the web server, or (when it runs an older Den
583
+ * than the tool needs) how to restart it.
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
+
607
+ export async function callTool(
608
+ name: string,
609
+ args: Args,
610
+ ctx: ToolContext,
611
+ ): Promise<{ text: string; isError: boolean }> {
612
+ const tool = toolsFor(ctx.cfg).find((t) => t.name === name);
613
+ if (!tool)
614
+ return {
615
+ text: `Unknown or disabled tool "${name}". Actions must be named in mcp.actions.`,
616
+ isError: true,
617
+ };
618
+ try {
619
+ const out = await tool.run(args, ctx);
620
+ const text = typeof out === "string" ? out : JSON.stringify(out, null, 2);
621
+ return { text: redactSecrets(text), isError: false };
622
+ } catch (e) {
623
+ if (e instanceof ActionFailed)
624
+ return {
625
+ text: redactSecrets(JSON.stringify(e.out, null, 2)),
626
+ isError: true,
627
+ };
628
+ if (e instanceof DenHttpError && e.status === 404) {
629
+ const text = await notFoundMessage(e, name, ctx).catch(
630
+ (inner: Error) => `Error: ${inner.message}`,
631
+ );
632
+ return { text: redactSecrets(text), isError: true };
633
+ }
634
+ if (e instanceof Refusal && tool.action)
635
+ await reportRefusal(ctx, name, args, e.message);
636
+ const msg =
637
+ e instanceof Refusal
638
+ ? `Refused: ${e.message}`
639
+ : e instanceof DenWebDown
640
+ ? e.message
641
+ : `Error: ${(e as Error).message}`;
642
+ return { text: redactSecrets(msg), isError: true };
643
+ }
644
+ }