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 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-05 17:49:21 -04:00
parent 29146439bb
commit dd0010db62
5 changed files with 152 additions and 41 deletions

81
tools/relay/call.py Normal file
View File

@@ -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)

25
tools/relay/call.ts Normal file
View File

@@ -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 <tool_name> <args_json>");
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();

View File

@@ -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<string>(["pm", "dev-a", "dev-b"]);
const KNOWN_ROLES = new Set<string>(["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 {

View File

@@ -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<string, string>;
function handleToolCall(name: string, args: Record<string, string>) {
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<string, string>);
});
return srv;
}
const transports = new Map<string, SSEServerTransport>();
@@ -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") ?? "";

View File

@@ -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."