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:
81
tools/relay/call.py
Normal file
81
tools/relay/call.py
Normal 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
25
tools/relay/call.ts
Normal 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();
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") ?? "";
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user