@scp-dev/mcp 0.1.1 → 0.1.3

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/dist/index.d.ts CHANGED
@@ -13,10 +13,18 @@ interface RelayMcpOptions {
13
13
  input: AsyncIterable<Uint8Array | string> | ReadableStream<Uint8Array>;
14
14
  /** one JSON-RPC line, newline included */
15
15
  output: (line: string) => void;
16
+ /** whether the session is on the relay now: `dark` (why not) keeps the shim from claiming, `handle` is the name it joined under */
17
+ membership?: () => {
18
+ dark: string;
19
+ } | {
20
+ handle?: string;
21
+ };
16
22
  /** HOME shortens cwds in relay_list */
17
23
  env?: Record<string, string | undefined>;
18
- /** push deliveries as `notifications/claude/channel` */
24
+ /** push deliveries as `notifications/claude/channel` to a client that declares `claude/channel` or was launched with channels */
19
25
  channels?: boolean;
26
+ /** the client was started with channels loaded (Claude Code does not declare the capability itself) */
27
+ launchedWithChannels?: boolean;
20
28
  version?: string;
21
29
  /** 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
30
  spawn?: (request: SpawnRequest) => Promise<{
@@ -96,6 +104,10 @@ declare const TOOLS: readonly [{
96
104
  readonly type: "boolean";
97
105
  readonly description: "only for recipients allowed private data";
98
106
  };
107
+ readonly live: {
108
+ readonly type: "boolean";
109
+ readonly description: "refuse (not_live) instead of queueing when a recipient has no live session";
110
+ };
99
111
  };
100
112
  readonly required: readonly ["to", "body"];
101
113
  readonly additionalProperties: false;
@@ -275,6 +287,27 @@ declare const TOOLS: readonly [{
275
287
  readonly required: readonly ["now"];
276
288
  readonly additionalProperties: false;
277
289
  };
290
+ }, {
291
+ readonly name: "relay_join";
292
+ readonly description: "Put this session on the relay, so peers can message it and new messages reach you after your tool calls; name sets your handle once (renaming you in place when you already joined).";
293
+ readonly inputSchema: {
294
+ readonly type: "object";
295
+ readonly properties: {
296
+ readonly name: {
297
+ readonly type: "string";
298
+ readonly maxLength: 63;
299
+ };
300
+ };
301
+ readonly additionalProperties: false;
302
+ };
303
+ }, {
304
+ readonly name: "relay_leave";
305
+ readonly description: "Take this session off the relay: it goes offline and gets no new messages pushed; its mail is kept until relay_join.";
306
+ readonly inputSchema: {
307
+ readonly type: "object";
308
+ readonly properties: {};
309
+ readonly additionalProperties: false;
310
+ };
278
311
  }, {
279
312
  readonly name: "relay_set_title";
280
313
  readonly description: "Set your title, a short line on what you work on that every agent and your user see in relay_list and swarm list; an empty title clears it.";
package/dist/index.js CHANGED
@@ -70,7 +70,8 @@ var TOOLS = [
70
70
  to: { oneOf: [{ type: "string" }, { type: "array", items: { type: "string" }, minItems: 1, maxItems: LIMITS.recipients }] },
71
71
  body: { type: "string" },
72
72
  thread: { type: "string", description: "continue this thread: its id, or the id or short id of a message in it" },
73
- private: { type: "boolean", description: "only for recipients allowed private data" }
73
+ private: { type: "boolean", description: "only for recipients allowed private data" },
74
+ live: { type: "boolean", description: "refuse (not_live) instead of queueing when a recipient has no live session" }
74
75
  },
75
76
  required: ["to", "body"],
76
77
  additionalProperties: false
@@ -157,6 +158,16 @@ var TOOLS = [
157
158
  description: "Set your now, a one-line note on what you are doing right now, shown with your title in relay_list and relay_peers; your status state stays (working when unset).",
158
159
  inputSchema: { type: "object", properties: { now: { type: "string", maxLength: LIMITS.noteChars } }, required: ["now"], additionalProperties: false }
159
160
  },
161
+ {
162
+ name: "relay_join",
163
+ description: "Put this session on the relay, so peers can message it and new messages reach you after your tool calls; name sets your handle once (renaming you in place when you already joined).",
164
+ inputSchema: { type: "object", properties: { name: { type: "string", maxLength: 63 } }, additionalProperties: false }
165
+ },
166
+ {
167
+ name: "relay_leave",
168
+ description: "Take this session off the relay: it goes offline and gets no new messages pushed; its mail is kept until relay_join.",
169
+ inputSchema: { type: "object", properties: {}, additionalProperties: false }
170
+ },
160
171
  {
161
172
  name: "relay_set_title",
162
173
  description: `Set your title, a short line on what you work on that every agent and your user see in relay_list and ${CLI_NAME} list; an empty title clears it.`,
@@ -336,6 +347,7 @@ function isDirectory(path) {
336
347
  return false;
337
348
  }
338
349
  }
350
+ var capped = (wait) => typeof wait === "number" && wait > LIMITS.waitMaxS;
339
351
  function seconds(args, fallback) {
340
352
  const value = args.wait_s;
341
353
  if (value === void 0) return fallback;
@@ -397,7 +409,13 @@ async function runRelayMcp(options) {
397
409
  sent.set(message.id, { from: message.from.address, thread: message.thread });
398
410
  while (sent.size > DELIVERED_KEEP) sent.delete(sent.keys().next().value);
399
411
  };
412
+ function sessionClaim() {
413
+ const joined = options.membership?.() ?? {};
414
+ if ("dark" in joined) throw new RelayError("unavailable", joined.dark);
415
+ return joined.handle ? { ...options.claim, handle: joined.handle } : options.claim;
416
+ }
400
417
  async function open() {
418
+ const claim = sessionClaim();
401
419
  const next = await options.connect();
402
420
  try {
403
421
  let hello;
@@ -408,7 +426,7 @@ async function runRelayMcp(options) {
408
426
  if (!(error instanceof RelayError)) throw error;
409
427
  }
410
428
  }
411
- hello ??= await next.hello({ claim: options.claim, ...options.claim.cwd === void 0 ? {} : { cwd: options.claim.cwd } });
429
+ hello ??= await next.hello({ claim, ...claim.cwd === void 0 ? {} : { cwd: claim.cwd } });
412
430
  if (hello.token) token = hello.token;
413
431
  me = hello.endpoint;
414
432
  if (!announced) {
@@ -515,10 +533,11 @@ async function runRelayMcp(options) {
515
533
  body: str(args, "body", true),
516
534
  ...args.thread !== void 0 ? { thread: str(args, "thread") } : {},
517
535
  ...bool(args, "private") ? { data_class: "private" } : {},
536
+ ...bool(args, "live") ? { live: true } : {},
518
537
  client_id: clientId()
519
538
  });
520
539
  if (result.held) return text(heldText(result.held));
521
- const lines = reachLines(result.deliveries);
540
+ const lines = [...reachLines(result.deliveries), ...result.warnings ?? []];
522
541
  const to = result.deliveries.map((d) => lines.length ? d.address : deliveryText(d)).join(", ");
523
542
  const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting") ? [`relay_status with id ${shortId(result.id)} shows when it is read`] : [];
524
543
  return {
@@ -581,7 +600,7 @@ async function runRelayMcp(options) {
581
600
  case "relay_inbox": {
582
601
  const ack = bool(args, "ack") ?? true;
583
602
  const { messages } = await call("inbox", { wait_s: seconds(args, 0) });
584
- if (!messages.length) return text("no unread messages");
603
+ if (!messages.length) return text(`no unread messages${capped(args.wait_s) ? `; waited ${LIMITS.waitMaxS} s (cap)` : ""}`);
585
604
  for (const message of messages) remember(message);
586
605
  if (ack) await call("ack", { seqs: messages.map((m) => m.seq) });
587
606
  return text(delivered(messages));
@@ -613,6 +632,27 @@ async function runRelayMcp(options) {
613
632
  const view = await call("status", { note: args.now });
614
633
  return text(`${view.address}: ${view.status?.state}${view.status?.note ? `: ${quote(view.status.note)}` : ""}`);
615
634
  }
635
+ case "relay_join": {
636
+ const name2 = str(args, "name");
637
+ const joined = options.membership?.() ?? {};
638
+ const claim = "handle" in joined && joined.handle ? { ...options.claim, handle: joined.handle } : options.claim;
639
+ const next = await options.connect();
640
+ try {
641
+ const hello = await next.hello({ transient: true, claim, join: name2 ? { handle: name2 } : {} });
642
+ me = hello.endpoint;
643
+ } finally {
644
+ next.close();
645
+ }
646
+ await ensure();
647
+ return text(`joined the relay as ${me?.address}: peers can message you, and new messages reach you after your tool calls`);
648
+ }
649
+ case "relay_leave": {
650
+ const view = await call("leave");
651
+ client?.close();
652
+ client = void 0;
653
+ token = void 0;
654
+ return text(`${view.address} left the relay: it shows offline and gets no new messages pushed; its mail is kept until relay_join`);
655
+ }
616
656
  case "relay_set_title": {
617
657
  if (typeof args.title !== "string") throw new ToolInputError("title must be a string");
618
658
  const view = await call("title", { text: args.title });
@@ -766,9 +806,8 @@ ${wrapMessage(message)}`,
766
806
  const protocolVersion = typeof asked === "string" && MCP_PROTOCOLS.includes(asked) ? asked : MCP_PROTOCOLS[0];
767
807
  initialized = true;
768
808
  const capabilities = params.capabilities;
769
- const client2 = params.clientInfo;
770
809
  const channel = capabilities?.experimental?.["claude/channel"];
771
- channelClient = typeof channel === "object" && channel !== null || client2?.name === "claude-code";
810
+ channelClient = typeof channel === "object" && channel !== null || options.launchedWithChannels === true;
772
811
  return respond({
773
812
  protocolVersion,
774
813
  capabilities: { tools: {}, ...options.channels ? { experimental: { "claude/channel": {} } } : {} },
@@ -807,7 +846,16 @@ ${wrapMessage(message)}`,
807
846
  inFlight.add(running);
808
847
  void running.finally(() => inFlight.delete(running));
809
848
  }
810
- ensure().catch((error) => log(`${CLI_NAME} mcp: could not reach the relay broker yet: ${error instanceof Error ? error.message : String(error)}`));
849
+ const dark = (() => {
850
+ try {
851
+ sessionClaim();
852
+ return false;
853
+ } catch {
854
+ return true;
855
+ }
856
+ })();
857
+ if (!dark)
858
+ ensure().catch((error) => log(`${CLI_NAME} mcp: could not reach the relay broker yet: ${error instanceof Error ? error.message : String(error)}`));
811
859
  const heartbeat = setInterval(() => {
812
860
  call("whoami").catch(() => {
813
861
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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.1",
33
- "@scp-dev/core": "0.1.1"
32
+ "@scp-dev/broker": "0.1.3",
33
+ "@scp-dev/core": "0.1.2"
34
34
  },
35
35
  "devDependencies": {
36
36
  "tsup": "^8.5.1"
package/src/server.ts CHANGED
@@ -57,10 +57,14 @@ export interface RelayMcpOptions {
57
57
  input: AsyncIterable<Uint8Array | string> | ReadableStream<Uint8Array>
58
58
  /** one JSON-RPC line, newline included */
59
59
  output: (line: string) => void
60
+ /** whether the session is on the relay now: `dark` (why not) keeps the shim from claiming, `handle` is the name it joined under */
61
+ membership?: () => { dark: string } | { handle?: string }
60
62
  /** HOME shortens cwds in relay_list */
61
63
  env?: Record<string, string | undefined>
62
- /** push deliveries as `notifications/claude/channel` */
64
+ /** push deliveries as `notifications/claude/channel` to a client that declares `claude/channel` or was launched with channels */
63
65
  channels?: boolean
66
+ /** the client was started with channels loaded (Claude Code does not declare the capability itself) */
67
+ launchedWithChannels?: boolean
64
68
  version?: string
65
69
  /** 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
70
  spawn?: (request: SpawnRequest) => Promise<{ name: string; pid: number }>
@@ -135,6 +139,7 @@ export const TOOLS = [
135
139
  body: { type: "string" },
136
140
  thread: { type: "string", description: "continue this thread: its id, or the id or short id of a message in it" },
137
141
  private: { type: "boolean", description: "only for recipients allowed private data" },
142
+ live: { type: "boolean", description: "refuse (not_live) instead of queueing when a recipient has no live session" },
138
143
  },
139
144
  required: ["to", "body"],
140
145
  additionalProperties: false,
@@ -224,6 +229,17 @@ export const TOOLS = [
224
229
  "Set your now, a one-line note on what you are doing right now, shown with your title in relay_list and relay_peers; your status state stays (working when unset).",
225
230
  inputSchema: { type: "object", properties: { now: { type: "string", maxLength: LIMITS.noteChars } }, required: ["now"], additionalProperties: false },
226
231
  },
232
+ {
233
+ name: "relay_join",
234
+ description:
235
+ "Put this session on the relay, so peers can message it and new messages reach you after your tool calls; name sets your handle once (renaming you in place when you already joined).",
236
+ inputSchema: { type: "object", properties: { name: { type: "string", maxLength: 63 } }, additionalProperties: false },
237
+ },
238
+ {
239
+ name: "relay_leave",
240
+ description: "Take this session off the relay: it goes offline and gets no new messages pushed; its mail is kept until relay_join.",
241
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
242
+ },
227
243
  {
228
244
  name: "relay_set_title",
229
245
  description: `Set your title, a short line on what you work on that every agent and your user see in relay_list and ${CLI_NAME} list; an empty title clears it.`,
@@ -443,6 +459,8 @@ function isDirectory(path: string): boolean {
443
459
  }
444
460
  }
445
461
 
462
+ const capped = (wait: unknown): boolean => typeof wait === "number" && wait > LIMITS.waitMaxS
463
+
446
464
  function seconds(args: Json, fallback: number): number {
447
465
  const value = args.wait_s
448
466
  if (value === undefined) return fallback
@@ -502,7 +520,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
502
520
  let me: EndpointView | null = null
503
521
  let announced = false
504
522
  let initialized = false
505
- /** whether the client takes channel notifications: it says so, or it is Claude Code, which takes them without saying so when started with channels */
523
+ /** whether the client takes channel notifications: it says so, or it was launched with channels (Claude Code does not say so itself) */
506
524
  let channelClient = false
507
525
  let stopped = false
508
526
  /** seqs this shim pushed, so a push whose confirm was lost is not pushed again */
@@ -519,7 +537,15 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
519
537
  while (sent.size > DELIVERED_KEEP) sent.delete(sent.keys().next().value!)
520
538
  }
521
539
 
540
+ /** the claim for the session as it stands; a session off the relay is refused `unavailable` */
541
+ function sessionClaim(): Claim {
542
+ const joined = options.membership?.() ?? {}
543
+ if ("dark" in joined) throw new RelayError("unavailable", joined.dark)
544
+ return joined.handle ? { ...options.claim, handle: joined.handle } : options.claim
545
+ }
546
+
522
547
  async function open(): Promise<RelayClient> {
548
+ const claim = sessionClaim()
523
549
  const next = await options.connect()
524
550
  try {
525
551
  let hello: HelloResult | undefined
@@ -530,7 +556,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
530
556
  if (!(error instanceof RelayError)) throw error
531
557
  }
532
558
  }
533
- hello ??= await next.hello({ claim: options.claim, ...(options.claim.cwd === undefined ? {} : { cwd: options.claim.cwd }) })
559
+ hello ??= await next.hello({ claim, ...(claim.cwd === undefined ? {} : { cwd: claim.cwd }) })
534
560
  if (hello.token) token = hello.token
535
561
  me = hello.endpoint
536
562
  if (!announced) {
@@ -649,10 +675,11 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
649
675
  body: str(args, "body", true),
650
676
  ...(args.thread !== undefined ? { thread: str(args, "thread") } : {}),
651
677
  ...(bool(args, "private") ? { data_class: "private" } : {}),
678
+ ...(bool(args, "live") ? { live: true } : {}),
652
679
  client_id: clientId(),
653
680
  })
654
681
  if (result.held) return text(heldText(result.held))
655
- const lines = reachLines(result.deliveries)
682
+ const lines = [...reachLines(result.deliveries), ...(result.warnings ?? [])]
656
683
  const to = result.deliveries.map((d) => (lines.length ? d.address : deliveryText(d))).join(", ")
657
684
  const receipt = result.deliveries.some((d) => d.state === "running" || d.state === "starting")
658
685
  ? [`relay_status with id ${shortId(result.id)} shows when it is read`]
@@ -717,7 +744,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
717
744
  case "relay_inbox": {
718
745
  const ack = bool(args, "ack") ?? true
719
746
  const { messages } = await call<{ messages: Envelope[] }>("inbox", { wait_s: seconds(args, 0) })
720
- if (!messages.length) return text("no unread messages")
747
+ if (!messages.length) return text(`no unread messages${capped(args.wait_s) ? `; waited ${LIMITS.waitMaxS} s (cap)` : ""}`)
721
748
  for (const message of messages) remember(message)
722
749
  if (ack) await call("ack", { seqs: messages.map((m) => m.seq) })
723
750
  return text(delivered(messages))
@@ -748,6 +775,27 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
748
775
  const view = await call<EndpointView>("status", { note: args.now })
749
776
  return text(`${view.address}: ${view.status?.state}${view.status?.note ? `: ${quote(view.status.note)}` : ""}`)
750
777
  }
778
+ case "relay_join": {
779
+ const name = str(args, "name")
780
+ const joined = options.membership?.() ?? {}
781
+ const claim = "handle" in joined && joined.handle ? { ...options.claim, handle: joined.handle } : options.claim
782
+ const next = await options.connect()
783
+ try {
784
+ const hello = await next.hello({ transient: true, claim, join: name ? { handle: name } : {} })
785
+ me = hello.endpoint
786
+ } finally {
787
+ next.close()
788
+ }
789
+ await ensure()
790
+ return text(`joined the relay as ${me?.address}: peers can message you, and new messages reach you after your tool calls`)
791
+ }
792
+ case "relay_leave": {
793
+ const view = await call<EndpointView>("leave")
794
+ client?.close()
795
+ client = undefined
796
+ token = undefined
797
+ return text(`${view.address} left the relay: it shows offline and gets no new messages pushed; its mail is kept until relay_join`)
798
+ }
751
799
  case "relay_set_title": {
752
800
  if (typeof args.title !== "string") throw new ToolInputError("title must be a string")
753
801
  const view = await call<EndpointView>("title", { text: args.title })
@@ -903,9 +951,8 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
903
951
  const protocolVersion = typeof asked === "string" && (MCP_PROTOCOLS as readonly string[]).includes(asked) ? asked : MCP_PROTOCOLS[0]
904
952
  initialized = true
905
953
  const capabilities = params.capabilities as { experimental?: Record<string, unknown> } | undefined
906
- const client = params.clientInfo as { name?: unknown } | undefined
907
954
  const channel = capabilities?.experimental?.["claude/channel"]
908
- channelClient = (typeof channel === "object" && channel !== null) || client?.name === "claude-code"
955
+ channelClient = (typeof channel === "object" && channel !== null) || options.launchedWithChannels === true
909
956
  return respond({
910
957
  protocolVersion,
911
958
  capabilities: { tools: {}, ...(options.channels ? { experimental: { "claude/channel": {} } } : {}) },
@@ -946,7 +993,16 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
946
993
  void running.finally(() => inFlight.delete(running))
947
994
  }
948
995
 
949
- ensure().catch((error: unknown) => log(`${CLI_NAME} mcp: could not reach the relay broker yet: ${error instanceof Error ? error.message : String(error)}`))
996
+ const dark = (() => {
997
+ try {
998
+ sessionClaim()
999
+ return false
1000
+ } catch {
1001
+ return true
1002
+ }
1003
+ })()
1004
+ if (!dark)
1005
+ ensure().catch((error: unknown) => log(`${CLI_NAME} mcp: could not reach the relay broker yet: ${error instanceof Error ? error.message : String(error)}`))
950
1006
  const heartbeat = setInterval(() => {
951
1007
  call("whoami").catch(() => {})
952
1008
  }, options.heartbeatMs ?? 30_000)