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";
|
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 type MessageKind = "status" | "question" | "directive" | "free";
|
||||||
|
|
||||||
export interface RelayMessage {
|
export interface RelayMessage {
|
||||||
@@ -12,7 +12,7 @@ export interface RelayMessage {
|
|||||||
ts: string;
|
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 {
|
export function isRole(s: string): s is Role {
|
||||||
return KNOWN_ROLES.has(s);
|
return KNOWN_ROLES.has(s);
|
||||||
@@ -23,6 +23,7 @@ export class RelayQueue {
|
|||||||
["pm", []],
|
["pm", []],
|
||||||
["dev-a", []],
|
["dev-a", []],
|
||||||
["dev-b", []],
|
["dev-b", []],
|
||||||
|
["dev-c", []],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||||
|
|||||||
@@ -10,11 +10,6 @@ import { RelayQueue, isRole } from "./queue.ts";
|
|||||||
const PORT = 7331;
|
const PORT = 7331;
|
||||||
const queue = new RelayQueue();
|
const queue = new RelayQueue();
|
||||||
|
|
||||||
const mcpServer = new Server(
|
|
||||||
{ name: "relay", version: "0.1.0" },
|
|
||||||
{ capabilities: { tools: {} } }
|
|
||||||
);
|
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{
|
{
|
||||||
name: "post_message",
|
name: "post_message",
|
||||||
@@ -25,12 +20,12 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
from: {
|
from: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||||
description: "Your role name",
|
description: "Your role name",
|
||||||
},
|
},
|
||||||
to: {
|
to: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||||
description: "Recipient role name",
|
description: "Recipient role name",
|
||||||
},
|
},
|
||||||
kind: {
|
kind: {
|
||||||
@@ -55,7 +50,7 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
for: {
|
for: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||||
description: "Your role name",
|
description: "Your role name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,7 +66,7 @@ const TOOLS = [
|
|||||||
properties: {
|
properties: {
|
||||||
for: {
|
for: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["pm", "dev-a", "dev-b"],
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
||||||
description: "Your role name",
|
description: "Your role name",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -80,41 +75,36 @@ const TOOLS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
function handleToolCall(name: string, args: Record<string, string>) {
|
||||||
|
|
||||||
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
||||||
const { name, arguments: args } = request.params;
|
|
||||||
const a = args as Record<string, string>;
|
|
||||||
|
|
||||||
if (name === "post_message") {
|
if (name === "post_message") {
|
||||||
if (!isRole(a.from)) {
|
if (!isRole(args.from)) {
|
||||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.from}"` }], isError: true };
|
||||||
}
|
}
|
||||||
if (!isRole(a.to)) {
|
if (!isRole(args.to)) {
|
||||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.to}"` }], isError: true };
|
||||||
}
|
}
|
||||||
const kind = a.kind as "status" | "question" | "directive" | "free";
|
const kind = args.kind as "status" | "question" | "directive" | "free";
|
||||||
const msg = queue.post(a.from, a.to, kind, a.body);
|
const msg = queue.post(args.from, args.to, kind, args.body);
|
||||||
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||||
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
|
const preview = args.body.slice(0, 60).replace(/\n/g, " ");
|
||||||
const ellipsis = a.body.length > 60 ? "..." : "";
|
const ellipsis = args.body.length > 60 ? "..." : "";
|
||||||
process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`);
|
process.stdout.write(`[${ts}] ${args.from} → ${args.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||||
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === "read_messages") {
|
if (name === "read_messages") {
|
||||||
if (!isRole(a.for)) {
|
if (!isRole(args.for)) {
|
||||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
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) }] };
|
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name === "list_pending") {
|
if (name === "list_pending") {
|
||||||
if (!isRole(a.for)) {
|
if (!isRole(args.for)) {
|
||||||
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
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) }] };
|
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}"` }],
|
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||||
isError: true,
|
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>();
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
@@ -132,7 +135,8 @@ const httpServer = http.createServer(async (req, res) => {
|
|||||||
const transport = new SSEServerTransport("/message", res);
|
const transport = new SSEServerTransport("/message", res);
|
||||||
transports.set(transport.sessionId, transport);
|
transports.set(transport.sessionId, transport);
|
||||||
transport.onclose = () => transports.delete(transport.sessionId);
|
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")) {
|
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||||
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
const sessionId = url.searchParams.get("sessionId") ?? "";
|
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||||
|
|||||||
@@ -69,14 +69,14 @@ launch_tmux() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
launch_kitty() {
|
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"
|
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
kitty @ launch --new-window --window-title "PM" -- \
|
kitty @ launch --type=tab --tab-title "PM" --hold -- \
|
||||||
bash -c "cd '$REPO_ROOT' && claude"
|
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||||
kitty @ launch --new-window --window-title "Dev-A" -- \
|
kitty @ launch --type=tab --tab-title "Dev-A" --hold -- \
|
||||||
bash -c "cd '$REPO_ROOT' && claude"
|
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||||
kitty @ launch --new-window --window-title "Dev-B" -- \
|
kitty @ launch --type=tab --tab-title "Dev-B" --hold -- \
|
||||||
bash -c "cd '$REPO_ROOT' && claude"
|
bash -l -i -c "cd '$REPO_ROOT' && claude"
|
||||||
echo ""
|
echo ""
|
||||||
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||||
echo " Paste the kickoff prompts into each Claude window."
|
echo " Paste the kickoff prompts into each Claude window."
|
||||||
|
|||||||
Reference in New Issue
Block a user