@monster-orange/den 0.4.3 → 0.4.5

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 (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -191,6 +191,20 @@ function grantsNote(scope: McpScope): string {
191
191
  : "no grants";
192
192
  }
193
193
 
194
+ /**
195
+ * How a person gives `scope`'s agent a grant to `other` (MCP-2.3, AG spec
196
+ * §5b): config in git, then a restart. An agent never grants itself one.
197
+ */
198
+ export function grantHint(scope: McpScope, other: string): string {
199
+ const id = scope.actor.slice("agent:".length);
200
+ return (
201
+ `To reach it, a person adds "${printable(other)}" to agents.${id}.grants ` +
202
+ `in the org config and restarts the agent (agents start passes --grant); ` +
203
+ "an agent can't grant itself one. For names and status counts of every " +
204
+ "workspace, call org_summary."
205
+ );
206
+ }
207
+
194
208
  /** The refusal for a call that names `other`; it names the agent's grants. */
195
209
  export function otherWorkspace(
196
210
  tool: string,
@@ -199,7 +213,8 @@ export function otherWorkspace(
199
213
  ): ScopeRefusal {
200
214
  return new ScopeRefusal(
201
215
  `${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
202
- `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run.`,
216
+ `(${scope.actor}; ${grantsNote(scope)}); "${printable(other)}" is another workspace, so nothing was read or run. ` +
217
+ grantHint(scope, other),
203
218
  );
204
219
  }
205
220
 
@@ -299,8 +314,10 @@ function focusedOf(body: unknown): string | null {
299
314
  export async function assertScopeExists(
300
315
  api: DenApi,
301
316
  scope: McpScope,
317
+ /** An /api/workspaces body already read (else it is read now). */
318
+ read?: unknown,
302
319
  ): Promise<unknown> {
303
- const body = await api.get("/api/workspaces");
320
+ const body = read ?? (await api.get("/api/workspaces"));
304
321
  if (!(scope.workspace in asRecord(asRecord(body).workspaces)))
305
322
  throw new ScopeRefusal(
306
323
  `this MCP server is scoped to workspace "${scope.workspace}", which does not exist in this distribution.`,
@@ -375,7 +392,10 @@ export async function assertServicesInScope(
375
392
  if (outside.length)
376
393
  throw new ScopeRefusal(
377
394
  `${tool}: ${outside.map(printable).join(", ")} ${outside.length === 1 ? "is" : "are"} not a service ` +
378
- `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
395
+ `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read. ` +
396
+ "Logs and service details stay inside each workspace (logs can hold " +
397
+ "secrets), and a grant does not widen them; org_summary has every " +
398
+ "workspace's status counts.",
379
399
  );
380
400
  }
381
401
 
@@ -49,7 +49,9 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
49
49
  const p = product();
50
50
  const session = opts.session ?? randomUUID();
51
51
  const log = opts.log ?? ((l: string) => process.stderr.write(`${l}\n`));
52
- const tools = toolsFor(opts.cfg);
52
+ // Scoped, each workspace tool is registered pinned (MCP-2.2): "this
53
+ // workspace only", and no workspace parameter unless there are grants.
54
+ const tools = toolsFor(opts.cfg, opts.scope);
53
55
  const server = new McpServer(
54
56
  { name: p.name, version: engineVersion() ?? "0.0.0" },
55
57
  {
@@ -61,8 +63,11 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
61
63
  : "Read-only: the org has enabled no actions (mcp.actions).") +
62
64
  (opts.scope
63
65
  ? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
64
- "a call naming another workspace is refused, and lists show this one only."
65
- : ""),
66
+ "a call naming another workspace is refused, and lists show this one only, " +
67
+ "each with a `scope` block saying how much is hidden. org_summary has " +
68
+ "every workspace and repo (names and status counts), the graph, the " +
69
+ "edge and the environments: start there for the whole picture."
70
+ : " org_summary has the whole org at a glance: start there."),
66
71
  },
67
72
  );
68
73
 
@@ -222,8 +227,10 @@ export async function runOneShot(
222
227
  opts: OneShotOptions,
223
228
  ): Promise<number> {
224
229
  const out = opts.out ?? ((t: string) => process.stdout.write(t));
230
+ // The tools exactly as the stdio server registers them: pinned when scoped.
231
+ const tools = toolsFor(opts.cfg, opts.scope);
225
232
  if (shot.kind === "list") {
226
- const names = toolsFor(opts.cfg).map((t) => t.name);
233
+ const names = tools.map((t) => t.name);
227
234
  out(names.length ? `${names.join("\n")}\n` : "");
228
235
  return 0;
229
236
  }
@@ -235,11 +242,18 @@ export async function runOneShot(
235
242
  };
236
243
  // The stdio server validates arguments against each tool's schema (the
237
244
  // SDK does it); a one-shot must too, and strictly: it is typed by hand, so
238
- // an unknown or misspelt key is an error, not silently dropped.
245
+ // an unknown or misspelt key is an error, not silently dropped. Scoped,
246
+ // the schema is the pinned one; a `workspace` it dropped (no grants) is
247
+ // still taken, so naming another workspace gets Den's refusal (callTool),
248
+ // never a silent answer about the scope's own.
239
249
  let args = shot.args;
240
- const tool = toolsFor(opts.cfg).find((t) => t.name === shot.tool);
250
+ const tool = tools.find((t) => t.name === shot.tool);
241
251
  if (tool) {
242
- const parsed = z.object(tool.shape).strict().safeParse(args);
252
+ const shape =
253
+ opts.scope && !("workspace" in tool.shape) && "workspace" in args
254
+ ? { ...tool.shape, workspace: z.string().optional() }
255
+ : tool.shape;
256
+ const parsed = z.object(shape).strict().safeParse(args);
243
257
  if (!parsed.success) {
244
258
  const err = opts.err ?? ((t: string) => process.stderr.write(t));
245
259
  const lines = parsed.error.issues.map((i) => {
@@ -384,6 +398,16 @@ export async function runMcp(rawArgv: string[]): Promise<void> {
384
398
  ...scopeInfo(scope ?? undefined),
385
399
  }),
386
400
  });
401
+ // Say the scope on stderr (stdout is the result): a one-shot that ran
402
+ // unscoped must never pass for a scoped one.
403
+ if (scope)
404
+ process.stderr.write(
405
+ `${cli} mcp --call: scoped to ${scope.workspace} as ${scope.actor}` +
406
+ (scope.grants?.length
407
+ ? ` · grants: ${scope.grants.join(", ")}`
408
+ : "") +
409
+ "\n",
410
+ );
387
411
  process.exitCode = await runOneShot(shot, {
388
412
  cfg,
389
413
  cli,
@@ -45,6 +45,14 @@ import {
45
45
  pinWorkspace,
46
46
  type McpScope,
47
47
  } from "./scope.js";
48
+ import {
49
+ graphInScope,
50
+ mergeGraphs,
51
+ orgSummary,
52
+ scopeBlock,
53
+ statusView,
54
+ workspaceRepos,
55
+ } from "./views.js";
48
56
 
49
57
  export interface ToolContext {
50
58
  api: DenApi;
@@ -173,37 +181,104 @@ const READ_TOOLS: DenTool[] = [
173
181
  read(
174
182
  "status",
175
183
  "Service health",
176
- "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 or a container check (`container <project>/<service>`).",
184
+ "Health of every service in the workspace (what `status` shows): `counts`, then each service's `state` (up, down, redirect, pending, unknown, not-checked; `why` when there is no plain verdict), mode, and what was checked: `url` for an HTTP check, `check` for a shell command or a container check (`container <project>/<service>`). A redirect (302 → /login) answers, so it is not down.",
177
185
  { env: envArg, workspace: workspaceArg },
178
- (a, { api }) =>
179
- api.get("/api/health", { env: envOf(a), workspace: str(a.workspace) }),
186
+ async (a, { api, scope }) => {
187
+ const env = envOf(a);
188
+ const view = statusView(
189
+ await api.get("/api/health", { env, workspace: str(a.workspace) }),
190
+ env,
191
+ );
192
+ return scope
193
+ ? await withScope(api, scope, a, view, {
194
+ count: (view.services as unknown[]).length,
195
+ noun: "service",
196
+ })
197
+ : view;
198
+ },
180
199
  ),
181
200
  read(
182
201
  "services",
183
202
  "Services",
184
203
  "The workspace's services: repo, service, mode (managed or dev-managed), lifecycle commands, dependencies.",
185
204
  { env: envArg, workspace: workspaceArg },
186
- (a, { api }) =>
187
- api.get("/api/services", { env: envOf(a), workspace: str(a.workspace) }),
205
+ async (a, { api, scope }) => {
206
+ const list = await api.get("/api/services", {
207
+ env: envOf(a),
208
+ workspace: str(a.workspace),
209
+ });
210
+ if (!scope) return list;
211
+ const services = Array.isArray(list) ? list : [];
212
+ return withScope(
213
+ api,
214
+ scope,
215
+ a,
216
+ { services },
217
+ { count: services.length, noun: "service" },
218
+ );
219
+ },
188
220
  ),
189
221
  read(
190
222
  "applications",
191
223
  "Applications",
192
224
  "Org > Application > Repo > Services for a workspace (what `apps --json` shows).",
193
225
  { workspace: workspaceArg },
194
- (a, { api }) =>
195
- api.get("/api/applications", { workspace: str(a.workspace) }),
226
+ async (a, { api, scope }) => {
227
+ const tree = await api.get("/api/applications", {
228
+ workspace: str(a.workspace),
229
+ });
230
+ if (!scope) return tree;
231
+ const t =
232
+ tree && typeof tree === "object" && !Array.isArray(tree)
233
+ ? (tree as Record<string, unknown>)
234
+ : { applications: tree };
235
+ const apps = Array.isArray(t.applications) ? t.applications : [];
236
+ return withScope(api, scope, a, t, {
237
+ count: apps.length,
238
+ noun: "application",
239
+ });
240
+ },
196
241
  ),
197
242
  read(
198
243
  "graph",
199
244
  "Dependency graph",
200
- "The derived dependency graph and its startup order; `seed` narrows it to one repo's closure.",
245
+ "The derived dependency graph; `seed` narrows it to one repo's closure. Unscoped with no `workspace` it is the org-wide map (`map: \"org-wide\"`: every active workspace's repos and edges, `workspaces` naming them; `order` only when one is active), with `workspace` that workspace's (`map: \"workspace\"`, with its startup order). Scoped, the workspace's own repos and the edges out of them (`outside` names the repos they depend on elsewhere; the org-wide map is `org_summary.graph`).",
201
246
  {
202
247
  seed: z.string().optional().describe("Repo whose closure to show"),
203
248
  workspace: workspaceArg,
204
249
  },
205
- (a, { api }) =>
206
- api.get("/api/graph", { seed: str(a.seed), workspace: str(a.workspace) }),
250
+ async (a, { api, scope }) => {
251
+ // Unscoped and naming nothing: the org-wide map, every active
252
+ // workspace's graph merged (the web server derives one per workspace).
253
+ if (!scope && !str(a.seed) && !str(a.workspace))
254
+ return orgGraph(api, (p, q) => api.get(p, q));
255
+ const graph = await api.get("/api/graph", {
256
+ seed: str(a.seed),
257
+ workspace: str(a.workspace),
258
+ });
259
+ if (!scope)
260
+ return graph && typeof graph === "object" && !Array.isArray(graph)
261
+ ? {
262
+ map: str(a.seed) ? "closure" : "workspace",
263
+ ...(str(a.workspace) ? { workspace: str(a.workspace) } : {}),
264
+ ...(graph as Record<string, unknown>),
265
+ }
266
+ : graph;
267
+ const body = await scopeWorkspacesBody(api, scope);
268
+ const shown = String(a.workspace);
269
+ const view = graphInScope(graph, workspaceRepos(body, shown));
270
+ const { hiddenNodes, ...rest } = view;
271
+ return {
272
+ ...rest,
273
+ scope: scopeBlock(
274
+ scope,
275
+ body,
276
+ shown,
277
+ { count: (view.nodes as unknown[]).length, noun: "repo" },
278
+ { graphNodes: hiddenNodes as number },
279
+ ),
280
+ };
281
+ },
207
282
  ),
208
283
  read(
209
284
  "workspaces",
@@ -212,7 +287,44 @@ const READ_TOOLS: DenTool[] = [
212
287
  {},
213
288
  async (_a, { api, scope }) => {
214
289
  const body = await api.get("/api/workspaces");
215
- return scope ? filterWorkspacesBody(body, scope) : body;
290
+ if (!scope) return body;
291
+ const filtered = filterWorkspacesBody(body, scope);
292
+ return {
293
+ ...filtered,
294
+ scope: {
295
+ ...(filtered.scope as Record<string, unknown>),
296
+ ...scopeBlock(scope, body, scope.workspace, {
297
+ count: 1,
298
+ noun: "workspace",
299
+ }),
300
+ },
301
+ };
302
+ },
303
+ ),
304
+ read(
305
+ "org_summary",
306
+ "Org summary",
307
+ 'The whole org at a glance, for every agent: each workspace and repo with status counts (running, partial, stopped; up, down, redirect, not checked), the dependency graph (names and edge kinds), the edge (up, TLS, whether HTTPS serves) and the environments. Scoped, `yours` adds your own workspace\'s services in full. It never carries logs, env values, commands, URLs or check output from outside your workspace. Start here for "what are our servers?".',
308
+ {},
309
+ async (_a, { api, scope }) => {
310
+ const workspaces = await api.get("/api/workspaces");
311
+ const [health, graph, edge, environments, own] = await Promise.all([
312
+ api.get("/api/health", { env: "local" }),
313
+ // Names and edge kinds only (orgSummary drops the evidence).
314
+ orgGraph(api, (p, q) => softGet(api, p, q), workspaces),
315
+ softGet(api, "/api/edge"),
316
+ api.get("/api/environments"),
317
+ scope
318
+ ? api.get("/api/health", {
319
+ env: "local",
320
+ workspace: scope.workspace,
321
+ })
322
+ : Promise.resolve(undefined),
323
+ ]);
324
+ return orgSummary(
325
+ { workspaces, health, graph, edge, environments, own },
326
+ scope,
327
+ );
216
328
  },
217
329
  ),
218
330
  read(
@@ -372,7 +484,7 @@ const READ_TOOLS: DenTool[] = [
372
484
  read(
373
485
  "stacks",
374
486
  "Stacks",
375
- "Compose stacks (what `stack ls/ps/health` show): name, project, routes and container health.",
487
+ "Compose stacks (what `stack ls/ps/health` show): name, project, routes and container health. An empty list says why (`why`).",
376
488
  {
377
489
  workspace: workspaceArg,
378
490
  health: z
@@ -380,21 +492,151 @@ const READ_TOOLS: DenTool[] = [
380
492
  .optional()
381
493
  .describe("Check container health (default true)"),
382
494
  },
383
- (a, { api }) =>
384
- api.get("/api/stacks", {
495
+ async (a, { api, scope }) => {
496
+ const body = (await api.get("/api/stacks", {
385
497
  workspace: str(a.workspace),
386
498
  health: a.health === false ? "0" : "1",
387
- }),
499
+ })) as Record<string, unknown>;
500
+ const all = Array.isArray(body?.stacks)
501
+ ? (body.stacks as { repo?: unknown }[])
502
+ : [];
503
+ if (!scope) {
504
+ if (all.length || body?.why !== undefined) return body;
505
+ return {
506
+ ...body,
507
+ why: "no stacks (the web server gave no reason; it predates 0.4.4)",
508
+ };
509
+ }
510
+ const wsBody = await scopeWorkspacesBody(api, scope);
511
+ const shown = String(a.workspace);
512
+ const mine = new Set(workspaceRepos(wsBody, shown));
513
+ const stacks = all.filter((s) => mine.has(String(s.repo)));
514
+ const hiddenStacks = all.length - stacks.length;
515
+ const why = stacks.length
516
+ ? undefined
517
+ : hiddenStacks
518
+ ? `no stacks in workspace "${shown}": the ${hiddenStacks} stack${hiddenStacks === 1 ? "" : "s"} listed belong to repos outside it`
519
+ : ((body?.why as string | undefined) ??
520
+ "no stacks (the web server gave no reason; it predates 0.4.4)");
521
+ return withScope(
522
+ api,
523
+ scope,
524
+ a,
525
+ { ...body, stacks, ...(why ? { why } : {}) },
526
+ { count: stacks.length, noun: "stack" },
527
+ { stacks: hiddenStacks },
528
+ );
529
+ },
388
530
  ),
389
531
  read(
390
532
  "edge_status",
391
533
  "Edge status",
392
- "The HTTPS edge: up or down, its routes and its cert names.",
534
+ "The HTTPS edge: up or down, its routes and its cert names. `https` says the TLS strategy, who supplies the cert (den, or the distribution under tls strategy files) and whether HTTPS is serving; `certIssuedByDen` replaces the deprecated `certIssued`.",
393
535
  {},
394
- (_a, { api }) => api.get("/api/edge"),
536
+ async (_a, { api }) => {
537
+ const edge = await api.get("/api/edge");
538
+ const e =
539
+ edge && typeof edge === "object"
540
+ ? (edge as Record<string, unknown>)
541
+ : null;
542
+ // A web server before 0.4.4: say what certIssued means.
543
+ if (e && e.certIssued !== undefined && e.https === undefined)
544
+ return {
545
+ ...e,
546
+ certIssuedMeans:
547
+ "whether Den minted the cert itself (mkcert); false under tls strategy files, where the distribution supplies it, even while HTTPS serves. Restart the web server for https.serving.",
548
+ };
549
+ return edge;
550
+ },
395
551
  ),
396
552
  ];
397
553
 
554
+ /**
555
+ * The /api/workspaces body a scoped answer is counted against, read at most
556
+ * once every few seconds per scope (one tool call often makes several).
557
+ */
558
+ const WORKSPACES_TTL_MS = 5_000;
559
+ const workspacesSeen = new WeakMap<McpScope, { at: number; body: unknown }>();
560
+ async function scopeWorkspacesBody(
561
+ api: DenApi,
562
+ scope: McpScope,
563
+ ): Promise<unknown> {
564
+ const hit = workspacesSeen.get(scope);
565
+ if (hit && Date.now() - hit.at < WORKSPACES_TTL_MS) return hit.body;
566
+ const body = await api.get("/api/workspaces");
567
+ workspacesSeen.set(scope, { at: Date.now(), body });
568
+ return body;
569
+ }
570
+
571
+ /** `answer` with the scope it was served under (MCP-2.2). */
572
+ async function withScope(
573
+ api: DenApi,
574
+ scope: McpScope,
575
+ args: Args,
576
+ answer: Record<string, unknown>,
577
+ shows: { count: number; noun: string },
578
+ extraHidden: Record<string, number> = {},
579
+ ): Promise<Record<string, unknown>> {
580
+ const body = await scopeWorkspacesBody(api, scope);
581
+ return {
582
+ scope: scopeBlock(
583
+ scope,
584
+ body,
585
+ str(args.workspace) ?? scope.workspace,
586
+ shows,
587
+ extraHidden,
588
+ ),
589
+ ...answer,
590
+ };
591
+ }
592
+
593
+ /** A read whose HTTP error is part of the answer, not the whole call's. */
594
+ async function softGet(
595
+ api: DenApi,
596
+ path: string,
597
+ query?: Query,
598
+ ): Promise<unknown> {
599
+ try {
600
+ return await api.get(path, query);
601
+ } catch (e) {
602
+ if (e instanceof DenHttpError)
603
+ return { error: `${e.status} from ${e.path}` };
604
+ throw e;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * The org-wide graph: each active workspace's /api/graph, merged
610
+ * (mergeGraphs). With no active workspace, the web server's default one.
611
+ * `read`: an /api/workspaces body already read.
612
+ */
613
+ async function orgGraph(
614
+ api: DenApi,
615
+ get: (path: string, query?: Query) => Promise<unknown>,
616
+ read?: unknown,
617
+ ): Promise<unknown> {
618
+ const body = read ?? (await api.get("/api/workspaces"));
619
+ const active =
620
+ body &&
621
+ typeof body === "object" &&
622
+ Array.isArray((body as Record<string, unknown>).active)
623
+ ? ((body as Record<string, unknown>).active as unknown[]).map(String)
624
+ : [];
625
+ if (!active.length) {
626
+ const g = await get("/api/graph");
627
+ return g && typeof g === "object" && !Array.isArray(g)
628
+ ? { map: "org-wide", ...(g as Record<string, unknown>) }
629
+ : g;
630
+ }
631
+ const graphs = await Promise.all(
632
+ active.map(async (workspace) => ({
633
+ workspace,
634
+ graph: await get("/api/graph", { workspace }),
635
+ })),
636
+ );
637
+ return mergeGraphs(graphs);
638
+ }
639
+
398
640
  // ── Action tools ────────────────────────────────────────────────────────────
399
641
 
400
642
  /** Find the /api/services entry an action names (repo + optional service). */
@@ -580,13 +822,73 @@ export const READ_TOOL_NAMES: readonly string[] = READ_TOOLS.map((t) => t.name);
580
822
  export const ACTION_TOOL_NAMES: readonly McpAction[] = MCP_ACTIONS;
581
823
 
582
824
  /** The tools to register: every read tool, plus the actions the org named. */
583
- export function toolsFor(cfg: ResolvedMcpConfig): DenTool[] {
584
- return [
825
+ export function toolsFor(cfg: ResolvedMcpConfig, scope?: McpScope): DenTool[] {
826
+ const tools = [
585
827
  ...READ_TOOLS,
586
828
  ...MCP_ACTIONS.filter((a) => cfg.actions.includes(a)).map(
587
829
  (a) => ACTION_TOOLS[a],
588
830
  ),
589
831
  ];
832
+ return scope ? tools.map((t) => pinnedTool(t, scope)) : tools;
833
+ }
834
+
835
+ /**
836
+ * Tools that answer about one workspace, and so are "this workspace only"
837
+ * on a scoped server. The rest are the org's (environments, the edge,
838
+ * releases, security scans, the org summary) or take no workspace data.
839
+ */
840
+ const WORKSPACE_TOOLS = new Set([
841
+ "status",
842
+ "services",
843
+ "applications",
844
+ "graph",
845
+ "workspaces",
846
+ "logs_search",
847
+ "logs_tail",
848
+ "tests_available",
849
+ "manifest_validate",
850
+ "stacks",
851
+ "service_up",
852
+ "service_down",
853
+ "service_restart",
854
+ "workspace_sync",
855
+ "run_tests",
856
+ ]);
857
+
858
+ /**
859
+ * A tool as a scoped server registers it (MCP-2.2): its description says
860
+ * "this workspace only", and its `workspace` parameter is dropped, since it
861
+ * can only be the pinned one. With grants the parameter stays, to name a
862
+ * granted workspace, and says which those are. callTool still refuses a
863
+ * `workspace` naming another one (a one-shot or an old client may send it).
864
+ */
865
+ export function pinnedTool(t: DenTool, scope: McpScope): DenTool {
866
+ if (!WORKSPACE_TOOLS.has(t.name)) return t;
867
+ const grants = scope.grants ?? [];
868
+ const only =
869
+ ` This workspace only ("${scope.workspace}"` +
870
+ (grants.length ? `, or a granted one: ${grants.join(", ")}` : "") +
871
+ "); every other workspace is in org_summary, names and counts only.";
872
+ let shape = t.shape;
873
+ if ("workspace" in t.shape) {
874
+ const rest = { ...t.shape };
875
+ delete rest.workspace;
876
+ // A string, not an enum of the allowed names: a schema rejection
877
+ // ("expected one of …") would hide Den's refusal, which names
878
+ // agents.<id>.grants and how a person grants one (MCP-2.3).
879
+ shape = grants.length
880
+ ? {
881
+ ...rest,
882
+ workspace: z
883
+ .string()
884
+ .optional()
885
+ .describe(
886
+ `"${scope.workspace}" (the default) or a granted workspace: ${grants.map((g) => `"${g}"`).join(", ")}; any other is refused`,
887
+ ),
888
+ }
889
+ : rest;
890
+ }
891
+ return { ...t, description: t.description + only, shape };
590
892
  }
591
893
 
592
894
  /**
@@ -719,7 +1021,12 @@ async function scopeArgs(
719
1021
  scope: McpScope,
720
1022
  ): Promise<Args> {
721
1023
  if (!verifiedScopes.has(scope)) {
722
- await assertScopeExists(ctx.api, scope);
1024
+ // The body is the one scoped answers count against (one read).
1025
+ await assertScopeExists(
1026
+ ctx.api,
1027
+ scope,
1028
+ await scopeWorkspacesBody(ctx.api, scope),
1029
+ );
723
1030
  verifiedScopes.add(scope);
724
1031
  }
725
1032
  if (!("workspace" in tool.shape)) return args;