@monster-orange/den 0.2.7 → 0.2.8

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.
@@ -9,6 +9,9 @@
9
9
  import chalk from "chalk";
10
10
  import { product } from "../../shared/product.js";
11
11
  import { listOrgs } from "../../shared/manifest/service-links.js";
12
+ import { githubOwnersOf } from "../../shared/org-sources.js";
13
+ import { readDenConfig } from "../../shared/den-config.js";
14
+ import { isConfigError } from "../../shared/config-error.js";
12
15
  import {
13
16
  defaultOrg,
14
17
  githubOrgFor,
@@ -107,10 +110,17 @@ export function registerOrg(
107
110
  ctx?: OrgContext,
108
111
  ): string | undefined {
109
112
  let org = orgArg?.trim();
110
- if (!org) {
113
+ if (org) {
114
+ // ORG-1d: an org id names its first GitHub source's owner; an org with
115
+ // no GitHub source has nothing to list.
116
+ org = githubOwnersOf(org, (ctx?.config ?? readDenConfig()).orgs)[0];
117
+ if (!org) return undefined;
118
+ } else {
111
119
  try {
112
120
  org = githubOrgFor(workspace, ctx);
113
- } catch {
121
+ } catch (e) {
122
+ // No org is "nothing to register"; a config that fails validation is not.
123
+ if (isConfigError(e)) throw e;
114
124
  return undefined;
115
125
  }
116
126
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `up -w <ws>` with no repo, no `-a` and no service repo under the cwd
3
+ * (0.2.8-dev.1). `-w` only picks the workspace; it does not say what in it
4
+ * to start, so the plan used to come back "No services specified and no
5
+ * invocation target resolvable". It is a usage error naming the two forms
6
+ * that do: a repo, or `-a` for the whole workspace.
7
+ */
8
+ export function upNeedsTarget(cli: string, ws: string, plan: boolean): string {
9
+ const name = /^[A-Za-z0-9._-]{1,64}$/.test(ws) ? ws : "<workspace>";
10
+ const p = plan ? " --plan" : "";
11
+ return (
12
+ `up -w ${name} needs what to start: \`${cli} up -a${p} -w ${name}\` ` +
13
+ `for the whole workspace, or \`${cli} up <repo>${p} -w ${name}\` for one repo and its dependencies.`
14
+ );
15
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * A scoped MCP server (AG-2a-core): `<cli> mcp --workspace <ws> --actor
3
+ * agent:<id>`. It pins one workspace, refuses any call that names another,
4
+ * filters lists to it, and stamps the actor on every web API call (the
5
+ * x-den-mcp-agent / x-den-mcp-workspace headers, see shared/agent-actor.ts).
6
+ *
7
+ * It binds cooperating agents only: the scope lives in this process, and the
8
+ * web server's HTTP API does not check it yet (WS-3a).
9
+ */
10
+ import * as path from "path";
11
+ import { AGENT_ACTOR_RE, WORKSPACE_NAME_RE } from "../../shared/agent-actor.js";
12
+ import type { DenApi } from "./api.js";
13
+ import { Refusal } from "./guards.js";
14
+
15
+ export interface McpScope {
16
+ /** The one workspace this server serves. */
17
+ workspace: string;
18
+ /** Who acts: `agent:<id>`. */
19
+ actor: string;
20
+ }
21
+
22
+ /** A call reached outside the scoped workspace. Always reported (F2). */
23
+ export class ScopeRefusal extends Refusal {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "ScopeRefusal";
27
+ }
28
+ }
29
+
30
+ /** `--workspace` / `--actor` were given wrongly: a usage error (exit 2). */
31
+ export class ScopeUsageError extends Error {
32
+ constructor(message: string) {
33
+ super(message);
34
+ this.name = "ScopeUsageError";
35
+ }
36
+ }
37
+
38
+ const FLAGS: Record<string, "workspace" | "actor"> = {
39
+ "--workspace": "workspace",
40
+ "-w": "workspace",
41
+ "--actor": "actor",
42
+ };
43
+
44
+ /**
45
+ * Take `--workspace <ws>` (or `-w`) and `--actor agent:<id>` out of `argv`,
46
+ * also in the `--flag=value` form. Both or neither: a scope without an actor
47
+ * would act unattributed, an actor without a scope would look bound when it
48
+ * is not. Returns the scope (null when neither is given) and the rest of
49
+ * argv, unchanged otherwise.
50
+ */
51
+ export function parseScopeFlags(argv: readonly string[]): {
52
+ scope: McpScope | null;
53
+ rest: string[];
54
+ } {
55
+ const got: Partial<Record<"workspace" | "actor", string>> = {};
56
+ const rest: string[] = [];
57
+ for (let i = 0; i < argv.length; i++) {
58
+ const a = argv[i];
59
+ const eq = a.indexOf("=");
60
+ const name = a.startsWith("-") && eq !== -1 ? a.slice(0, eq) : a;
61
+ const key = FLAGS[name];
62
+ if (!key) {
63
+ rest.push(a);
64
+ continue;
65
+ }
66
+ let value: string | undefined;
67
+ if (name !== a) value = a.slice(eq + 1);
68
+ else {
69
+ value = argv[i + 1];
70
+ if (value === undefined || value.startsWith("-"))
71
+ throw new ScopeUsageError(`${name} needs a value`);
72
+ i++;
73
+ }
74
+ if (got[key] !== undefined && got[key] !== value)
75
+ throw new ScopeUsageError(`--${key} given twice`);
76
+ got[key] = value;
77
+ }
78
+ if (got.workspace === undefined && got.actor === undefined)
79
+ return { scope: null, rest };
80
+ if (got.workspace === undefined)
81
+ throw new ScopeUsageError(
82
+ "--actor needs --workspace: a scoped server serves one workspace",
83
+ );
84
+ if (got.actor === undefined)
85
+ throw new ScopeUsageError(
86
+ "--workspace needs --actor agent:<id>: a scoped server says who acts",
87
+ );
88
+ if (!WORKSPACE_NAME_RE.test(got.workspace))
89
+ throw new ScopeUsageError(
90
+ `--workspace "${printable(got.workspace)}" is not a workspace name ` +
91
+ "(letters, digits, dots, dashes, underscores; at most 64)",
92
+ );
93
+ if (!AGENT_ACTOR_RE.test(got.actor))
94
+ throw new ScopeUsageError(
95
+ `--actor "${printable(got.actor)}" is not agent:<id> ` +
96
+ "(the id: letters, digits, dots, dashes, underscores; at most 64)",
97
+ );
98
+ return { scope: { workspace: got.workspace, actor: got.actor }, rest };
99
+ }
100
+
101
+ /** A value echoed back in an error: one printable line, bounded. */
102
+ function printable(v: string): string {
103
+ return v.replace(/[^\x20-\x7e]/g, "?").slice(0, 80);
104
+ }
105
+
106
+ /** The refusal for a call that names `other`. */
107
+ export function otherWorkspace(
108
+ tool: string,
109
+ scope: McpScope,
110
+ other: string,
111
+ ): ScopeRefusal {
112
+ return new ScopeRefusal(
113
+ `${tool}: this MCP server is scoped to workspace "${scope.workspace}" ` +
114
+ `(${scope.actor}); "${printable(other)}" is another workspace, so nothing was read or run.`,
115
+ );
116
+ }
117
+
118
+ /**
119
+ * A tool's arguments with the workspace pinned: absent, it becomes the
120
+ * scoped one; another one is refused before anything is asked of the API.
121
+ */
122
+ export function pinWorkspace(
123
+ tool: string,
124
+ args: Record<string, unknown>,
125
+ scope: McpScope,
126
+ ): Record<string, unknown> {
127
+ const ws = args.workspace;
128
+ if (ws !== undefined && ws !== null && ws !== "" && ws !== scope.workspace)
129
+ throw otherWorkspace(tool, scope, String(ws));
130
+ return { ...args, workspace: scope.workspace };
131
+ }
132
+
133
+ /** What /api/workspaces answers (the fields the scope reads). */
134
+ interface WorkspacesBody {
135
+ active?: unknown;
136
+ focus?: unknown;
137
+ workspaces?: unknown;
138
+ [k: string]: unknown;
139
+ }
140
+
141
+ const asRecord = (v: unknown): Record<string, unknown> =>
142
+ v && typeof v === "object" && !Array.isArray(v)
143
+ ? (v as Record<string, unknown>)
144
+ : {};
145
+
146
+ /**
147
+ * /api/workspaces narrowed to the scoped workspace: only its entry, its
148
+ * resolved directory and code-workspace path, `active` holding it or empty,
149
+ * and `focus` only when it is the focus. Everything else is left out.
150
+ */
151
+ export function filterWorkspacesBody(
152
+ body: unknown,
153
+ scope: McpScope,
154
+ ): Record<string, unknown> {
155
+ const b = asRecord(body) as WorkspacesBody;
156
+ const ws = scope.workspace;
157
+ const only = (v: unknown): Record<string, unknown> => {
158
+ const r = asRecord(v);
159
+ return ws in r ? { [ws]: r[ws] } : {};
160
+ };
161
+ return {
162
+ active: Array.isArray(b.active) ? b.active.filter((n) => n === ws) : [],
163
+ ...(b.focus === ws ? { focus: ws } : {}),
164
+ workspaces: only(b.workspaces),
165
+ ...(b.resolvedDirs !== undefined
166
+ ? { resolvedDirs: only(b.resolvedDirs) }
167
+ : {}),
168
+ ...(b.codeWorkspacePaths !== undefined
169
+ ? { codeWorkspacePaths: only(b.codeWorkspacePaths) }
170
+ : {}),
171
+ scope: { workspace: ws, actor: scope.actor },
172
+ };
173
+ }
174
+
175
+ /** The workspace /api/workspaces says is focused (focus, else active[0]). */
176
+ function focusedOf(body: unknown): string | null {
177
+ const b = asRecord(body) as WorkspacesBody;
178
+ if (typeof b.focus === "string" && b.focus) return b.focus;
179
+ if (Array.isArray(b.active) && typeof b.active[0] === "string")
180
+ return b.active[0];
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Throws unless the scoped workspace exists in this distribution. A missing
186
+ * one must not fall through to the web server's "no workspace" defaults,
187
+ * which list services outside any workspace.
188
+ */
189
+ export async function assertScopeExists(
190
+ api: DenApi,
191
+ scope: McpScope,
192
+ ): Promise<unknown> {
193
+ const body = await api.get("/api/workspaces");
194
+ if (!(scope.workspace in asRecord(asRecord(body).workspaces)))
195
+ throw new ScopeRefusal(
196
+ `this MCP server is scoped to workspace "${scope.workspace}", which does not exist in this distribution.`,
197
+ );
198
+ return body;
199
+ }
200
+
201
+ /**
202
+ * Tests run against the focused workspace (the web server has no workspace
203
+ * argument for them): a scoped server runs or lists them only when that is
204
+ * its own.
205
+ */
206
+ export async function assertScopeFocused(
207
+ tool: string,
208
+ api: DenApi,
209
+ scope: McpScope,
210
+ ): Promise<void> {
211
+ const focused = focusedOf(await api.get("/api/workspaces"));
212
+ if (focused !== scope.workspace)
213
+ throw new ScopeRefusal(
214
+ `${tool}: tests run against the focused workspace` +
215
+ `${focused ? ` ("${focused}")` : ""}, and this MCP server is scoped to ` +
216
+ `"${scope.workspace}". Focus "${scope.workspace}" first; nothing was read or run.`,
217
+ );
218
+ }
219
+
220
+ /** The fields of an /api/services entry the scope checks read. */
221
+ interface ScopedEntry {
222
+ repo?: unknown;
223
+ service?: unknown;
224
+ repoPath?: unknown;
225
+ }
226
+
227
+ async function scopedEntries(
228
+ api: DenApi,
229
+ scope: McpScope,
230
+ ): Promise<ScopedEntry[]> {
231
+ const list = await api.get("/api/services", {
232
+ env: "local",
233
+ workspace: scope.workspace,
234
+ });
235
+ return Array.isArray(list) ? (list as ScopedEntry[]) : [];
236
+ }
237
+
238
+ /** Every `repo/service` must be one of the scoped workspace's services. */
239
+ export async function assertServicesInScope(
240
+ tool: string,
241
+ api: DenApi,
242
+ scope: McpScope,
243
+ targets: readonly { repo: string; service: string }[],
244
+ ): Promise<void> {
245
+ const entries = await scopedEntries(api, scope);
246
+ const have = new Set(entries.map((e) => `${e.repo}/${e.service}`));
247
+ const outside = targets
248
+ .map((t) => `${t.repo}/${t.service}`)
249
+ .filter((t) => !have.has(t));
250
+ if (outside.length)
251
+ throw new ScopeRefusal(
252
+ `${tool}: ${outside.map(printable).join(", ")} ${outside.length === 1 ? "is" : "are"} not a service ` +
253
+ `of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
254
+ );
255
+ }
256
+
257
+ /** A manifest path must be absolute and inside one of the workspace's repos. */
258
+ export async function assertPathInScope(
259
+ tool: string,
260
+ api: DenApi,
261
+ scope: McpScope,
262
+ p: string,
263
+ ): Promise<void> {
264
+ if (!path.isAbsolute(p))
265
+ throw new ScopeRefusal(
266
+ `${tool}: a scoped MCP server takes an absolute path inside workspace "${scope.workspace}"'s repos (or the manifest content).`,
267
+ );
268
+ const target = path.resolve(p);
269
+ const roots = (await scopedEntries(api, scope))
270
+ .map((e) => e.repoPath)
271
+ .filter((r): r is string => typeof r === "string" && r.length > 0)
272
+ .map((r) => path.resolve(r));
273
+ if (!roots.some((r) => target === r || target.startsWith(r + path.sep)))
274
+ throw new ScopeRefusal(
275
+ `${tool}: ${printable(p)} is not inside a repo of workspace "${scope.workspace}", the only one this MCP server serves; nothing was read.`,
276
+ );
277
+ }
@@ -22,6 +22,7 @@ import { createDenApi, type DenApi } from "./api.js";
22
22
  import { ownServer } from "../cli/server-identity.js";
23
23
  import { callTool, toolsFor, type ToolContext } from "./tools.js";
24
24
  import { plural } from "../../shared/plural.js";
25
+ import { parseScopeFlags, ScopeUsageError, type McpScope } from "./scope.js";
25
26
 
26
27
  export interface DenMcpOptions {
27
28
  cfg: ResolvedMcpConfig;
@@ -33,6 +34,8 @@ export interface DenMcpOptions {
33
34
  /** For tests: an API in place of the HTTP client. */
34
35
  api?: DenApi;
35
36
  log?: (line: string) => void;
37
+ /** `--workspace` + `--actor`: one workspace, one agent (AG-2a-core). */
38
+ scope?: McpScope;
36
39
  }
37
40
 
38
41
  export function createDenMcpServer(opts: DenMcpOptions): McpServer {
@@ -48,7 +51,11 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
48
51
  `through the running ${p.title} web server (\`${opts.cli} web up\`). ` +
49
52
  (opts.cfg.actions.length
50
53
  ? `Actions enabled: ${opts.cfg.actions.join(", ")}.`
51
- : "Read-only: the org has enabled no actions (mcp.actions)."),
54
+ : "Read-only: the org has enabled no actions (mcp.actions).") +
55
+ (opts.scope
56
+ ? ` Scoped to workspace "${opts.scope.workspace}" as ${opts.scope.actor}: ` +
57
+ "a call naming another workspace is refused, and lists show this one only."
58
+ : ""),
52
59
  },
53
60
  );
54
61
 
@@ -67,9 +74,15 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
67
74
  clientVersion: client()?.version ?? "unknown",
68
75
  mcpPid: opts.pid ?? process.pid,
69
76
  session,
77
+ ...scopeInfo(opts.scope),
70
78
  }),
71
79
  });
72
- const ctx: ToolContext = { api, cfg: opts.cfg, cli: opts.cli };
80
+ const ctx: ToolContext = {
81
+ api,
82
+ cfg: opts.cfg,
83
+ cli: opts.cli,
84
+ ...(opts.scope ? { scope: opts.scope } : {}),
85
+ };
73
86
 
74
87
  for (const t of tools) {
75
88
  server.registerTool(
@@ -90,6 +103,9 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
90
103
  const who = client();
91
104
  log(
92
105
  `[mcp] ${t.name} ${r.isError ? "refused/failed" : "ok"} · ` +
106
+ (opts.scope
107
+ ? `${opts.scope.actor} in ${opts.scope.workspace} · `
108
+ : "") +
93
109
  `${who?.name ?? "unknown"} ${who?.version ?? ""} · session ${session}` +
94
110
  (r.isError ? ` · ${r.text.split("\n")[0]}` : ""),
95
111
  );
@@ -104,6 +120,14 @@ export function createDenMcpServer(opts: DenMcpOptions): McpServer {
104
120
  return server;
105
121
  }
106
122
 
123
+ /** The audit fields a scope adds to every call's headers (none unscoped). */
124
+ function scopeInfo(scope: McpScope | undefined): {
125
+ agent?: string;
126
+ workspace?: string;
127
+ } {
128
+ return scope ? { agent: scope.actor, workspace: scope.workspace } : {};
129
+ }
130
+
107
131
  function parsePort(argv: string[]): number | undefined {
108
132
  const i = argv.indexOf("--port");
109
133
  const raw =
@@ -122,6 +146,7 @@ export const MCP_USAGE = (cli: string): string =>
122
146
  `Usage: ${cli} mcp [--port <web port>]`,
123
147
  ` ${cli} mcp --list`,
124
148
  ` ${cli} mcp --call <tool> ['<json args>']`,
149
+ ` ${cli} mcp --workspace <ws> --actor agent:<id> [...]`,
125
150
  "",
126
151
  `An MCP server over stdio for agents, a client of the running web server`,
127
152
  `(\`${cli} web up\`; default port: config web.port). Read tools always;`,
@@ -130,6 +155,11 @@ export const MCP_USAGE = (cli: string): string =>
130
155
  "--list prints the tool names; --call runs one tool through the same guards,",
131
156
  "prints its text result and exits 1 if the tool reports an error.",
132
157
  "",
158
+ "--workspace + --actor (both, or neither) scope the server to one workspace:",
159
+ "a call naming another is refused, lists show that one only, and every call",
160
+ "is recorded as agent:<id>. It binds cooperating agents only: the web",
161
+ "server's HTTP API does not check the scope yet.",
162
+ "",
133
163
  `Register it with your agents: ${cli} mcp clients, then ${cli} mcp connect <client> (or --all).`,
134
164
  ].join("\n");
135
165
 
@@ -167,6 +197,7 @@ export interface OneShotOptions {
167
197
  cfg: ResolvedMcpConfig;
168
198
  cli: string;
169
199
  api: DenApi;
200
+ scope?: McpScope;
170
201
  out?: (text: string) => void;
171
202
  err?: (text: string) => void;
172
203
  }
@@ -185,7 +216,12 @@ export async function runOneShot(
185
216
  out(names.length ? `${names.join("\n")}\n` : "");
186
217
  return 0;
187
218
  }
188
- const ctx: ToolContext = { api: opts.api, cfg: opts.cfg, cli: opts.cli };
219
+ const ctx: ToolContext = {
220
+ api: opts.api,
221
+ cfg: opts.cfg,
222
+ cli: opts.cli,
223
+ ...(opts.scope ? { scope: opts.scope } : {}),
224
+ };
189
225
  // The stdio server validates arguments against each tool's schema (the
190
226
  // SDK does it); a one-shot must too, and strictly: it is typed by hand, so
191
227
  // an unknown or misspelt key is an error, not silently dropped.
@@ -214,12 +250,25 @@ export async function runOneShot(
214
250
  }
215
251
 
216
252
  /** Run `<cli> mcp` until stdin closes (or one `--call` / `--list`). */
217
- export async function runMcp(argv: string[]): Promise<void> {
253
+ export async function runMcp(rawArgv: string[]): Promise<void> {
218
254
  const cli = product().cli;
219
- if (argv.includes("-h") || argv.includes("--help")) {
255
+ if (rawArgv.includes("-h") || rawArgv.includes("--help")) {
220
256
  process.stderr.write(`${MCP_USAGE(cli)}\n`);
221
257
  return;
222
258
  }
259
+ let scope: McpScope | null;
260
+ let argv: string[];
261
+ try {
262
+ ({ scope, rest: argv } = parseScopeFlags(rawArgv));
263
+ } catch (e) {
264
+ if (!(e instanceof ScopeUsageError)) throw e;
265
+ process.stderr.write(
266
+ `${cli} mcp: ${e.message}\n` +
267
+ `Usage: ${cli} mcp --workspace <ws> --actor agent:<id> [--port <n>] [--list | --call <tool> ['<json>']]\n`,
268
+ );
269
+ process.exitCode = 2;
270
+ return;
271
+ }
223
272
  let port = parsePort(argv);
224
273
  if (port === undefined) {
225
274
  try {
@@ -244,15 +293,28 @@ export async function runMcp(argv: string[]): Promise<void> {
244
293
  clientVersion: engineVersion() ?? "unknown",
245
294
  mcpPid: process.pid,
246
295
  session,
296
+ ...scopeInfo(scope ?? undefined),
247
297
  }),
248
298
  });
249
- process.exitCode = await runOneShot(shot, { cfg, cli, api });
299
+ process.exitCode = await runOneShot(shot, {
300
+ cfg,
301
+ cli,
302
+ api,
303
+ ...(scope ? { scope } : {}),
304
+ });
250
305
  return;
251
306
  }
252
- const server = createDenMcpServer({ cfg, baseUrl, cli });
307
+ const server = createDenMcpServer({
308
+ cfg,
309
+ baseUrl,
310
+ cli,
311
+ ...(scope ? { scope } : {}),
312
+ });
253
313
  await server.connect(new StdioServerTransport());
254
314
  process.stderr.write(
255
315
  `${cli} mcp: ${plural(toolsFor(cfg).length, "tool")} ` +
256
- `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}\n`,
316
+ `(${cfg.actions.length ? `actions: ${cfg.actions.join(", ")}` : "read only"}) → ${baseUrl}` +
317
+ (scope ? ` · scoped to ${scope.workspace} as ${scope.actor}` : "") +
318
+ "\n",
257
319
  );
258
320
  }