Add headless puppet bridge: control socket, renderer handlers, MCP daemon
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Veskslop Phase 0 spike — MCP control bridge.
|
||||
*
|
||||
* Exposes MCP over Streamable HTTP and forwards tool calls to the Vesktop
|
||||
* control socket, where the real client performs them. Every command is paced
|
||||
* by the shared queue (300-800 ms), reads are coalesced, and state reads are
|
||||
* cache-only.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm control:bridge
|
||||
* VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge
|
||||
*/
|
||||
|
||||
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
import { dedupe, getQueryCount, paced } from "./pacing.js";
|
||||
import { sendVesktopCommand, socketPath } from "./vesktop-socket.js";
|
||||
|
||||
const HOST = process.env.MCP_HOST ?? "127.0.0.1";
|
||||
const PORT = Number(process.env.MCP_PORT ?? 8788);
|
||||
|
||||
function jsonText(value: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
||||
}
|
||||
|
||||
function command<T>(name: string, data?: unknown): Promise<T> {
|
||||
return paced(() => sendVesktopCommand<T>(name, data));
|
||||
}
|
||||
|
||||
function buildServer(): McpServer {
|
||||
const server = new McpServer({ name: "veskslop-control", version: "0.0.0" });
|
||||
|
||||
server.registerTool(
|
||||
"get_state",
|
||||
{
|
||||
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
|
||||
},
|
||||
async () => jsonText(await command("state:get"))
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"send_message",
|
||||
{
|
||||
description: "Send a message as the logged-in user through the real client. Query-paced.",
|
||||
inputSchema: {
|
||||
channelId: z.string().describe("Target channel ID"),
|
||||
content: z.string().min(1).describe("Message content")
|
||||
}
|
||||
},
|
||||
async ({ channelId, content }) => {
|
||||
const result = await command<Record<string, unknown>>("message:send", { channelId, content });
|
||||
return jsonText({ ...result, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"read_messages",
|
||||
{
|
||||
description: "Read messages from the client's cache. Identical concurrent reads are coalesced.",
|
||||
inputSchema: {
|
||||
channelId: z.string().describe("Target channel ID"),
|
||||
limit: z.number().int().min(1).max(100).default(20).describe("How many messages")
|
||||
}
|
||||
},
|
||||
async ({ channelId, limit }) => {
|
||||
const result = await dedupe(`read:${channelId}:${limit}`, () =>
|
||||
command<{ messages: unknown[]; cached: number; channelId: string }>("message:read", { channelId, limit })
|
||||
);
|
||||
return jsonText({ ...result, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"send_dm",
|
||||
{
|
||||
description: "Send a DM as the logged-in user. Sanctioned live-test target: user zzib.",
|
||||
inputSchema: {
|
||||
userId: z.string().describe("Recipient user ID"),
|
||||
content: z.string().min(1).describe("Message content")
|
||||
}
|
||||
},
|
||||
async ({ userId, content }) => {
|
||||
const result = await command<Record<string, unknown>>("dm:send", { userId, content });
|
||||
return jsonText({ ...result, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
const app = createMcpExpressApp({ host: HOST });
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
const server = buildServer();
|
||||
try {
|
||||
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
res.on("close", () => {
|
||||
transport.close();
|
||||
server.close();
|
||||
});
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
} catch (error) {
|
||||
console.error("Error handling MCP request:", error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal server error" },
|
||||
id: null
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/mcp", (_req, res) => {
|
||||
res.writeHead(405).end(
|
||||
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
|
||||
);
|
||||
});
|
||||
|
||||
app.delete("/mcp", (_req, res) => {
|
||||
res.writeHead(405).end(
|
||||
JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Method not allowed." }, id: null })
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true, socketPath, queries: getQueryCount() });
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, (error?: Error) => {
|
||||
if (error) {
|
||||
console.error("Failed to start control bridge:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[control] MCP listening on http://${HOST}:${PORT}/mcp`);
|
||||
console.log(`[control] Vesktop socket: ${socketPath}`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
Reference in New Issue
Block a user