@scp-dev/mcp 0.1.9 → 0.2.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/dist/index.d.ts CHANGED
@@ -25,6 +25,8 @@ interface RelayMcpOptions {
25
25
  channels?: boolean;
26
26
  /** the client was started with channels loaded (Claude Code does not declare the capability itself) */
27
27
  launchedWithChannels?: boolean;
28
+ /** Claude Code's per-session inbox socket (CLAUDE_CODE_MESSAGING_SOCKET): with channels enabled but not loaded, as in a background session, deliveries are queued there as the next turn */
29
+ sessionInbox?: SessionInbox;
28
30
  /** the wake the endpoint advertises while this shim pushes by channel, over `pi` for a pi client and `channels` for any other */
29
31
  wake?: Wake;
30
32
  version?: string;
@@ -306,6 +308,19 @@ declare const TOOLS: readonly [{
306
308
  };
307
309
  readonly additionalProperties: false;
308
310
  };
311
+ }, {
312
+ readonly name: "relay_link_accept";
313
+ readonly description: "Accept a link request; you then sit under the requesting agent as its worker.";
314
+ readonly inputSchema: {
315
+ readonly type: "object";
316
+ readonly properties: {
317
+ readonly id: {
318
+ readonly type: "string";
319
+ };
320
+ };
321
+ readonly required: readonly ["id"];
322
+ readonly additionalProperties: false;
323
+ };
309
324
  }, {
310
325
  readonly name: "relay_leave";
311
326
  readonly description: "Take this session off the relay: it goes offline and gets no new messages pushed; its mail is kept until relay_join.";
@@ -480,10 +495,19 @@ declare const TOOLS: readonly [{
480
495
  readonly additionalProperties: false;
481
496
  };
482
497
  }];
498
+ type SessionInbox = {
499
+ socket: string;
500
+ token?: string;
501
+ sessionId?: string;
502
+ };
503
+ /** the inbox of the Claude Code session that started this process, from its environment */
504
+ declare function sessionInboxOf(env: Record<string, string | undefined>): SessionInbox | undefined;
505
+ /** queues one user turn on a Claude Code session's inbox socket; resolves once the lines are flushed and the socket closed */
506
+ declare function sendToSessionInbox(inbox: SessionInbox, content: string, timeoutMs?: number): Promise<void>;
483
507
  /**
484
508
  * The relay's MCP server over stdio: newline-delimited JSON-RPC 2.0, nothing else on the output.
485
509
  * It claims one agent endpoint, keeps one broker connection (heartbeat, reconnect on the next call), and resolves when the input ends.
486
510
  */
487
511
  declare function runRelayMcp(options: RelayMcpOptions): Promise<void>;
488
512
 
489
- export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, type SpawnRequest, TOOLS, runRelayMcp };
513
+ export { INSTRUCTIONS, MCP_PROTOCOLS, type RelayMcpOptions, type SessionInbox, type SpawnRequest, TOOLS, runRelayMcp, sendToSessionInbox, sessionInboxOf };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/server.ts
2
2
  import { statSync } from "fs";
3
+ import { connect as connectSocket } from "net";
3
4
  import { isAbsolute } from "path";
4
5
  import {
5
6
  CLI_NAME,
@@ -168,6 +169,11 @@ var TOOLS = [
168
169
  additionalProperties: false
169
170
  }
170
171
  },
172
+ {
173
+ name: "relay_link_accept",
174
+ description: "Accept a link request; you then sit under the requesting agent as its worker.",
175
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false }
176
+ },
171
177
  {
172
178
  name: "relay_leave",
173
179
  description: "Take this session off the relay: it goes offline and gets no new messages pushed; its mail is kept until relay_join.",
@@ -393,6 +399,37 @@ function delivered(messages) {
393
399
  ` : "";
394
400
  return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n");
395
401
  }
402
+ function sessionInboxOf(env) {
403
+ const socket = env.CLAUDE_CODE_MESSAGING_SOCKET;
404
+ if (!socket || !isAbsolute(socket)) return void 0;
405
+ return {
406
+ socket,
407
+ ...env.CLAUDE_CODE_MESSAGING_TOKEN ? { token: env.CLAUDE_CODE_MESSAGING_TOKEN } : {},
408
+ ...env.CLAUDE_CODE_SESSION_ID ? { sessionId: env.CLAUDE_CODE_SESSION_ID } : {}
409
+ };
410
+ }
411
+ function sendToSessionInbox(inbox, content, timeoutMs = 5e3) {
412
+ return new Promise((resolve, reject) => {
413
+ const socket = connectSocket(inbox.socket);
414
+ const timer = setTimeout(() => socket.destroy(new Error("session inbox timed out")), timeoutMs);
415
+ socket.once("error", (error) => {
416
+ clearTimeout(timer);
417
+ reject(error);
418
+ });
419
+ socket.once("close", (failed) => {
420
+ clearTimeout(timer);
421
+ if (!failed) resolve();
422
+ });
423
+ socket.once("connect", () => {
424
+ const lines = [
425
+ ...inbox.token ? [{ type: "auth", token: inbox.token }] : [],
426
+ { type: "user", ...inbox.sessionId ? { session_id: inbox.sessionId } : {}, priority: "next", message: { role: "user", content } }
427
+ ];
428
+ socket.end(lines.map((line) => `${JSON.stringify(line)}
429
+ `).join(""));
430
+ });
431
+ });
432
+ }
396
433
  async function runRelayMcp(options) {
397
434
  const log = options.log ?? (() => {
398
435
  });
@@ -406,6 +443,7 @@ async function runRelayMcp(options) {
406
443
  let announced = false;
407
444
  let initialized = false;
408
445
  let channelClient = false;
446
+ let inbox;
409
447
  let stopped = false;
410
448
  const channelPushed = /* @__PURE__ */ new Set();
411
449
  let channelLoop;
@@ -669,6 +707,11 @@ async function runRelayMcp(options) {
669
707
  `joined the relay as ${me?.address}${me?.parent ? ` under ${me.parent}` : ""}: peers can message you, and new messages reach you after your tool calls`
670
708
  );
671
709
  }
710
+ case "relay_link_accept": {
711
+ const result = await call("link_accept", { id: str(args, "id", true) });
712
+ me = result.endpoint;
713
+ return text(`${result.endpoint.address} now sits under ${result.parent} as its worker`);
714
+ }
672
715
  case "relay_leave": {
673
716
  const view = await call("leave");
674
717
  client?.close();
@@ -778,7 +821,7 @@ ${wrapPeek(result)}`);
778
821
  }
779
822
  }
780
823
  async function pushChannel() {
781
- while (!stopped && channelClient) {
824
+ while (!stopped && (channelClient || inbox)) {
782
825
  try {
783
826
  const { messages, hold } = await call("inbox", { wait_s: LIMITS.injectHoldS, offer: true });
784
827
  for (const message of messages) {
@@ -786,16 +829,22 @@ ${wrapPeek(result)}`);
786
829
  channelPushed.add(message.seq);
787
830
  if (channelPushed.size > DELIVERED_KEEP) channelPushed.delete(channelPushed.values().next().value);
788
831
  remember(message);
789
- write({
790
- jsonrpc: "2.0",
791
- method: "notifications/claude/channel",
792
- params: {
793
- content: `${PREAMBLE}
832
+ const content = `${PREAMBLE}
794
833
 
795
- ${wrapMessage(message)}`,
796
- meta: { from: message.from.address, id: message.id, seq: String(message.seq), thread: message.thread }
834
+ ${wrapMessage(message)}`;
835
+ if (inbox) {
836
+ try {
837
+ await sendToSessionInbox(inbox, content);
838
+ } catch (error) {
839
+ channelPushed.delete(message.seq);
840
+ throw error;
797
841
  }
798
- });
842
+ } else
843
+ write({
844
+ jsonrpc: "2.0",
845
+ method: "notifications/claude/channel",
846
+ params: { content, meta: { from: message.from.address, id: message.id, seq: String(message.seq), thread: message.thread } }
847
+ });
799
848
  }
800
849
  const pushed = messages.flatMap((message) => message.seq !== void 0 && channelPushed.has(message.seq) ? [message.seq] : []);
801
850
  if (pushed.length) await call("injected", { seqs: pushed, hold });
@@ -819,7 +868,8 @@ ${wrapMessage(message)}`,
819
868
  }
820
869
  const params = typeof message.params === "object" && message.params !== null ? message.params : {};
821
870
  if (!isRequest) {
822
- if (method === "notifications/initialized" && options.channels && channelClient) channelLoop ??= pushChannel().finally(() => channelLoop = void 0);
871
+ if (method === "notifications/initialized" && options.channels && (channelClient || inbox))
872
+ channelLoop ??= pushChannel().finally(() => channelLoop = void 0);
823
873
  return;
824
874
  }
825
875
  const respond = (result) => write({ jsonrpc: "2.0", id, result });
@@ -832,7 +882,8 @@ ${wrapMessage(message)}`,
832
882
  const channel = capabilities?.experimental?.["claude/channel"];
833
883
  channelClient = typeof channel === "object" && channel !== null || options.launchedWithChannels === true;
834
884
  const clientInfo = params.clientInfo;
835
- wake = options.channels && channelClient ? options.wake ?? (clientInfo?.name === "pi" ? "pi" : "channels") : null;
885
+ inbox = options.channels && !channelClient && clientInfo?.name === "claude-code" ? options.sessionInbox : void 0;
886
+ wake = options.channels && channelClient ? options.wake ?? (clientInfo?.name === "pi" ? "pi" : "channels") : inbox ? "claude-inbox" : null;
836
887
  advertise();
837
888
  return respond({
838
889
  protocolVersion,
@@ -922,5 +973,7 @@ export {
922
973
  INSTRUCTIONS,
923
974
  MCP_PROTOCOLS,
924
975
  TOOLS,
925
- runRelayMcp
976
+ runRelayMcp,
977
+ sendToSessionInbox,
978
+ sessionInboxOf
926
979
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scp-dev/mcp",
3
- "version": "0.1.9",
3
+ "version": "0.2.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.3.0",
33
- "@scp-dev/core": "0.2.0"
32
+ "@scp-dev/broker": "0.4.1",
33
+ "@scp-dev/core": "0.3.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "tsup": "^8.5.1"
package/src/index.ts CHANGED
@@ -1 +1,11 @@
1
- export { INSTRUCTIONS, MCP_PROTOCOLS, runRelayMcp, TOOLS, type RelayMcpOptions, type SpawnRequest } from "./server"
1
+ export {
2
+ INSTRUCTIONS,
3
+ MCP_PROTOCOLS,
4
+ runRelayMcp,
5
+ sendToSessionInbox,
6
+ sessionInboxOf,
7
+ TOOLS,
8
+ type RelayMcpOptions,
9
+ type SessionInbox,
10
+ type SpawnRequest,
11
+ } from "./server"
package/src/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { statSync } from "node:fs"
2
+ import { connect as connectSocket } from "node:net"
2
3
  import { isAbsolute } from "node:path"
3
4
  import {
4
5
  type AskResult,
@@ -67,6 +68,8 @@ export interface RelayMcpOptions {
67
68
  channels?: boolean
68
69
  /** the client was started with channels loaded (Claude Code does not declare the capability itself) */
69
70
  launchedWithChannels?: boolean
71
+ /** Claude Code's per-session inbox socket (CLAUDE_CODE_MESSAGING_SOCKET): with channels enabled but not loaded, as in a background session, deliveries are queued there as the next turn */
72
+ sessionInbox?: SessionInbox
70
73
  /** the wake the endpoint advertises while this shim pushes by channel, over `pi` for a pi client and `channels` for any other */
71
74
  wake?: Wake
72
75
  version?: string
@@ -243,6 +246,11 @@ export const TOOLS = [
243
246
  additionalProperties: false,
244
247
  },
245
248
  },
249
+ {
250
+ name: "relay_link_accept",
251
+ description: "Accept a link request; you then sit under the requesting agent as its worker.",
252
+ inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"], additionalProperties: false },
253
+ },
246
254
  {
247
255
  name: "relay_leave",
248
256
  description: "Take this session off the relay: it goes offline and gets no new messages pushed; its mail is kept until relay_join.",
@@ -517,6 +525,42 @@ function delivered(messages: Envelope[]): string {
517
525
  return [PREAMBLE, ...messages.map((message) => `${shown(message)}${wrapMessage(message)}`)].join("\n\n")
518
526
  }
519
527
 
528
+ export type SessionInbox = { socket: string; token?: string; sessionId?: string }
529
+
530
+ /** the inbox of the Claude Code session that started this process, from its environment */
531
+ export function sessionInboxOf(env: Record<string, string | undefined>): SessionInbox | undefined {
532
+ const socket = env.CLAUDE_CODE_MESSAGING_SOCKET
533
+ if (!socket || !isAbsolute(socket)) return undefined
534
+ return {
535
+ socket,
536
+ ...(env.CLAUDE_CODE_MESSAGING_TOKEN ? { token: env.CLAUDE_CODE_MESSAGING_TOKEN } : {}),
537
+ ...(env.CLAUDE_CODE_SESSION_ID ? { sessionId: env.CLAUDE_CODE_SESSION_ID } : {}),
538
+ }
539
+ }
540
+
541
+ /** queues one user turn on a Claude Code session's inbox socket; resolves once the lines are flushed and the socket closed */
542
+ export function sendToSessionInbox(inbox: SessionInbox, content: string, timeoutMs = 5000): Promise<void> {
543
+ return new Promise((resolve, reject) => {
544
+ const socket = connectSocket(inbox.socket)
545
+ const timer = setTimeout(() => socket.destroy(new Error("session inbox timed out")), timeoutMs)
546
+ socket.once("error", (error) => {
547
+ clearTimeout(timer)
548
+ reject(error)
549
+ })
550
+ socket.once("close", (failed) => {
551
+ clearTimeout(timer)
552
+ if (!failed) resolve()
553
+ })
554
+ socket.once("connect", () => {
555
+ const lines = [
556
+ ...(inbox.token ? [{ type: "auth", token: inbox.token }] : []),
557
+ { type: "user", ...(inbox.sessionId ? { session_id: inbox.sessionId } : {}), priority: "next", message: { role: "user", content } },
558
+ ]
559
+ socket.end(lines.map((line) => `${JSON.stringify(line)}\n`).join(""))
560
+ })
561
+ })
562
+ }
563
+
520
564
  /**
521
565
  * The relay's MCP server over stdio: newline-delimited JSON-RPC 2.0, nothing else on the output.
522
566
  * It claims one agent endpoint, keeps one broker connection (heartbeat, reconnect on the next call), and resolves when the input ends.
@@ -534,6 +578,8 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
534
578
  let initialized = false
535
579
  /** whether the client takes channel notifications: it says so, or it was launched with channels (Claude Code does not say so itself) */
536
580
  let channelClient = false
581
+ /** the session inbox deliveries go to instead, for a Claude Code client without channels loaded */
582
+ let inbox: SessionInbox | undefined
537
583
  let stopped = false
538
584
  /** seqs this shim pushed, so a push whose confirm was lost is not pushed again */
539
585
  const channelPushed = new Set<number>()
@@ -817,6 +863,11 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
817
863
  `joined the relay as ${me?.address}${me?.parent ? ` under ${me.parent}` : ""}: peers can message you, and new messages reach you after your tool calls`,
818
864
  )
819
865
  }
866
+ case "relay_link_accept": {
867
+ const result = await call<{ endpoint: EndpointView; parent: string }>("link_accept", { id: str(args, "id", true) })
868
+ me = result.endpoint
869
+ return text(`${result.endpoint.address} now sits under ${result.parent} as its worker`)
870
+ }
820
871
  case "relay_leave": {
821
872
  const view = await call<EndpointView>("leave")
822
873
  client?.close()
@@ -929,7 +980,7 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
929
980
  }
930
981
 
931
982
  async function pushChannel(): Promise<void> {
932
- while (!stopped && channelClient) {
983
+ while (!stopped && (channelClient || inbox)) {
933
984
  try {
934
985
  const { messages, hold } = await call<{ messages: Envelope[]; hold?: string | null }>("inbox", { wait_s: LIMITS.injectHoldS, offer: true })
935
986
  for (const message of messages) {
@@ -937,14 +988,20 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
937
988
  channelPushed.add(message.seq)
938
989
  if (channelPushed.size > DELIVERED_KEEP) channelPushed.delete(channelPushed.values().next().value!)
939
990
  remember(message)
940
- write({
941
- jsonrpc: "2.0",
942
- method: "notifications/claude/channel",
943
- params: {
944
- content: `${PREAMBLE}\n\n${wrapMessage(message)}`,
945
- meta: { from: message.from.address, id: message.id, seq: String(message.seq), thread: message.thread },
946
- },
947
- })
991
+ const content = `${PREAMBLE}\n\n${wrapMessage(message)}`
992
+ if (inbox) {
993
+ try {
994
+ await sendToSessionInbox(inbox, content)
995
+ } catch (error) {
996
+ channelPushed.delete(message.seq)
997
+ throw error
998
+ }
999
+ } else
1000
+ write({
1001
+ jsonrpc: "2.0",
1002
+ method: "notifications/claude/channel",
1003
+ params: { content, meta: { from: message.from.address, id: message.id, seq: String(message.seq), thread: message.thread } },
1004
+ })
948
1005
  }
949
1006
  const pushed = messages.flatMap((message) => (message.seq !== undefined && channelPushed.has(message.seq) ? [message.seq] : []))
950
1007
  if (pushed.length) await call("injected", { seqs: pushed, hold })
@@ -969,7 +1026,8 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
969
1026
  }
970
1027
  const params = (typeof message.params === "object" && message.params !== null ? message.params : {}) as Json
971
1028
  if (!isRequest) {
972
- if (method === "notifications/initialized" && options.channels && channelClient) channelLoop ??= pushChannel().finally(() => (channelLoop = undefined))
1029
+ if (method === "notifications/initialized" && options.channels && (channelClient || inbox))
1030
+ channelLoop ??= pushChannel().finally(() => (channelLoop = undefined))
973
1031
  return
974
1032
  }
975
1033
  const respond = (result: unknown) => write({ jsonrpc: "2.0", id, result })
@@ -982,7 +1040,8 @@ export async function runRelayMcp(options: RelayMcpOptions): Promise<void> {
982
1040
  const channel = capabilities?.experimental?.["claude/channel"]
983
1041
  channelClient = (typeof channel === "object" && channel !== null) || options.launchedWithChannels === true
984
1042
  const clientInfo = params.clientInfo as { name?: unknown } | undefined
985
- wake = options.channels && channelClient ? (options.wake ?? (clientInfo?.name === "pi" ? "pi" : "channels")) : null
1043
+ inbox = options.channels && !channelClient && clientInfo?.name === "claude-code" ? options.sessionInbox : undefined
1044
+ wake = options.channels && channelClient ? (options.wake ?? (clientInfo?.name === "pi" ? "pi" : "channels")) : inbox ? "claude-inbox" : null
986
1045
  advertise()
987
1046
  return respond({
988
1047
  protocolVersion,