@scp-dev/mcp 0.1.0 → 0.1.2
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 +90 -2
- package/dist/index.js +107 -9
- package/package.json +3 -3
- package/src/index.ts +1 -1
- package/src/server.ts +137 -11
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
|
@@ -15,13 +15,35 @@ interface RelayMcpOptions {
|
|
|
15
15
|
output: (line: string) => void;
|
|
16
16
|
/** HOME shortens cwds in relay_list */
|
|
17
17
|
env?: Record<string, string | undefined>;
|
|
18
|
-
/** push deliveries as `notifications/claude/channel` */
|
|
18
|
+
/** push deliveries as `notifications/claude/channel` to a client that declares `claude/channel` or was launched with channels */
|
|
19
19
|
channels?: boolean;
|
|
20
|
+
/** the client was started with channels loaded (Claude Code does not declare the capability itself) */
|
|
21
|
+
launchedWithChannels?: boolean;
|
|
20
22
|
version?: string;
|
|
23
|
+
/** 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 */
|
|
24
|
+
spawn?: (request: SpawnRequest) => Promise<{
|
|
25
|
+
name: string;
|
|
26
|
+
pid: number;
|
|
27
|
+
}>;
|
|
21
28
|
/** diagnostics, never stdout */
|
|
22
29
|
log?: (line: string) => void;
|
|
23
30
|
heartbeatMs?: number;
|
|
24
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
|
|
34
|
+
* in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
|
|
35
|
+
*/
|
|
36
|
+
interface SpawnRequest {
|
|
37
|
+
command: string[];
|
|
38
|
+
as: string;
|
|
39
|
+
harness?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
no_push?: boolean;
|
|
43
|
+
handback_bytes?: number;
|
|
44
|
+
/** the directory the worker runs in, absolute and existing */
|
|
45
|
+
cwd?: string;
|
|
46
|
+
}
|
|
25
47
|
declare const TOOLS: readonly [{
|
|
26
48
|
readonly name: "relay_whoami";
|
|
27
49
|
readonly description: "Your own address on the relay, with your harness, title and labels.";
|
|
@@ -80,6 +102,72 @@ declare const TOOLS: readonly [{
|
|
|
80
102
|
readonly required: readonly ["to", "body"];
|
|
81
103
|
readonly additionalProperties: false;
|
|
82
104
|
};
|
|
105
|
+
}, {
|
|
106
|
+
readonly name: "relay_spawn";
|
|
107
|
+
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.";
|
|
108
|
+
readonly inputSchema: {
|
|
109
|
+
readonly type: "object";
|
|
110
|
+
readonly properties: {
|
|
111
|
+
readonly command: {
|
|
112
|
+
readonly type: "array";
|
|
113
|
+
readonly items: {
|
|
114
|
+
readonly type: "string";
|
|
115
|
+
};
|
|
116
|
+
readonly minItems: 1;
|
|
117
|
+
readonly description: "the harness command as argv";
|
|
118
|
+
};
|
|
119
|
+
readonly harness: {
|
|
120
|
+
readonly type: "string";
|
|
121
|
+
readonly enum: readonly ["claude", "codex"];
|
|
122
|
+
readonly description: "instead of command: claude -p <task> or codex exec <task>";
|
|
123
|
+
};
|
|
124
|
+
readonly task: {
|
|
125
|
+
readonly type: "string";
|
|
126
|
+
readonly description: "with harness: the worker's task";
|
|
127
|
+
};
|
|
128
|
+
readonly model: {
|
|
129
|
+
readonly type: "string";
|
|
130
|
+
readonly description: "with harness: its --model";
|
|
131
|
+
};
|
|
132
|
+
readonly name: {
|
|
133
|
+
readonly type: "string";
|
|
134
|
+
readonly description: "its address (default <harness>-<5 random characters>)";
|
|
135
|
+
};
|
|
136
|
+
readonly title: {
|
|
137
|
+
readonly type: "string";
|
|
138
|
+
readonly maxLength: 64;
|
|
139
|
+
readonly description: "its title until it sets its own (default the task's first line)";
|
|
140
|
+
};
|
|
141
|
+
readonly no_push: {
|
|
142
|
+
readonly type: "boolean";
|
|
143
|
+
readonly description: "your stop does not wait on it; its messages and handback still reach you after your tool calls";
|
|
144
|
+
};
|
|
145
|
+
readonly handback_bytes: {
|
|
146
|
+
readonly type: "integer";
|
|
147
|
+
readonly minimum: 1;
|
|
148
|
+
readonly maximum: 16384;
|
|
149
|
+
readonly description: "hand back only the last N bytes of its final output";
|
|
150
|
+
};
|
|
151
|
+
readonly cwd: {
|
|
152
|
+
readonly type: "string";
|
|
153
|
+
readonly description: "the absolute path of an existing directory it runs in (default your working directory)";
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
readonly additionalProperties: false;
|
|
157
|
+
};
|
|
158
|
+
}, {
|
|
159
|
+
readonly name: "relay_status";
|
|
160
|
+
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).";
|
|
161
|
+
readonly inputSchema: {
|
|
162
|
+
readonly type: "object";
|
|
163
|
+
readonly properties: {
|
|
164
|
+
readonly id: {
|
|
165
|
+
readonly type: "string";
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
readonly required: readonly ["id"];
|
|
169
|
+
readonly additionalProperties: false;
|
|
170
|
+
};
|
|
83
171
|
}, {
|
|
84
172
|
readonly name: "relay_reply";
|
|
85
173
|
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 +449,4 @@ declare const TOOLS: readonly [{
|
|
|
361
449
|
*/
|
|
362
450
|
declare function runRelayMcp(options: RelayMcpOptions): Promise<void>;
|
|
363
451
|
|
|
364
|
-
export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, TOOLS, runRelayMcp };
|
|
452
|
+
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,46 @@ 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
|
+
}
|
|
339
|
+
var capped = (wait) => typeof wait === "number" && wait > LIMITS.waitMaxS;
|
|
272
340
|
function seconds(args, fallback) {
|
|
273
341
|
const value = args.wait_s;
|
|
274
342
|
if (value === void 0) return fallback;
|
|
@@ -301,11 +369,14 @@ async function* chunks(input) {
|
|
|
301
369
|
for (let read = await reader.read(); !read.done; read = await reader.read()) yield read.value;
|
|
302
370
|
}
|
|
303
371
|
function delivered(messages) {
|
|
304
|
-
|
|
372
|
+
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)
|
|
373
|
+
` : "";
|
|
374
|
+
return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n");
|
|
305
375
|
}
|
|
306
376
|
async function runRelayMcp(options) {
|
|
307
377
|
const log = options.log ?? (() => {
|
|
308
378
|
});
|
|
379
|
+
const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn");
|
|
309
380
|
const home = options.env?.HOME;
|
|
310
381
|
const sent = /* @__PURE__ */ new Map();
|
|
311
382
|
let client;
|
|
@@ -450,11 +521,39 @@ async function runRelayMcp(options) {
|
|
|
450
521
|
if (result.held) return text(heldText(result.held));
|
|
451
522
|
const lines = reachLines(result.deliveries);
|
|
452
523
|
const to = result.deliveries.map((d) => lines.length ? d.address : deliveryText(d)).join(", ");
|
|
524
|
+
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
525
|
return {
|
|
454
|
-
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
|
|
526
|
+
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
|
|
455
527
|
structuredContent: sentContent(result)
|
|
456
528
|
};
|
|
457
529
|
}
|
|
530
|
+
case "relay_spawn": {
|
|
531
|
+
const request = spawnRequest(args);
|
|
532
|
+
const spawn = options.spawn;
|
|
533
|
+
if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn");
|
|
534
|
+
const view = await call("whoami");
|
|
535
|
+
let started;
|
|
536
|
+
try {
|
|
537
|
+
const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : void 0);
|
|
538
|
+
started = await spawn({ ...request, as: view.address, ...view.harness ? { harness: view.harness } : {}, ...cwd ? { cwd } : {} });
|
|
539
|
+
} catch (error) {
|
|
540
|
+
return text(error instanceof Error ? error.message : String(error), true);
|
|
541
|
+
}
|
|
542
|
+
return {
|
|
543
|
+
...text(
|
|
544
|
+
`${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)`
|
|
545
|
+
),
|
|
546
|
+
structuredContent: { name: started.name, pid: started.pid }
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
case "relay_status": {
|
|
550
|
+
const receipts = await call("receipts", { id: str(args, "id", true) });
|
|
551
|
+
const lines = receiptLines(receipts);
|
|
552
|
+
return {
|
|
553
|
+
...text([`${receipts.id}:`, ...lines.length ? lines : ["no deliveries of it are kept any more"]].join("\n")),
|
|
554
|
+
structuredContent: { ...receipts }
|
|
555
|
+
};
|
|
556
|
+
}
|
|
458
557
|
case "relay_reply": {
|
|
459
558
|
let id = str(args, "id", true);
|
|
460
559
|
const body = str(args, "body", true);
|
|
@@ -483,7 +582,7 @@ async function runRelayMcp(options) {
|
|
|
483
582
|
case "relay_inbox": {
|
|
484
583
|
const ack = bool(args, "ack") ?? true;
|
|
485
584
|
const { messages } = await call("inbox", { wait_s: seconds(args, 0) });
|
|
486
|
-
if (!messages.length) return text(
|
|
585
|
+
if (!messages.length) return text(`no unread messages${capped(args.wait_s) ? `; waited ${LIMITS.waitMaxS} s (cap)` : ""}`);
|
|
487
586
|
for (const message of messages) remember(message);
|
|
488
587
|
if (ack) await call("ack", { seqs: messages.map((m) => m.seq) });
|
|
489
588
|
return text(delivered(messages));
|
|
@@ -668,9 +767,8 @@ ${wrapMessage(message)}`,
|
|
|
668
767
|
const protocolVersion = typeof asked === "string" && MCP_PROTOCOLS.includes(asked) ? asked : MCP_PROTOCOLS[0];
|
|
669
768
|
initialized = true;
|
|
670
769
|
const capabilities = params.capabilities;
|
|
671
|
-
const client2 = params.clientInfo;
|
|
672
770
|
const channel = capabilities?.experimental?.["claude/channel"];
|
|
673
|
-
channelClient = typeof channel === "object" && channel !== null ||
|
|
771
|
+
channelClient = typeof channel === "object" && channel !== null || options.launchedWithChannels === true;
|
|
674
772
|
return respond({
|
|
675
773
|
protocolVersion,
|
|
676
774
|
capabilities: { tools: {}, ...options.channels ? { experimental: { "claude/channel": {} } } : {} },
|
|
@@ -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.2",
|
|
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.2",
|
|
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")
|
|
@@ -54,14 +59,34 @@ export interface RelayMcpOptions {
|
|
|
54
59
|
output: (line: string) => void
|
|
55
60
|
/** HOME shortens cwds in relay_list */
|
|
56
61
|
env?: Record<string, string | undefined>
|
|
57
|
-
/** push deliveries as `notifications/claude/channel` */
|
|
62
|
+
/** push deliveries as `notifications/claude/channel` to a client that declares `claude/channel` or was launched with channels */
|
|
58
63
|
channels?: boolean
|
|
64
|
+
/** the client was started with channels loaded (Claude Code does not declare the capability itself) */
|
|
65
|
+
launchedWithChannels?: boolean
|
|
59
66
|
version?: string
|
|
67
|
+
/** 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 */
|
|
68
|
+
spawn?: (request: SpawnRequest) => Promise<{ name: string; pid: number }>
|
|
60
69
|
/** diagnostics, never stdout */
|
|
61
70
|
log?: (line: string) => void
|
|
62
71
|
heartbeatMs?: number
|
|
63
72
|
}
|
|
64
73
|
|
|
74
|
+
/**
|
|
75
|
+
* what relay_spawn asks for: `swarm spawn --bg` of `command` with these flags, acting as `as`, the shim's own address, of `harness`,
|
|
76
|
+
* in `cwd`: the one asked for, else the caller's working directory when it exists, else (none) the shim's
|
|
77
|
+
*/
|
|
78
|
+
export interface SpawnRequest {
|
|
79
|
+
command: string[]
|
|
80
|
+
as: string
|
|
81
|
+
harness?: string
|
|
82
|
+
name?: string
|
|
83
|
+
title?: string
|
|
84
|
+
no_push?: boolean
|
|
85
|
+
handback_bytes?: number
|
|
86
|
+
/** the directory the worker runs in, absolute and existing */
|
|
87
|
+
cwd?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
65
90
|
type Json = Record<string, unknown>
|
|
66
91
|
type ToolResult = { content: { type: "text"; text: string }[]; isError?: boolean; structuredContent?: Record<string, unknown> }
|
|
67
92
|
|
|
@@ -117,6 +142,31 @@ export const TOOLS = [
|
|
|
117
142
|
additionalProperties: false,
|
|
118
143
|
},
|
|
119
144
|
},
|
|
145
|
+
{
|
|
146
|
+
name: "relay_spawn",
|
|
147
|
+
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.`,
|
|
148
|
+
inputSchema: {
|
|
149
|
+
type: "object",
|
|
150
|
+
properties: {
|
|
151
|
+
command: { type: "array", items: { type: "string" }, minItems: 1, description: "the harness command as argv" },
|
|
152
|
+
harness: { type: "string", enum: ["claude", "codex"], description: "instead of command: claude -p <task> or codex exec <task>" },
|
|
153
|
+
task: { type: "string", description: "with harness: the worker's task" },
|
|
154
|
+
model: { type: "string", description: "with harness: its --model" },
|
|
155
|
+
name: { type: "string", description: "its address (default <harness>-<5 random characters>)" },
|
|
156
|
+
title: { type: "string", maxLength: 64, description: "its title until it sets its own (default the task's first line)" },
|
|
157
|
+
no_push: { type: "boolean", description: "your stop does not wait on it; its messages and handback still reach you after your tool calls" },
|
|
158
|
+
handback_bytes: { type: "integer", minimum: 1, maximum: LIMITS.handbackBytes, description: "hand back only the last N bytes of its final output" },
|
|
159
|
+
cwd: { type: "string", description: "the absolute path of an existing directory it runs in (default your working directory)" },
|
|
160
|
+
},
|
|
161
|
+
additionalProperties: false,
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "relay_status",
|
|
166
|
+
description:
|
|
167
|
+
"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).",
|
|
168
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
|
|
169
|
+
},
|
|
120
170
|
{
|
|
121
171
|
name: "relay_reply",
|
|
122
172
|
description:
|
|
@@ -353,6 +403,50 @@ function groupText(result: unknown): string {
|
|
|
353
403
|
].join("\n")
|
|
354
404
|
}
|
|
355
405
|
|
|
406
|
+
/** relay_spawn's arguments as a spawn request, all but who spawns; a `cwd` must be an existing directory's absolute path */
|
|
407
|
+
function spawnRequest(args: Json): Omit<SpawnRequest, "as" | "harness"> {
|
|
408
|
+
if ((args.command === undefined) === (args.harness === undefined)) throw new ToolInputError("give command (the harness command as argv) or harness and task")
|
|
409
|
+
let command: string[]
|
|
410
|
+
if (args.command !== undefined) {
|
|
411
|
+
const words = args.command
|
|
412
|
+
if (!Array.isArray(words) || !words.length || !words.every((w) => typeof w === "string") || !String(words[0]).trim())
|
|
413
|
+
throw new ToolInputError('command must be the harness command as a list of words, e.g. ["codex", "exec", "<task>"]')
|
|
414
|
+
if (args.task !== undefined || args.model !== undefined) throw new ToolInputError("task and model go with harness; with command, put them in it")
|
|
415
|
+
command = words as string[]
|
|
416
|
+
} else {
|
|
417
|
+
const harness = oneOf(args, "harness", ["claude", "codex"] as const)
|
|
418
|
+
const model = str(args, "model")
|
|
419
|
+
command = [...(harness === "claude" ? ["claude", "-p"] : ["codex", "exec"]), str(args, "task", true), ...(model ? ["--model", model] : [])]
|
|
420
|
+
}
|
|
421
|
+
const bytes = args.handback_bytes
|
|
422
|
+
if (bytes !== undefined && (typeof bytes !== "number" || !Number.isInteger(bytes) || bytes < 1 || bytes > LIMITS.handbackBytes))
|
|
423
|
+
throw new ToolInputError(`handback_bytes must be whole bytes, 1 to ${LIMITS.handbackBytes}`)
|
|
424
|
+
const name = args.name === undefined ? undefined : str(args, "name", true)
|
|
425
|
+
const title = str(args, "title")
|
|
426
|
+
const noPush = bool(args, "no_push")
|
|
427
|
+
const cwd = str(args, "cwd")
|
|
428
|
+
if (cwd !== undefined && !isAbsolute(cwd)) throw new ToolInputError("cwd must be an absolute path")
|
|
429
|
+
if (cwd !== undefined && !isDirectory(cwd)) throw new ToolInputError(`cwd ${cwd} is not an existing directory`)
|
|
430
|
+
return {
|
|
431
|
+
command,
|
|
432
|
+
...(name ? { name } : {}),
|
|
433
|
+
...(title !== undefined ? { title } : {}),
|
|
434
|
+
...(noPush ? { no_push: true } : {}),
|
|
435
|
+
...(bytes !== undefined ? { handback_bytes: bytes } : {}),
|
|
436
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function isDirectory(path: string): boolean {
|
|
441
|
+
try {
|
|
442
|
+
return statSync(path).isDirectory()
|
|
443
|
+
} catch {
|
|
444
|
+
return false
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const capped = (wait: unknown): boolean => typeof wait === "number" && wait > LIMITS.waitMaxS
|
|
449
|
+
|
|
356
450
|
function seconds(args: Json, fallback: number): number {
|
|
357
451
|
const value = args.wait_s
|
|
358
452
|
if (value === undefined) return fallback
|
|
@@ -392,7 +486,9 @@ async function* chunks(input: RelayMcpOptions["input"]): AsyncIterable<Uint8Arra
|
|
|
392
486
|
}
|
|
393
487
|
|
|
394
488
|
function delivered(messages: Envelope[]): string {
|
|
395
|
-
|
|
489
|
+
const shown = (message: Envelope) =>
|
|
490
|
+
message.shown_at ? `(a hook already showed you this at ${clock(message.shown_at)}; that copy may reach you after this call)\n` : ""
|
|
491
|
+
return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n")
|
|
396
492
|
}
|
|
397
493
|
|
|
398
494
|
/**
|
|
@@ -401,6 +497,7 @@ function delivered(messages: Envelope[]): string {
|
|
|
401
497
|
*/
|
|
402
498
|
export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
403
499
|
const log = options.log ?? (() => {})
|
|
500
|
+
const tools = options.spawn ? TOOLS : TOOLS.filter((t) => t.name !== "relay_spawn")
|
|
404
501
|
const home = options.env?.HOME
|
|
405
502
|
const sent = new Map<string, { from: string; thread: string }>()
|
|
406
503
|
let client: RelayClient | undefined
|
|
@@ -409,7 +506,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
409
506
|
let me: EndpointView | null = null
|
|
410
507
|
let announced = false
|
|
411
508
|
let initialized = false
|
|
412
|
-
/** whether the client takes channel notifications: it says so, or it
|
|
509
|
+
/** whether the client takes channel notifications: it says so, or it was launched with channels (Claude Code does not say so itself) */
|
|
413
510
|
let channelClient = false
|
|
414
511
|
let stopped = false
|
|
415
512
|
/** seqs this shim pushed, so a push whose confirm was lost is not pushed again */
|
|
@@ -561,11 +658,41 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
561
658
|
if (result.held) return text(heldText(result.held))
|
|
562
659
|
const lines = reachLines(result.deliveries)
|
|
563
660
|
const to = result.deliveries.map((d) => (lines.length ? d.address : deliveryText(d))).join(", ")
|
|
661
|
+
const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting")
|
|
662
|
+
? [`relay_status with id ${shortId(result.id)} shows when it is read`]
|
|
663
|
+
: []
|
|
564
664
|
return {
|
|
565
|
-
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines].join("\n")),
|
|
665
|
+
...text([`sent ${result.id} (thread ${result.thread}) to ${to}${redactedText(result.redacted)}`, ...lines, ...receipt].join("\n")),
|
|
566
666
|
structuredContent: sentContent(result),
|
|
567
667
|
}
|
|
568
668
|
}
|
|
669
|
+
case "relay_spawn": {
|
|
670
|
+
const request = spawnRequest(args)
|
|
671
|
+
const spawn = options.spawn
|
|
672
|
+
if (!spawn) throw new RelayError("method_not_found", "no tool relay_spawn")
|
|
673
|
+
const view = await call<EndpointView>("whoami")
|
|
674
|
+
let started: { name: string; pid: number }
|
|
675
|
+
try {
|
|
676
|
+
const cwd = request.cwd ?? (view.cwd && isAbsolute(view.cwd) && isDirectory(view.cwd) ? view.cwd : undefined)
|
|
677
|
+
started = await spawn({ ...request, as: view.address, ...(view.harness ? { harness: view.harness } : {}), ...(cwd ? { cwd } : {}) })
|
|
678
|
+
} catch (error) {
|
|
679
|
+
return text(error instanceof Error ? error.message : String(error), true)
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
...text(
|
|
683
|
+
`${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)`,
|
|
684
|
+
),
|
|
685
|
+
structuredContent: { name: started.name, pid: started.pid },
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
case "relay_status": {
|
|
689
|
+
const receipts = await call<MessageReceipts>("receipts", { id: str(args, "id", true) })
|
|
690
|
+
const lines = receiptLines(receipts)
|
|
691
|
+
return {
|
|
692
|
+
...text([`${receipts.id}:`, ...(lines.length ? lines : ["no deliveries of it are kept any more"])].join("\n")),
|
|
693
|
+
structuredContent: { ...receipts },
|
|
694
|
+
}
|
|
695
|
+
}
|
|
569
696
|
case "relay_reply": {
|
|
570
697
|
let id = str(args, "id", true)
|
|
571
698
|
const body = str(args, "body", true)
|
|
@@ -594,7 +721,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
594
721
|
case "relay_inbox": {
|
|
595
722
|
const ack = bool(args, "ack") ?? true
|
|
596
723
|
const { messages } = await call<{ messages: Envelope[] }>("inbox", { wait_s: seconds(args, 0) })
|
|
597
|
-
if (!messages.length) return text(
|
|
724
|
+
if (!messages.length) return text(`no unread messages${capped(args.wait_s) ? `; waited ${LIMITS.waitMaxS} s (cap)` : ""}`)
|
|
598
725
|
for (const message of messages) remember(message)
|
|
599
726
|
if (ack) await call("ack", { seqs: messages.map((m) => m.seq) })
|
|
600
727
|
return text(delivered(messages))
|
|
@@ -780,9 +907,8 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
780
907
|
const protocolVersion = typeof asked === "string" && (MCP_PROTOCOLS as readonly string[]).includes(asked) ? asked : MCP_PROTOCOLS[0]
|
|
781
908
|
initialized = true
|
|
782
909
|
const capabilities = params.capabilities as { experimental?: Record<string, unknown> } | undefined
|
|
783
|
-
const client = params.clientInfo as { name?: unknown } | undefined
|
|
784
910
|
const channel = capabilities?.experimental?.["claude/channel"]
|
|
785
|
-
channelClient = (typeof channel === "object" && channel !== null) ||
|
|
911
|
+
channelClient = (typeof channel === "object" && channel !== null) || options.launchedWithChannels === true
|
|
786
912
|
return respond({
|
|
787
913
|
protocolVersion,
|
|
788
914
|
capabilities: { tools: {}, ...(options.channels ? { experimental: { "claude/channel": {} } } : {}) },
|
|
@@ -793,10 +919,10 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
|
|
|
793
919
|
case "ping":
|
|
794
920
|
return respond({})
|
|
795
921
|
case "tools/list":
|
|
796
|
-
return respond({ tools
|
|
922
|
+
return respond({ tools })
|
|
797
923
|
case "tools/call": {
|
|
798
924
|
if (!initialized) return write({ jsonrpc: "2.0", id, error: { code: -32002, message: "call initialize first" } })
|
|
799
|
-
if (!
|
|
925
|
+
if (!tools.some((t) => t.name === params.name)) {
|
|
800
926
|
return write({ jsonrpc: "2.0", id, error: { code: -32602, message: `unknown tool ${JSON.stringify(params.name)}` } })
|
|
801
927
|
}
|
|
802
928
|
return respond(await callTool(params))
|