From dd0010db6280ff470cbb722792adb11d451ade80 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 5 May 2026 17:49:21 -0400 Subject: [PATCH] feat(relay): expand to dev-c role + python/ts MCP fallback shims queue.ts and server.ts now know about dev-c alongside pm/dev-a/dev-b so the four-role coordination paradigm works end-to-end. start.sh opens a fourth window for dev-c. call.py and call.ts are HTTP shims that agents can use when the MCP relay tools aren't registered in their session (the kickoff prompts reference call.py by path as a fallback). Co-Authored-By: Claude Opus 4.7 --- tools/relay/call.py | 81 +++++++++++++++++++++++++++++++++++++++++++ tools/relay/call.ts | 25 +++++++++++++ tools/relay/queue.ts | 5 +-- tools/relay/server.ts | 68 +++++++++++++++++++----------------- tools/relay/start.sh | 14 ++++---- 5 files changed, 152 insertions(+), 41 deletions(-) create mode 100644 tools/relay/call.py create mode 100644 tools/relay/call.ts diff --git a/tools/relay/call.py b/tools/relay/call.py new file mode 100644 index 0000000..40b3179 --- /dev/null +++ b/tools/relay/call.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +CLI shim: call a relay MCP tool via raw SSE protocol. +Usage: + python3 call.py read_messages '{"for":"pm"}' + python3 call.py post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}' + python3 call.py list_pending '{"for":"pm"}' +""" +import sys, json, threading, requests, time + +BASE = "http://localhost:7331" +tool_name = sys.argv[1] +args = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {} + +result = {"done": False, "value": None} +endpoint_url = {"url": None} +msg_id = 1 + +def read_sse(): + with requests.get(f"{BASE}/sse", stream=True, timeout=15) as r: + for line in r.iter_lines(decode_unicode=True): + if not line: + continue + if line.startswith("data:"): + data = line[5:].strip() + if not endpoint_url["url"] and data.startswith("/message"): + endpoint_url["url"] = BASE + data + elif endpoint_url["url"]: + try: + payload = json.loads(data) + if payload.get("id") == msg_id: + result["value"] = payload + result["done"] = True + return + except json.JSONDecodeError: + pass + +t = threading.Thread(target=read_sse, daemon=True) +t.start() + +# Wait for endpoint +for _ in range(50): + if endpoint_url["url"]: + break + time.sleep(0.1) +if not endpoint_url["url"]: + print(json.dumps({"error": "no endpoint received"})) + sys.exit(1) + +# Send initialize +init_payload = { + "jsonrpc": "2.0", "id": 0, "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "relay-cli", "version": "0.1.0"} + } +} +requests.post(endpoint_url["url"], json=init_payload, timeout=5) + +# Send tools/call +call_payload = { + "jsonrpc": "2.0", "id": msg_id, "method": "tools/call", + "params": {"name": tool_name, "arguments": args} +} +requests.post(endpoint_url["url"], json=call_payload, timeout=5) + +# Wait for result +for _ in range(100): + if result["done"]: + break + time.sleep(0.1) + +if result["done"]: + content = result["value"].get("result", {}).get("content", []) + for item in content: + if item.get("type") == "text": + print(item["text"]) +else: + print(json.dumps({"error": "timeout waiting for response"})) + sys.exit(1) diff --git a/tools/relay/call.ts b/tools/relay/call.ts new file mode 100644 index 0000000..d1901a2 --- /dev/null +++ b/tools/relay/call.ts @@ -0,0 +1,25 @@ +/** + * CLI shim: call a relay MCP tool from bash. + * Usage: + * npx tsx call.ts read_messages '{"for":"pm"}' + * npx tsx call.ts post_message '{"from":"pm","to":"dev-a","kind":"directive","body":"..."}' + * npx tsx call.ts list_pending '{"for":"pm"}' + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; + +const [, , toolName, argsJson] = process.argv; +if (!toolName) { + console.error("Usage: call.ts "); + process.exit(1); +} + +const args = argsJson ? JSON.parse(argsJson) : {}; +const url = new URL("http://localhost:7331/sse"); +const transport = new SSEClientTransport(url); +const client = new Client({ name: "relay-cli", version: "0.1.0" }, { capabilities: {} }); + +await client.connect(transport); +const result = await client.callTool({ name: toolName, arguments: args }); +console.log(JSON.stringify(result)); +await client.close(); diff --git a/tools/relay/queue.ts b/tools/relay/queue.ts index da7fede..0a38824 100644 --- a/tools/relay/queue.ts +++ b/tools/relay/queue.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; -export type Role = "pm" | "dev-a" | "dev-b"; +export type Role = "pm" | "dev-a" | "dev-b" | "dev-c"; export type MessageKind = "status" | "question" | "directive" | "free"; export interface RelayMessage { @@ -12,7 +12,7 @@ export interface RelayMessage { ts: string; } -const KNOWN_ROLES = new Set(["pm", "dev-a", "dev-b"]); +const KNOWN_ROLES = new Set(["pm", "dev-a", "dev-b", "dev-c"]); export function isRole(s: string): s is Role { return KNOWN_ROLES.has(s); @@ -23,6 +23,7 @@ export class RelayQueue { ["pm", []], ["dev-a", []], ["dev-b", []], + ["dev-c", []], ]); post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage { diff --git a/tools/relay/server.ts b/tools/relay/server.ts index 008f38e..ed963f2 100644 --- a/tools/relay/server.ts +++ b/tools/relay/server.ts @@ -10,11 +10,6 @@ import { RelayQueue, isRole } from "./queue.ts"; const PORT = 7331; const queue = new RelayQueue(); -const mcpServer = new Server( - { name: "relay", version: "0.1.0" }, - { capabilities: { tools: {} } } -); - const TOOLS = [ { name: "post_message", @@ -25,12 +20,12 @@ const TOOLS = [ properties: { from: { type: "string", - enum: ["pm", "dev-a", "dev-b"], + enum: ["pm", "dev-a", "dev-b", "dev-c"], description: "Your role name", }, to: { type: "string", - enum: ["pm", "dev-a", "dev-b"], + enum: ["pm", "dev-a", "dev-b", "dev-c"], description: "Recipient role name", }, kind: { @@ -55,7 +50,7 @@ const TOOLS = [ properties: { for: { type: "string", - enum: ["pm", "dev-a", "dev-b"], + enum: ["pm", "dev-a", "dev-b", "dev-c"], description: "Your role name", }, }, @@ -71,7 +66,7 @@ const TOOLS = [ properties: { for: { type: "string", - enum: ["pm", "dev-a", "dev-b"], + enum: ["pm", "dev-a", "dev-b", "dev-c"], description: "Your role name", }, }, @@ -80,41 +75,36 @@ const TOOLS = [ }, ]; -mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); - -mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const a = args as Record; - +function handleToolCall(name: string, args: Record) { if (name === "post_message") { - if (!isRole(a.from)) { - return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true }; + if (!isRole(args.from)) { + return { content: [{ type: "text" as const, text: `Error: unknown role "${args.from}"` }], isError: true }; } - if (!isRole(a.to)) { - return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true }; + if (!isRole(args.to)) { + return { content: [{ type: "text" as const, text: `Error: unknown role "${args.to}"` }], isError: true }; } - const kind = a.kind as "status" | "question" | "directive" | "free"; - const msg = queue.post(a.from, a.to, kind, a.body); + const kind = args.kind as "status" | "question" | "directive" | "free"; + const msg = queue.post(args.from, args.to, kind, args.body); const ts = new Date(msg.ts).toTimeString().slice(0, 8); - const preview = a.body.slice(0, 60).replace(/\n/g, " "); - const ellipsis = a.body.length > 60 ? "..." : ""; - process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`); + const preview = args.body.slice(0, 60).replace(/\n/g, " "); + const ellipsis = args.body.length > 60 ? "..." : ""; + process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`); return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] }; } if (name === "read_messages") { - if (!isRole(a.for)) { - return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true }; + if (!isRole(args.for)) { + return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true }; } - const messages = queue.read(a.for); + const messages = queue.read(args.for); return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] }; } if (name === "list_pending") { - if (!isRole(a.for)) { - return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true }; + if (!isRole(args.for)) { + return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true }; } - const result = queue.pending(a.for); + const result = queue.pending(args.for); return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; } @@ -122,7 +112,20 @@ mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }], isError: true, }; -}); +} + +function makeServer() { + const srv = new Server( + { name: "relay", version: "0.1.0" }, + { capabilities: { tools: {} } } + ); + srv.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS })); + srv.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + return handleToolCall(name, args as Record); + }); + return srv; +} const transports = new Map(); @@ -132,7 +135,8 @@ const httpServer = http.createServer(async (req, res) => { const transport = new SSEServerTransport("/message", res); transports.set(transport.sessionId, transport); transport.onclose = () => transports.delete(transport.sessionId); - await mcpServer.connect(transport); + // Each connection gets its own Server instance so multiple clients can coexist. + await makeServer().connect(transport); } else if (req.method === "POST" && req.url?.startsWith("/message")) { const url = new URL(req.url, `http://127.0.0.1:${PORT}`); const sessionId = url.searchParams.get("sessionId") ?? ""; diff --git a/tools/relay/start.sh b/tools/relay/start.sh index 44dbadd..2682ab2 100755 --- a/tools/relay/start.sh +++ b/tools/relay/start.sh @@ -69,14 +69,14 @@ launch_tmux() { } launch_kitty() { - kitty @ launch --new-tab --tab-title "relay" -- \ + kitty @ launch --type=tab --tab-title "relay" -- \ bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts" - kitty @ launch --new-window --window-title "PM" -- \ - bash -c "cd '$REPO_ROOT' && claude" - kitty @ launch --new-window --window-title "Dev-A" -- \ - bash -c "cd '$REPO_ROOT' && claude" - kitty @ launch --new-window --window-title "Dev-B" -- \ - bash -c "cd '$REPO_ROOT' && claude" + kitty @ launch --type=tab --tab-title "PM" --hold -- \ + bash -l -i -c "cd '$REPO_ROOT' && claude" + kitty @ launch --type=tab --tab-title "Dev-A" --hold -- \ + bash -l -i -c "cd '$REPO_ROOT' && claude" + kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \ + bash -l -i -c "cd '$REPO_ROOT' && claude" echo "" echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)." echo " Paste the kickoff prompts into each Claude window."