@scp-dev/mcp 0.1.0 → 0.1.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.
- package/README.md +36 -0
- package/dist/index.d.ts +87 -1
- package/dist/index.js +104 -6
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/server.ts +129 -6
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @scp-dev/mcp
|
|
2
|
+
|
|
3
|
+
The SCP MCP server: a dependency-free stdio server that gives one agent session the relay's tools over its local broker.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @scp-dev/mcp
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Most people mount it through the CLI: `swarm setup --write`, or `swarm mcp install --harness claude`, which registers `swarm mcp` under the name `scp`.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { connectBroker, relayDir } from "@scp-dev/broker"
|
|
15
|
+
import { runRelayMcp } from "@scp-dev/mcp"
|
|
16
|
+
|
|
17
|
+
const dir = relayDir()
|
|
18
|
+
await runRelayMcp({
|
|
19
|
+
connect: () => connectBroker(dir),
|
|
20
|
+
claim: { handle: "parser", harness: "codex" },
|
|
21
|
+
input: process.stdin,
|
|
22
|
+
output: (line) => void process.stdout.write(line),
|
|
23
|
+
})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Provides
|
|
27
|
+
|
|
28
|
+
- `runRelayMcp`: JSON-RPC 2.0 over stdio. It binds as an agent endpoint, resumes with its token after a reconnect, sets status `working`, and with `channels` pushes messages as `notifications/claude/channel`.
|
|
29
|
+
- `TOOLS`: `relay_whoami`, `relay_list`, `relay_peers`, `relay_send`, `relay_reply`, `relay_inbox`, `relay_ask`, `relay_ask_wait`, `relay_spawn`, `relay_status`, `relay_set_status`, `relay_set_now`, `relay_set_title`, `relay_rules`, `relay_group`, `relay_peek`, `relay_set_peek`.
|
|
30
|
+
- `INSTRUCTIONS` and `MCP_PROTOCOLS`: the server's instructions (the untrusted-content preamble) and the protocol versions it speaks.
|
|
31
|
+
|
|
32
|
+
Implements [Appendix F, MCP tools](https://scp.dev/spec#appendix-f-reference-binding-mcp-tools). See the [root README](https://github.com/scphq/scp#readme).
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
[Apache-2.0](LICENSE).
|
package/dist/index.d.ts
CHANGED
|
@@ -18,10 +18,30 @@ interface RelayMcpOptions {
|
|
|
18
18
|
/** push deliveries as `notifications/claude/channel` */
|
|
19
19
|
channels?: boolean;
|
|
20
20
|
version?: string;
|
|
21
|
+
/** starts a worker for relay_spawn, resolving with its name and its runner's pid once its harness started; without it there is no relay_spawn */
|
|
22
|
+
spawn?: (request: SpawnRequest) => Promise<{
|
|
23
|
+
name: string;
|
|
24
|
+
pid: number;
|
|
25
|
+
}>;
|
|
21
26
|
/** diagnostics, never stdout */
|
|
22
27
|
log?: (line: string) => void;
|
|
23
28
|
heartbeatMs?: number;
|
|
24
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
|
|
32
|
+
* in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
|
|
33
|
+
*/
|
|
34
|
+
interface SpawnRequest {
|
|
35
|
+
command: string[];
|
|
36
|
+
as: string;
|
|
37
|
+
harness?: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
no_push?: boolean;
|
|
41
|
+
handback_bytes?: number;
|
|
42
|
+
/** the directory the worker runs in, absolute and existing */
|
|
43
|
+
cwd?: string;
|
|
44
|
+
}
|
|
25
45
|
declare const TOOLS: readonly [{
|
|
26
46
|
readonly name: "relay_whoami";
|
|
27
47
|
readonly description: "Your own address on the relay, with your harness, title and labels.";
|
|
@@ -80,6 +100,72 @@ declare const TOOLS: readonly [{
|
|
|
80
100
|
readonly required: readonly ["to", "body"];
|
|
81
101
|
readonly additionalProperties: false;
|
|
82
102
|
};
|
|
103
|
+
}, {
|
|
104
|
+
readonly name: "relay_spawn";
|
|
105
|
+
readonly description: "Start a worker agent under you, as swarm spawn does: command is its harness command as argv, its task in it (e.g. [\"claude\", \"-p\", \"<task>\", \"--model\", \"<model>\"] or [\"codex\", \"exec\", \"<task>\"]), or give harness and task instead. It returns at once with the worker's name; its messages and its handback (exit code and final output) reach you by themselves, and relay_send to that name steers it: the worker gets your message after its next tool call or before it finishes, so its task need not tell it to check an inbox.";
|
|
106
|
+
readonly inputSchema: {
|
|
107
|
+
readonly type: "object";
|
|
108
|
+
readonly properties: {
|
|
109
|
+
readonly command: {
|
|
110
|
+
readonly type: "array";
|
|
111
|
+
readonly items: {
|
|
112
|
+
readonly type: "string";
|
|
113
|
+
};
|
|
114
|
+
readonly minItems: 1;
|
|
115
|
+
readonly description: "the harness command as argv";
|
|
116
|
+
};
|
|
117
|
+
readonly harness: {
|
|
118
|
+
readonly type: "string";
|
|
119
|
+
readonly enum: readonly ["claude", "codex"];
|
|
120
|
+
readonly description: "instead of command: claude -p <task> or codex exec <task>";
|
|
121
|
+
};
|
|
122
|
+
readonly task: {
|
|
123
|
+
readonly type: "string";
|
|
124
|
+
readonly description: "with harness: the worker's task";
|
|
125
|
+
};
|
|
126
|
+
readonly model: {
|
|
127
|
+
readonly type: "string";
|
|
128
|
+
readonly description: "with harness: its --model";
|
|
129
|
+
};
|
|
130
|
+
readonly name: {
|
|
131
|
+
readonly type: "string";
|
|
132
|
+
readonly description: "its address (default <harness>-<5 random characters>)";
|
|
133
|
+
};
|
|
134
|
+
readonly title: {
|
|
135
|
+
readonly type: "string";
|
|
136
|
+
readonly maxLength: 64;
|
|
137
|
+
readonly description: "its title until it sets its own (default the task's first line)";
|
|
138
|
+
};
|
|
139
|
+
readonly no_push: {
|
|
140
|
+
readonly type: "boolean";
|
|
141
|
+
readonly description: "your stop does not wait on it; its messages and handback still reach you after your tool calls";
|
|
142
|
+
};
|
|
143
|
+
readonly handback_bytes: {
|
|
144
|
+
readonly type: "integer";
|
|
145
|
+
readonly minimum: 1;
|
|
146
|
+
readonly maximum: 16384;
|
|
147
|
+
readonly description: "hand back only the last N bytes of its final output";
|
|
148
|
+
};
|
|
149
|
+
readonly cwd: {
|
|
150
|
+
readonly type: "string";
|
|
151
|
+
readonly description: "the absolute path of an existing directory it runs in (default your working directory)";
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
readonly additionalProperties: false;
|
|
155
|
+
};
|
|
156
|
+
}, {
|
|
157
|
+
readonly name: "relay_status";
|
|
158
|
+
readonly description: "Whether the recipients of a message you sent read it: per recipient read at, delivered at (a background hook handed it over; it reaches the model with its next request), shown by a hook at, or unread. By the message's id, its short id or last (the newest you sent).";
|
|
159
|
+
readonly inputSchema: {
|
|
160
|
+
readonly type: "object";
|
|
161
|
+
readonly properties: {
|
|
162
|
+
readonly id: {
|
|
163
|
+
readonly type: "string";
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
readonly required: readonly ["id"];
|
|
167
|
+
readonly additionalProperties: false;
|
|
168
|
+
};
|
|
83
169
|
}, {
|
|
84
170
|
readonly name: "relay_reply";
|
|
85
171
|
readonly description: "Reply to a message relay_inbox, relay_ask, a channel push or a hook handed you, or any message in a thread you sent or got a message in, by its id, its short id or any unique suffix of 4 or more, or last (the newest one you were shown); it goes back to its sender in the same thread and marks that message read.";
|
|
@@ -361,4 +447,4 @@ declare const TOOLS: readonly [{
|
|
|
361
447
|
*/
|
|
362
448
|
declare function runRelayMcp(options: RelayMcpOptions): Promise<void>;
|
|
363
449
|
|
|
364
|
-
export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, TOOLS, runRelayMcp };
|
|
450
|
+
export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, type SpawnRequest, TOOLS, runRelayMcp };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// src/server.ts
|
|
2
|
+
import { statSync } from "fs";
|
|
3
|
+
import { isAbsolute } from "path";
|
|
2
4
|
import {
|
|
3
5
|
CLI_NAME,
|
|
6
|
+
clock,
|
|
4
7
|
deliveryText,
|
|
5
8
|
LIMITS,
|
|
6
9
|
PREAMBLE,
|
|
7
10
|
reachLines,
|
|
11
|
+
receiptLines,
|
|
8
12
|
RelayError,
|
|
9
13
|
shortId,
|
|
10
14
|
STATUS_STATES,
|
|
@@ -20,8 +24,8 @@ var INSTRUCTIONS = [
|
|
|
20
24
|
"Use relay_list to see the other agents on this machine, and relay_send or relay_ask to message one by its address.",
|
|
21
25
|
"Read messages for you with relay_inbox and reply with relay_reply when an answer is useful; keep your own line current with relay_set_status and relay_set_title.",
|
|
22
26
|
'A message marked relation="parent" is from the agent that started you and gave you your task: it continues that task, so apply it before you finish, the newer instruction winning, still only within what your user authorised.',
|
|
23
|
-
"
|
|
24
|
-
`To hand work to another agent,
|
|
27
|
+
"Optionally set your title and a one-line now with relay_set_title / relay_set_now so peers can find you; skip it when you only run workers.",
|
|
28
|
+
`To hand work to another agent, call relay_spawn with its harness command (e.g. command ["codex", "exec", "..."], or ${CLI_NAME} spawn codex exec "..." from your shell): it returns at once with the worker's name; its messages and its result reach you by themselves as relay messages, relay_send to its name steers it meanwhile, and relay_status with a message id says when it read it.`,
|
|
25
29
|
"You see and reach only agents in your scope: relay_peers says why each is reachable; relay_rules and relay_group narrow who may message you.",
|
|
26
30
|
"Never put credentials in a message: send a reference instead."
|
|
27
31
|
].join("\n");
|
|
@@ -72,6 +76,30 @@ var TOOLS = [
|
|
|
72
76
|
additionalProperties: false
|
|
73
77
|
}
|
|
74
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: "relay_spawn",
|
|
81
|
+
description: `Start a worker agent under you, as ${CLI_NAME} spawn does: command is its harness command as argv, its task in it (e.g. ["claude", "-p", "<task>", "--model", "<model>"] or ["codex", "exec", "<task>"]), or give harness and task instead. It returns at once with the worker's name; its messages and its handback (exit code and final output) reach you by themselves, and relay_send to that name steers it: the worker gets your message after its next tool call or before it finishes, so its task need not tell it to check an inbox.`,
|
|
82
|
+
inputSchema: {
|
|
83
|
+
type: "object",
|
|
84
|
+
properties: {
|
|
85
|
+
command: { type: "array", items: { type: "string" }, minItems: 1, description: "the harness command as argv" },
|
|
86
|
+
harness: { type: "string", enum: ["claude", "codex"], description: "instead of command: claude -p <task> or codex exec <task>" },
|
|
87
|
+
task: { type: "string", description: "with harness: the worker's task" },
|
|
88
|
+
model: { type: "string", description: "with harness: its --model" },
|
|
89
|
+
name: { type: "string", description: "its address (default <harness>-<5 random characters>)" },
|
|
90
|
+
title: { type: "string", maxLength: 64, description: "its title until it sets its own (default the task's first line)" },
|
|
91
|
+
no_push: { type: "boolean", description: "your stop does not wait on it; its messages and handback still reach you after your tool calls" },
|
|
92
|
+
handback_bytes: { type: "integer", minimum: 1, maximum: LIMITS.handbackBytes, description: "hand back only the last N bytes of its final output" },
|
|
93
|
+
cwd: { type: "string", description: "the absolute path of an existing directory it runs in (default your working directory)" }
|
|
94
|
+
},
|
|
95
|
+
additionalProperties: false
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "relay_status",
|
|
100
|
+
description: "Whether the recipients of a message you sent read it: per recipient read at, delivered at (a background hook handed it over; it reaches the model with its next request), shown by a hook at, or unread. By the message's id, its short id or last (the newest you sent).",
|
|
101
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false }
|
|
102
|
+
},
|
|
75
103
|
{
|
|
76
104
|
name: "relay_reply",
|
|
77
105
|
description: "Reply to a message relay_inbox, relay_ask, a channel push or a hook handed you, or any message in a thread you sent or got a message in, by its id, its short id or any unique suffix of 4 or more, or last (the newest one you were shown); it goes back to its sender in the same thread and marks that message read.",
|
|
@@ -269,6 +297,45 @@ function groupText(result) {
|
|
|
269
297
|
`members: ${g.members.join(", ") || "none"}`
|
|
270
298
|
].join("\n");
|
|
271
299
|
}
|
|
300
|
+
function spawnRequest(args) {
|
|
301
|
+
if (args.command === void 0 === (args.harness === void 0)) throw new ToolInputError("give command (the harness command as argv) or harness and task");
|
|
302
|
+
let command;
|
|
303
|
+
if (args.command !== void 0) {
|
|
304
|
+
const words = args.command;
|
|
305
|
+
if (!Array.isArray(words) || !words.length || !words.every((w) => typeof w === "string") || !String(words[0]).trim())
|
|
306
|
+
throw new ToolInputError('command must be the harness command as a list of words, e.g. ["codex", "exec", "<task>"]');
|
|
307
|
+
if (args.task !== void 0 || args.model !== void 0) throw new ToolInputError("task and model go with harness; with command, put them in it");
|
|
308
|
+
command = words;
|
|
309
|
+
} else {
|
|
310
|
+
const harness = oneOf(args, "harness", ["claude", "codex"]);
|
|
311
|
+
const model = str(args, "model");
|
|
312
|
+
command = [...harness === "claude" ? ["claude", "-p"] : ["codex", "exec"], str(args, "task", true), ...model ? ["--model", model] : []];
|
|
313
|
+
}
|
|
314
|
+
const bytes = args.handback_bytes;
|
|
315
|
+
if (bytes !== void 0 && (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 1 || bytes > LIMITS.handbackBytes))
|
|
316
|
+
throw new ToolInputError(`handback_bytes must be whole bytes, 1 to ${LIMITS.handbackBytes}`);
|
|
317
|
+
const name = args.name === void 0 ? void 0 : str(args, "name", true);
|
|
318
|
+
const title = str(args, "title");
|
|
319
|
+
const noPush = bool(args, "no_push");
|
|
320
|
+
const cwd = str(args, "cwd");
|
|
321
|
+
if (cwd !== void 0 && !isAbsolute(cwd)) throw new ToolInputError("cwd must be an absolute path");
|
|
322
|
+
if (cwd !== void 0 && !isDirectory(cwd)) throw new ToolInputError(`cwd ${cwd} is not an existing directory`);
|
|
323
|
+
return {
|
|
324
|
+
command,
|
|
325
|
+
...name ? { name } : {},
|
|
326
|
+
...title !== void 0 ? { title } : {},
|
|
327
|
+
...noPush ? { no_push: true } : {},
|
|
328
|
+
...bytes !== void 0 ? { handback_bytes: bytes } : {},
|
|
329
|
+
...cwd !== void 0 ? { cwd } : {}
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function isDirectory(path) {
|
|
333
|
+
try {
|
|
334
|
+
return statSync(path).isDirectory();
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
272
339
|
function seconds(args, fallback) {
|
|
273
340
|
const value = args.wait_s;
|
|
274
341
|
if (value === void 0) return fallback;
|
|
@@ -301,11 +368,14 @@ async function* chunks(input) {
|
|
|
301
368
|
for (let read = await reader.read(); !read.done; read = await reader.read()) yield read.value;
|
|
302
369
|
}
|
|
303
370
|
function delivered(messages) {
|
|
304
|
-
|
|
371
|
+
const shown = (message) => message.shown_at ? `(a hook already showed you this at ${clock(message.shown_at)}; that copy may reach you after this call)
|
|
372
|
+
` : "";
|
|
373
|
+
return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n");
|
|
305
374
|
}
|
|
306
375
|
async function runRelayMcp(options) {
|
|
307
376
|
const log = options.log ?? (() => {
|
|
308
377
|
});
|
|
378
|
+
const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn");
|
|
309
379
|
const home = options.env?.HOME;
|
|
310
380
|
const sent = /* @__PURE__ */ new Map();
|
|
311
381
|
let client;
|
|
@@ -450,11 +520,39 @@ async function runRelayMcp(options) {
|
|
|
450
520
|
if (result.held) return text(heldText(result.held));
|
|
451
521
|
const lines = reachLines(result.deliveries);
|
|
452
522
|
const to = result.deliveries.map((d) => lines.length ? d.address : deliveryText(d)).join(", ");
|
|
523
|
+
const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting") ? [`relay_status with id ${shortId(result.id)} shows when it is read`] : [];
|
|
453
524
|
return {
|
|
454
|
-
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
|
|
525
|
+
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
|
|
455
526
|
structuredContent: sentContent(result)
|
|
456
527
|
};
|
|
457
528
|
}
|
|
529
|
+
case "relay_spawn": {
|
|
530
|
+
const request = spawnRequest(args);
|
|
531
|
+
const spawn = options.spawn;
|
|
532
|
+
if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn");
|
|
533
|
+
const view = await call("whoami");
|
|
534
|
+
let started;
|
|
535
|
+
try {
|
|
536
|
+
const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : void 0);
|
|
537
|
+
started = await spawn({ ...request, as: view.address, ...view.harness ? { harness: view.harness } : {}, ...cwd ? { cwd } : {} });
|
|
538
|
+
} catch (error) {
|
|
539
|
+
return text(error instanceof Error ? error.message : String(error), true);
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
...text(
|
|
543
|
+
`${started.name} started (pid ${started.pid}) and runs in the background: its messages and its handback reach you by themselves, after your tool calls or when you stop; steer it with relay_send to ${started.name} (it gets the message after its next tool call or before it finishes)`
|
|
544
|
+
),
|
|
545
|
+
structuredContent: { name: started.name, pid: started.pid }
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
case "relay_status": {
|
|
549
|
+
const receipts = await call("receipts", { id: str(args, "id", true) });
|
|
550
|
+
const lines = receiptLines(receipts);
|
|
551
|
+
return {
|
|
552
|
+
...text([`${receipts.id}:`, ...lines.length ? lines : ["no deliveries of it are kept any more"]].join("\n")),
|
|
553
|
+
structuredContent: { ...receipts }
|
|
554
|
+
};
|
|
555
|
+
}
|
|
458
556
|
case "relay_reply": {
|
|
459
557
|
let id = str(args, "id", true);
|
|
460
558
|
const body = str(args, "body", true);
|
|
@@ -681,10 +779,10 @@ ${wrapMessage(message)}`,
|
|
|
681
779
|
case "ping":
|
|
682
780
|
return respond({});
|
|
683
781
|
case "tools/list":
|
|
684
|
-
return respond({ tools
|
|
782
|
+
return respond({ tools });
|
|
685
783
|
case "tools/call": {
|
|
686
784
|
if (!initialized) return write({ jsonrpc: "2.0", id, error: { code: -32002, message: "call initialize first" } });
|
|
687
|
-
if (!
|
|
785
|
+
if (!tools.some((t) => t.name === params.name)) {
|
|
688
786
|
return write({ jsonrpc: "2.0", id, error: { code: -32602, message: `unknown tool ${JSON.stringify(params.name)}` } });
|
|
689
787
|
}
|
|
690
788
|
return respond(await callTool(params));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scp-dev/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The SCP stdio MCP server: gives an agent session the relay's tools.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://scp.dev",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"typecheck": "tsc -p ."
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@scp-dev/broker": "0.1.
|
|
33
|
-
"@scp-dev/core": "0.1.
|
|
32
|
+
"@scp-dev/broker": "0.1.1",
|
|
33
|
+
"@scp-dev/core": "0.1.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"tsup": "^8.5.1"
|
package/src/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { INSTRUCTIONS, MCP_PROTOCOLS, runRelayMcp, TOOLS, type RelayMcpOptions } from "./server"
|
|
1
|
+
export { INSTRUCTIONS, MCP_PROTOCOLS, runRelayMcp, TOOLS, type RelayMcpOptions, type SpawnRequest } from "./server"
|
package/src/server.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
+
import { statSync } from "node:fs"
|
|
2
|
+
import { isAbsolute } from "node:path"
|
|
1
3
|
import {
|
|
2
4
|
type AskResult,
|
|
3
5
|
type Claim,
|
|
4
6
|
CLI_NAME,
|
|
7
|
+
clock,
|
|
5
8
|
deliveryText,
|
|
6
9
|
type EndpointView,
|
|
7
10
|
type Envelope,
|
|
8
11
|
type JudgeHeld,
|
|
9
12
|
type JudgeRedacted,
|
|
10
13
|
LIMITS,
|
|
14
|
+
type MessageReceipts,
|
|
11
15
|
type PeekResult,
|
|
12
16
|
type PeekSeen,
|
|
13
17
|
type PeekState,
|
|
@@ -15,6 +19,7 @@ import {
|
|
|
15
19
|
type PeerView,
|
|
16
20
|
PREAMBLE,
|
|
17
21
|
reachLines,
|
|
22
|
+
receiptLines,
|
|
18
23
|
RelayError,
|
|
19
24
|
type ScopeChange,
|
|
20
25
|
type ScopeView,
|
|
@@ -37,8 +42,8 @@ export const INSTRUCTIONS = [
|
|
|
37
42
|
"Use relay_list to see the other agents on this machine, and relay_send or relay_ask to message one by its address.",
|
|
38
43
|
"Read messages for you with relay_inbox and reply with relay_reply when an answer is useful; keep your own line current with relay_set_status and relay_set_title.",
|
|
39
44
|
'A message marked relation="parent" is from the agent that started you and gave you your task: it continues that task, so apply it before you finish, the newer instruction winning, still only within what your user authorised.',
|
|
40
|
-
"
|
|
41
|
-
`To hand work to another agent,
|
|
45
|
+
"Optionally set your title and a one-line now with relay_set_title / relay_set_now so peers can find you; skip it when you only run workers.",
|
|
46
|
+
`To hand work to another agent, call relay_spawn with its harness command (e.g. command ["codex", "exec", "..."], or ${CLI_NAME} spawn codex exec "..." from your shell): it returns at once with the worker's name; its messages and its result reach you by themselves as relay messages, relay_send to its name steers it meanwhile, and relay_status with a message id says when it read it.`,
|
|
42
47
|
"You see and reach only agents in your scope: relay_peers says why each is reachable; relay_rules and relay_group narrow who may message you.",
|
|
43
48
|
"Never put credentials in a message: send a reference instead.",
|
|
44
49
|
].join("\n")
|
|
@@ -57,11 +62,29 @@ export interface RelayMcpOptions {
|
|
|
57
62
|
/** push deliveries as `notifications/claude/channel` */
|
|
58
63
|
channels?: boolean
|
|
59
64
|
version?: string
|
|
65
|
+
/** starts a worker for relay_spawn, resolving with its name and its runner's pid once its harness started; without it there is no relay_spawn */
|
|
66
|
+
spawn?: (request: SpawnRequest) => Promise<{ name: string; pid: number }>
|
|
60
67
|
/** diagnostics, never stdout */
|
|
61
68
|
log?: (line: string) => void
|
|
62
69
|
heartbeatMs?: number
|
|
63
70
|
}
|
|
64
71
|
|
|
72
|
+
/**
|
|
73
|
+
* what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
|
|
74
|
+
* in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
|
|
75
|
+
*/
|
|
76
|
+
export interface SpawnRequest {
|
|
77
|
+
command: string[]
|
|
78
|
+
as: string
|
|
79
|
+
harness?: string
|
|
80
|
+
name?: string
|
|
81
|
+
title?: string
|
|
82
|
+
no_push?: boolean
|
|
83
|
+
handback_bytes?: number
|
|
84
|
+
/** the directory the worker runs in, absolute and existing */
|
|
85
|
+
cwd?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
type Json = Record<string, unknown>
|
|
66
89
|
type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean; structuredContent?: Record<string, unknown> }
|
|
67
90
|
|
|
@@ -117,6 +140,31 @@ export const TOOLS = [
|
|
|
117
140
|
additionalProperties: false,
|
|
118
141
|
},
|
|
119
142
|
},
|
|
143
|
+
{
|
|
144
|
+
name: "relay_spawn",
|
|
145
|
+
description: `Start a worker agent under you, as ${CLI_NAME} spawn does: command is its harness command as argv, its task in it (e.g. ["claude", "-p", "<task>", "--model", "<model>"] or ["codex", "exec", "<task>"]), or give harness and task instead. It returns at once with the worker's name; its messages and its handback (exit code and final output) reach you by themselves, and relay_send to that name steers it: the worker gets your message after its next tool call or before it finishes, so its task need not tell it to check an inbox.`,
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
command: { type: "array", items: { type: "string" }, minItems: 1, description: "the harness command as argv" },
|
|
150
|
+
harness: { type: "string", enum: ["claude", "codex"], description: "instead of command: claude -p <task> or codex exec <task>" },
|
|
151
|
+
task: { type: "string", description: "with harness: the worker's task" },
|
|
152
|
+
model: { type: "string", description: "with harness: its --model" },
|
|
153
|
+
name: { type: "string", description: "its address (default <harness>-<5 random characters>)" },
|
|
154
|
+
title: { type: "string", maxLength: 64, description: "its title until it sets its own (default the task's first line)" },
|
|
155
|
+
no_push: { type: "boolean", description: "your stop does not wait on it; its messages and handback still reach you after your tool calls" },
|
|
156
|
+
handback_bytes: { type: "integer", minimum: 1, maximum: LIMITS.handbackBytes, description: "hand back only the last N bytes of its final output" },
|
|
157
|
+
cwd: { type: "string", description: "the absolute path of an existing directory it runs in (default your working directory)" },
|
|
158
|
+
},
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "relay_status",
|
|
164
|
+
description:
|
|
165
|
+
"Whether the recipients of a message you sent read it: per recipient read at, delivered at (a background hook handed it over; it reaches the model with its next request), shown by a hook at, or unread. By the message's id, its short id or last (the newest you sent).",
|
|
166
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
167
|
+
},
|
|
120
168
|
{
|
|
121
169
|
name: "relay_reply",
|
|
122
170
|
description:
|
|
@@ -353,6 +401,48 @@ function groupText(result: unknown): string {
|
|
|
353
401
|
].join("\n")
|
|
354
402
|
}
|
|
355
403
|
|
|
404
|
+
/** relay_spawn's arguments as a spawn request, all but who spawns; a `cwd` must be an existing directory's absolute path */
|
|
405
|
+
function spawnRequest(args: Json): Omit<SpawnRequest, "as" | "harness"> {
|
|
406
|
+
if ((args.command === undefined) === (args.harness === undefined)) throw new ToolInputError("give command (the harness command as argv) or harness and task")
|
|
407
|
+
let command: string[]
|
|
408
|
+
if (args.command !== undefined) {
|
|
409
|
+
const words = args.command
|
|
410
|
+
if (!Array.isArray(words) || !words.length || !words.every((w) => typeof w === "string") || !String(words[0]).trim())
|
|
411
|
+
throw new ToolInputError('command must be the harness command as a list of words, e.g. ["codex", "exec", "<task>"]')
|
|
412
|
+
if (args.task !== undefined || args.model !== undefined) throw new ToolInputError("task and model go with harness; with command, put them in it")
|
|
413
|
+
command = words as string[]
|
|
414
|
+
} else {
|
|
415
|
+
const harness = oneOf(args, "harness", ["claude", "codex"] as const)
|
|
416
|
+
const model = str(args, "model")
|
|
417
|
+
command = [...(harness === "claude" ? ["claude", "-p"] : ["codex", "exec"]), str(args, "task", true), ...(model ? ["--model", model] : [])]
|
|
418
|
+
}
|
|
419
|
+
const bytes = args.handback_bytes
|
|
420
|
+
if (bytes !== undefined && (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 1 || bytes > LIMITS.handbackBytes))
|
|
421
|
+
throw new ToolInputError(`handback_bytes must be whole bytes, 1 to ${LIMITS.handbackBytes}`)
|
|
422
|
+
const name = args.name === undefined ? undefined : str(args, "name", true)
|
|
423
|
+
const title = str(args, "title")
|
|
424
|
+
const noPush = bool(args, "no_push")
|
|
425
|
+
const cwd = str(args, "cwd")
|
|
426
|
+
if (cwd !== undefined && !isAbsolute(cwd)) throw new ToolInputError("cwd must be an absolute path")
|
|
427
|
+
if (cwd !== undefined && !isDirectory(cwd)) throw new ToolInputError(`cwd ${cwd} is not an existing directory`)
|
|
428
|
+
return {
|
|
429
|
+
command,
|
|
430
|
+
...(name ? { name } : {}),
|
|
431
|
+
...(title !== undefined ? { title } : {}),
|
|
432
|
+
...(noPush ? { no_push: true } : {}),
|
|
433
|
+
...(bytes !== undefined ? { handback_bytes: bytes } : {}),
|
|
434
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isDirectory(path: string): boolean {
|
|
439
|
+
try {
|
|
440
|
+
return statSync(path).isDirectory()
|
|
441
|
+
} catch {
|
|
442
|
+
return false
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
356
446
|
function seconds(args: Json, fallback: number): number {
|
|
357
447
|
const value = args.wait_s
|
|
358
448
|
if (value === undefined) return fallback
|
|
@@ -392,7 +482,9 @@ async function* chunks(input: RelayMcpOptions["input"]): AsyncIterable<Uint8Arra
|
|
|
392
482
|
}
|
|
393
483
|
|
|
394
484
|
function delivered(messages: Envelope[]): string {
|
|
395
|
-
|
|
485
|
+
const shown = (message: Envelope) =>
|
|
486
|
+
message.shown_at ? `(a hook already showed you this at ${clock(message.shown_at)}; that copy may reach you after this call)\n` : ""
|
|
487
|
+
return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n")
|
|
396
488
|
}
|
|
397
489
|
|
|
398
490
|
/**
|
|
@@ -401,6 +493,7 @@ function delivered(messages: Envelope[]): string {
|
|
|
401
493
|
*/
|
|
402
494
|
export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
403
495
|
const log = options.log ?? (() => {})
|
|
496
|
+
const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn")
|
|
404
497
|
const home = options.env?.HOME
|
|
405
498
|
const sent = new Map<string, { from: string; thread: string }>()
|
|
406
499
|
let client: RelayClient | undefined
|
|
@@ -561,11 +654,41 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
561
654
|
if (result.held) return text(heldText(result.held))
|
|
562
655
|
const lines = reachLines(result.deliveries)
|
|
563
656
|
const to = result.deliveries.map((d) => (lines.length ? d.address : deliveryText(d))).join(", ")
|
|
657
|
+
const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting")
|
|
658
|
+
? [`relay_status with id ${shortId(result.id)} shows when it is read`]
|
|
659
|
+
: []
|
|
564
660
|
return {
|
|
565
|
-
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
|
|
661
|
+
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
|
|
566
662
|
structuredContent: sentContent(result),
|
|
567
663
|
}
|
|
568
664
|
}
|
|
665
|
+
case "relay_spawn": {
|
|
666
|
+
const request = spawnRequest(args)
|
|
667
|
+
const spawn = options.spawn
|
|
668
|
+
if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn")
|
|
669
|
+
const view = await call<EndpointView>("whoami")
|
|
670
|
+
let started: { name: string; pid: number }
|
|
671
|
+
try {
|
|
672
|
+
const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : undefined)
|
|
673
|
+
started = await spawn({ ...request, as: view.address, ...(view.harness ? { harness: view.harness } : {}), ...(cwd ? { cwd } : {}) })
|
|
674
|
+
} catch (error) {
|
|
675
|
+
return text(error instanceof Error ? error.message : String(error), true)
|
|
676
|
+
}
|
|
677
|
+
return {
|
|
678
|
+
...text(
|
|
679
|
+
`${started.name} started (pid ${started.pid}) and runs in the background: its messages and its handback reach you by themselves, after your tool calls or when you stop; steer it with relay_send to ${started.name} (it gets the message after its next tool call or before it finishes)`,
|
|
680
|
+
),
|
|
681
|
+
structuredContent: { name: started.name, pid: started.pid },
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
case "relay_status": {
|
|
685
|
+
const receipts = await call<MessageReceipts>("receipts", { id: str(args, "id", true) })
|
|
686
|
+
const lines = receiptLines(receipts)
|
|
687
|
+
return {
|
|
688
|
+
...text([`${receipts.id}:`, ...(lines.length ? lines : ["no deliveries of it are kept any more"])].join("\n")),
|
|
689
|
+
structuredContent: { ...receipts },
|
|
690
|
+
}
|
|
691
|
+
}
|
|
569
692
|
case "relay_reply": {
|
|
570
693
|
let id = str(args, "id", true)
|
|
571
694
|
const body = str(args, "body", true)
|
|
@@ -793,10 +916,10 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
793
916
|
case "ping":
|
|
794
917
|
return respond({})
|
|
795
918
|
case "tools/list":
|
|
796
|
-
return respond({ tools
|
|
919
|
+
return respond({ tools })
|
|
797
920
|
case "tools/call": {
|
|
798
921
|
if (!initialized) return write({ jsonrpc: "2.0", id, error: { code: -32002, message: "call initialize first" } })
|
|
799
|
-
if (!
|
|
922
|
+
if (!tools.some((t) => t.name === params.name)) {
|
|
800
923
|
return write({ jsonrpc: "2.0", id, error: { code: -32602, message: `unknown tool ${JSON.stringify(params.name)}` } })
|
|
801
924
|
}
|
|
802
925
|
return respond(await callTool(params))
|