Rework core integration: validated capability layer, docs, and session state
This commit is contained in:
+112
-38
@@ -1,14 +1,13 @@
|
||||
/*
|
||||
* Veskslop Phase 0 spike — MCP control bridge.
|
||||
* Veskslop daemon — MCP control bridge over Chrome DevTools Protocol.
|
||||
*
|
||||
* 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.
|
||||
* Talks to an unmodified Vesktop through --remote-debugging-port, evaluates
|
||||
* actions against the real client, receives events through a CDP binding, and
|
||||
* runs the trigger/whitelist/log logic locally.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm control:bridge
|
||||
* VESKSLOP_SOCKET=/tmp/veskslop-mock.sock MCP_PORT=8788 pnpm control:bridge
|
||||
* VESKSLOP_TOKEN_FILE=... pnpm control:bridge
|
||||
* CDP_URL=http://127.0.0.1:9222 MCP_PORT=8788 pnpm control:bridge
|
||||
*/
|
||||
|
||||
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
|
||||
@@ -16,18 +15,54 @@ 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";
|
||||
import { CdpClient } from "./cdp-client.js";
|
||||
import { Discord } from "./discord.js";
|
||||
import { dedupe, getQueryCount, paced, sleep } from "./pacing.js";
|
||||
import { Puppet } from "./puppet.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) }] };
|
||||
let discord: Discord | undefined;
|
||||
let puppet: Puppet | undefined;
|
||||
let cdpConnected = false;
|
||||
|
||||
async function connectLoop() {
|
||||
for (;;) {
|
||||
try {
|
||||
const cdp = await CdpClient.connect();
|
||||
|
||||
discord = new Discord(cdp);
|
||||
puppet = new Puppet(discord);
|
||||
|
||||
await discord.installEventBridge();
|
||||
cdp.onEvent(event => {
|
||||
puppet!.handleEvent(event).catch(error => console.error("[daemon] event error:", error));
|
||||
});
|
||||
|
||||
cdpConnected = true;
|
||||
console.log("[daemon] connected to Vesktop via CDP");
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn(`[daemon] waiting for Vesktop CDP: ${(error as Error).message}`);
|
||||
await sleep(2000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function command<T>(name: string, data?: unknown): Promise<T> {
|
||||
return paced(() => sendVesktopCommand<T>(name, data));
|
||||
function requireDiscord(): Discord {
|
||||
if (!cdpConnected || !discord) throw new Error("Not connected to Vesktop (run it with --remote-debugging-port)");
|
||||
return discord;
|
||||
}
|
||||
|
||||
function requirePuppet(): Puppet {
|
||||
if (!puppet) throw new Error("Puppet not connected yet");
|
||||
return puppet;
|
||||
}
|
||||
|
||||
function jsonText(value: unknown) {
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
||||
}
|
||||
|
||||
function buildServer(): McpServer {
|
||||
@@ -38,7 +73,7 @@ function buildServer(): McpServer {
|
||||
{
|
||||
description: "Get the logged-in user, guilds and channels. Cache-only, no Discord queries."
|
||||
},
|
||||
async () => jsonText(await command("state:get"))
|
||||
async () => jsonText(await requireDiscord().getState())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
@@ -51,25 +86,8 @@ function buildServer(): McpServer {
|
||||
}
|
||||
},
|
||||
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() });
|
||||
await paced(() => requireDiscord().sendMessage(channelId, content));
|
||||
return jsonText({ channelId, sent: true, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
|
||||
@@ -83,14 +101,71 @@ function buildServer(): McpServer {
|
||||
}
|
||||
},
|
||||
async ({ userId, content }) => {
|
||||
const result = await command<Record<string, unknown>>("dm:send", { userId, content });
|
||||
return jsonText({ ...result, queriesUsed: getQueryCount() });
|
||||
const result = await paced(() => requireDiscord().sendDm(userId, content));
|
||||
return jsonText({ ...result, userId, 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<{ cached: number; messages: unknown[] }>(`read:${channelId}:${limit}`, () =>
|
||||
paced(() => requireDiscord().readMessages(channelId, limit))
|
||||
);
|
||||
return jsonText({ ...result, channelId, queriesUsed: getQueryCount() });
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_message_log",
|
||||
{
|
||||
description: "Read the trigger log: last 100 messages from trusted users, last 30 from untrusted."
|
||||
},
|
||||
async () => jsonText(requirePuppet().getLog())
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"whitelist_list",
|
||||
{
|
||||
description: "List the puppet owner and trusted (whitelisted) user IDs."
|
||||
},
|
||||
async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"whitelist_add",
|
||||
{
|
||||
description: "Add users to the puppet whitelist.",
|
||||
inputSchema: {
|
||||
userIds: z.array(z.string()).min(1).describe("User IDs to trust")
|
||||
}
|
||||
},
|
||||
async ({ userIds }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds) })
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"whitelist_remove",
|
||||
{
|
||||
description: "Remove users from the puppet whitelist. The owner cannot be removed.",
|
||||
inputSchema: {
|
||||
userIds: z.array(z.string()).min(1).describe("User IDs to untrust")
|
||||
}
|
||||
},
|
||||
async ({ userIds }) => jsonText({ whitelist: requirePuppet().removeFromWhitelist(userIds) })
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
void connectLoop();
|
||||
|
||||
const app = createMcpExpressApp({ host: HOST });
|
||||
|
||||
app.post("/mcp", async (req, res) => {
|
||||
@@ -128,7 +203,7 @@ app.delete("/mcp", (_req, res) => {
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ ok: true, socketPath, queries: getQueryCount() });
|
||||
res.json({ ok: true, cdpConnected, ownerId: puppet?.owner, queries: getQueryCount() });
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, (error?: Error) => {
|
||||
@@ -136,8 +211,7 @@ app.listen(PORT, HOST, (error?: 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}`);
|
||||
console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`);
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => process.exit(0));
|
||||
|
||||
Reference in New Issue
Block a user