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>
164 lines
5.5 KiB
TypeScript
164 lines
5.5 KiB
TypeScript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
import {
|
|
CallToolRequestSchema,
|
|
ListToolsRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import http from "node:http";
|
|
import { RelayQueue, isRole } from "./queue.ts";
|
|
|
|
const PORT = 7331;
|
|
const queue = new RelayQueue();
|
|
|
|
const TOOLS = [
|
|
{
|
|
name: "post_message",
|
|
description:
|
|
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
from: {
|
|
type: "string",
|
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
|
description: "Your role name",
|
|
},
|
|
to: {
|
|
type: "string",
|
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
|
description: "Recipient role name",
|
|
},
|
|
kind: {
|
|
type: "string",
|
|
enum: ["status", "question", "directive", "free"],
|
|
description: "Message type matching the coordination protocol",
|
|
},
|
|
body: {
|
|
type: "string",
|
|
description: "Message body — freeform markdown, typically the full formatted block",
|
|
},
|
|
},
|
|
required: ["from", "to", "kind", "body"],
|
|
},
|
|
},
|
|
{
|
|
name: "read_messages",
|
|
description:
|
|
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
for: {
|
|
type: "string",
|
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
|
description: "Your role name",
|
|
},
|
|
},
|
|
required: ["for"],
|
|
},
|
|
},
|
|
{
|
|
name: "list_pending",
|
|
description:
|
|
"Return count and kinds of pending messages without consuming them.",
|
|
inputSchema: {
|
|
type: "object" as const,
|
|
properties: {
|
|
for: {
|
|
type: "string",
|
|
enum: ["pm", "dev-a", "dev-b", "dev-c"],
|
|
description: "Your role name",
|
|
},
|
|
},
|
|
required: ["for"],
|
|
},
|
|
},
|
|
];
|
|
|
|
function handleToolCall(name: string, args: Record<string, string>) {
|
|
if (name === "post_message") {
|
|
if (!isRole(args.from)) {
|
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.from}"` }], isError: true };
|
|
}
|
|
if (!isRole(args.to)) {
|
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.to}"` }], isError: true };
|
|
}
|
|
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 = 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(args.for)) {
|
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true };
|
|
}
|
|
const messages = queue.read(args.for);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
|
}
|
|
|
|
if (name === "list_pending") {
|
|
if (!isRole(args.for)) {
|
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${args.for}"` }], isError: true };
|
|
}
|
|
const result = queue.pending(args.for);
|
|
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
}
|
|
|
|
return {
|
|
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>();
|
|
|
|
const httpServer = http.createServer(async (req, res) => {
|
|
try {
|
|
if (req.method === "GET" && req.url === "/sse") {
|
|
const transport = new SSEServerTransport("/message", res);
|
|
transports.set(transport.sessionId, transport);
|
|
transport.onclose = () => transports.delete(transport.sessionId);
|
|
// 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") ?? "";
|
|
const transport = transports.get(sessionId);
|
|
if (transport) {
|
|
await transport.handlePostMessage(req, res);
|
|
} else {
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ error: "session not found" }));
|
|
}
|
|
} else {
|
|
res.writeHead(404).end("not found");
|
|
}
|
|
} catch (err) {
|
|
console.error("[relay] error:", err);
|
|
if (!res.headersSent) res.writeHead(500).end(String(err));
|
|
}
|
|
});
|
|
|
|
httpServer.listen(PORT, "127.0.0.1", () => {
|
|
console.log(`[relay] server ready on :${PORT}`);
|
|
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
|
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
|
});
|