Rework core integration: validated capability layer, docs, and session state

This commit is contained in:
Fisto
2026-09-11 14:09:04 +02:00
parent 2a15f057df
commit e62d3007d8
33 changed files with 1897 additions and 612 deletions
+112 -38
View File
@@ -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));