@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.
- package/CHANGELOG.md +108 -0
- package/docs/distributions.md +115 -2
- package/package.json +1 -1
- package/src/shared/agent-actor.ts +38 -3
- package/src/shared/config-error.ts +33 -0
- package/src/shared/den-config.ts +46 -4
- package/src/shared/environment-manager/catalog.ts +52 -1
- package/src/shared/environment-manager/default-workspace.ts +10 -2
- package/src/shared/environment-manager/git-remote.ts +49 -0
- package/src/shared/environment-manager/workspace-manager.ts +67 -25
- package/src/shared/environment-manager/workspace-org.ts +26 -11
- package/src/shared/manifest/service-links.ts +20 -8
- package/src/shared/org-sources.ts +309 -0
- package/src/shared/types/index.ts +12 -2
- package/src/ui/cli/clone-plan.ts +154 -23
- package/src/ui/cli/flag-parser.ts +57 -3
- package/src/ui/cli/index.ts +106 -17
- package/src/ui/cli/org-command.ts +12 -2
- package/src/ui/cli/up-args.ts +15 -0
- package/src/ui/mcp/scope.ts +277 -0
- package/src/ui/mcp/server.ts +70 -8
- package/src/ui/mcp/tools.ts +101 -17
- package/src/ui/server/audit.ts +90 -3
- package/src/ui/server/github-service.ts +6 -1
- package/src/ui/server/index.ts +65 -1
- package/src/ui/server/public/panel.js +1 -1
- package/src/ui/server/readonly-routes.ts +12 -3
- package/src/ui/server/release-service.ts +25 -3
- package/src/ui/server/workspace-routes.ts +10 -6
|
@@ -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 (
|
|
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
|
+
}
|
package/src/ui/mcp/server.ts
CHANGED
|
@@ -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 = {
|
|
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 = {
|
|
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(
|
|
253
|
+
export async function runMcp(rawArgv: string[]): Promise<void> {
|
|
218
254
|
const cli = product().cli;
|
|
219
|
-
if (
|
|
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, {
|
|
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({
|
|
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}
|
|
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
|
}
|