/* * Veskslop daemon — MCP control bridge over the validated core bridge. * * The daemon holds all logic. It sends validated actions to Vesktop and * receives normalized events. See docs/core-bridge.md. * * Usage: * pnpm control:bridge * VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge */ import { spawn } from "node:child_process"; import { closeSync, openSync } from "node:fs"; import { config as loadEnv } from "dotenv"; 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 { AuthService } from "./auth-service.js"; import { AudioService } from "./audio-service.js"; import { tokenFile } from "./config.js"; import { dirname, join } from "node:path"; import { Stt } from "./stt.js"; import { TranscriptStore } from "./transcript.js"; import { DEFAULT_VAD } from "./vad.js"; import { CoreClient, coreSocketPath } from "./core-client.js"; import { CoreProcess, staleBundleWarning } from "./core-process.js"; import { dedupe, getQueryCount, paced, pacedSend, pacedVoice, sleep } from "./pacing.js"; import { MediaService } from "./media.js"; import { PreferencesStore } from "./preferences.js"; import { Puppet } from "./puppet.js"; import { startWebUi } from "./webui.js"; loadEnv({ override: true }); const HOST = process.env.MCP_HOST ?? "127.0.0.1"; const PORT = Number(process.env.MCP_PORT ?? 8788); let core: CoreClient | undefined; let puppet: Puppet | undefined; let coreConnected = false; let connecting = false; const media = new MediaService({ getCore: () => core, isCoreConnected: () => coreConnected, getMediaPrefs: () => preferences.get().media, log: line => console.log(`[media] ${line}`) }); async function connectLoop(): Promise { if (connecting) return; connecting = true; try { for (;;) { try { const client = await CoreClient.connect(); core = client; puppet = new Puppet(client, audio, media); client.onEvent(event => { // Speaking hints add to the reconciled set so the poller can // clear users the store no longer reports as speaking. if (event?.type === "VOICE_SPEAKING" && event.userId) { if (event.speaking) speakingUsers.add(String(event.userId)); else speakingUsers.delete(String(event.userId)); } void puppet!.enqueueEvent(event); void audio.onEvent(event); webui.broadcast(event); }); client.onClose(() => { console.warn("[daemon] core bridge disconnected"); stopSpeakingPoll(); coreConnected = false; core = undefined; puppet = undefined; webui.broadcast({ type: "CORE_STATUS", coreConnected: false }); void connectLoop(); }); coreConnected = true; console.log(`[daemon] connected to the core bridge at ${coreSocketPath}`); webui.broadcast({ type: "CORE_STATUS", coreConnected: true }); startSpeakingPoll(); void autoConnectAudioWithRetry(client); return; } catch (error) { console.warn(`[daemon] waiting for the core bridge: ${(error as Error).message}`); await sleep(2000); } } } finally { connecting = false; } } function requireCore(): CoreClient { if (!coreConnected || !core) throw new Error("Not connected to Vesktop (is the core bridge running?)"); return core; } function requirePuppet(): Puppet { if (!puppet) throw new Error("Puppet not connected yet"); return puppet; } /* * Discord's dispatcher only sends speaking-start events, so the UI and the * transcript attribution need the store's authoritative set. Poll it once a * second and synthesize the missing stop/start events. Dispatcher events feed * the same set so a user the store no longer reports is cleared. */ let speakingTimer: ReturnType | undefined; const speakingUsers = new Set(); function emitCoreEvent(event: any): void { void puppet?.enqueueEvent(event); void audio.onEvent(event); webui.broadcast(event); } async function pollSpeaking(): Promise { if (!core) return; try { const result = (await core.voiceSpeaking()) as { channelId?: string | null; userIds?: string[] }; const next = new Set(result?.channelId ? (result.userIds ?? []) : []); for (const userId of next) { if (!speakingUsers.has(userId)) { emitCoreEvent({ type: "VOICE_SPEAKING", userId, speaking: true, channelId: result.channelId ?? null }); } } for (const userId of speakingUsers) { if (!next.has(userId)) { emitCoreEvent({ type: "VOICE_SPEAKING", userId, speaking: false, channelId: result?.channelId ?? null }); } } speakingUsers.clear(); for (const userId of next) speakingUsers.add(userId); } catch { // The close handler stops the poller. } } function startSpeakingPoll(): void { stopSpeakingPoll(); speakingTimer = setInterval(() => void pollSpeaking(), 1000); } function stopSpeakingPoll(): void { if (speakingTimer) clearInterval(speakingTimer); speakingTimer = undefined; speakingUsers.clear(); } const coreProcess = new CoreProcess({ tokenFile }); const transcripts = new TranscriptStore( process.env.VESKSLOP_TRANSCRIPT ?? join(dirname(tokenFile), "transcripts.jsonl") ); const preferences = new PreferencesStore( process.env.VESKSLOP_PREFERENCES ?? join(dirname(tokenFile), "preferences.json"), { legacyFile: process.env.VESKSLOP_STATE_FILE ?? join(dirname(tokenFile), ".veskslop-state.json") } ); const stt = new Stt(); const audio = new AudioService({ cacheDir: process.env.VESKSLOP_CACHE_DIR, transcripts, stt, autoCapture: process.env.VESKSLOP_CAPTURE_AUTO === "1", vad: { ...DEFAULT_VAD, thresholdDb: Number(process.env.VESKSLOP_VAD_THRESHOLD_DB ?? DEFAULT_VAD.thresholdDb), minSpeechMs: Number(process.env.VESKSLOP_VAD_MIN_SPEECH_MS ?? DEFAULT_VAD.minSpeechMs), silenceMs: Number(process.env.VESKSLOP_VAD_SILENCE_MS ?? DEFAULT_VAD.silenceMs), preRollMs: Number(process.env.VESKSLOP_VAD_PRE_ROLL_MS ?? DEFAULT_VAD.preRollMs), maxSegmentMs: Number(process.env.VESKSLOP_VAD_MAX_SEGMENT_MS ?? DEFAULT_VAD.maxSegmentMs) }, getSelfId: async () => (core ? await core.getSelfId() : undefined), getVoiceContext: async () => { if (!core) return {}; const state = (await core.voiceGet()) as { guildId?: string | null; channelId?: string | null }; return { guildId: state?.guildId ?? null, channelId: state?.channelId ?? null }; }, getSpeakerName: async userId => { if (!core) return undefined; const state = (await core.voiceGet(userId)) as { members?: Array<{ id: string; username?: string }>; }; return state?.members?.find(member => member.id === userId)?.username; }, onTranscript: event => webui.broadcast(event) }); audio.setDuckEnabled(preferences.get().audio?.duck !== false); void audio.init(); /** Point the client at the virtual microphone and capture sink when present. */ async function autoConnectAudio(client: CoreClient): Promise<{ connected: boolean; message: string }> { if (process.env.VESKSLOP_AUDIO_AUTOCONNECT === "0") { return { connected: false, message: "auto-connect is disabled" }; } const status = audio.status(); if (!status.available) { return { connected: false, message: status.hint ?? status.error ?? "audio is not available" }; } try { await audio.manager.ensureDevices(); const devices = (await client.voiceDevices()) as { input?: Array<{ id: string; label: string }>; output?: Array<{ id: string; label: string }>; }; const input = devices.input?.find(device => device.label.includes("VeskslopMic")); const output = devices.output?.find(device => device.label.includes("VeskslopCapture")); if (!input && !output) { return { connected: false, message: "the client does not see the virtual devices yet" }; } const result = await client.voiceSetDevices(input?.id, output?.id); console.log("[audio] client devices set:", JSON.stringify(result)); return { connected: true, message: "the client now uses the virtual devices" }; } catch (error) { return { connected: false, message: (error as Error).message }; } } /** * The bridge socket is up before the renderer finishes loading, so the first * device calls can fail with "core is not ready" style errors. Retry. */ async function autoConnectAudioWithRetry(client: CoreClient): Promise { for (let attempt = 1; attempt <= 10; attempt++) { if (attempt > 1) await sleep(2500); const result = await autoConnectAudio(client); if (result.connected) return; console.warn(`[audio] device auto-connect attempt ${attempt}: ${result.message}`); } } const auth = new AuthService(tokenFile, update => { webui.broadcast({ type: "AUTH_QR", update }); if (update.state === "finished" && coreProcess.status().running) { setTimeout(() => { void coreProcess.restart(auth.consumeFreshLogin()).catch(error => { console.error("[core] restart after login failed:", error); }); }, 500); } }); const webui = startWebUi({ getCore: () => core, getPuppet: () => puppet, isCoreConnected: () => coreConnected, coreProcess, auth, audio, transcripts, preferences, media, restartDaemon, connectAudioDevices: () => autoConnectAudio(requireCore()) }); coreProcess.onExit(() => { webui.broadcast({ type: "CORE_STATUS", coreConnected, process: coreProcess.status() }); }); function jsonText(value: unknown) { return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] }; } /** Read-only tools that the batch tool can run. Mutating tools stay out. */ const READ_ONLY_TOOLS: Record Promise | unknown> = { get_state: () => requireCore().getState(), get_voice_state: args => requireCore().voiceGet(args?.userId, args?.guildId), get_voice_overview: args => requireCore().voiceOverview(args?.guildId), search_users: args => requireCore().userSearch(args?.query, args?.guildId, args?.limit), list_dms: args => requireCore().dmList(args?.limit), get_audio_status: () => audio.status(), list_audio_devices: () => requireCore().voiceDevices(), get_transcript: args => transcripts.readSegments(args?.limit ?? 100, args?.sessionId), get_context: args => requirePuppet().getContext(args?.channelId, { maxMessages: args?.maxMessages, maxChars: args?.maxChars, refresh: args?.refresh }), get_message_log: () => requirePuppet().getLog(), get_action_log: args => requirePuppet().readActionLog(args?.limit ?? 100), get_puppet_config: () => requirePuppet().getConfig(), whitelist_list: () => ({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }), read_messages: args => paced(() => requireCore().readMessages(args?.channelId, args?.limit ?? 20)), list_media_scrapes: () => media.listScrapes(), get_media_storage: () => media.refreshRetention(), query_media: args => media.queryMedia({ kind: args?.kind, scrapeId: args?.scrapeId, limit: args?.limit, offset: args?.offset }) }; 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 requireCore().getState()) ); 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 }) => { await pacedSend(() => requireCore().sendMessage(channelId, content)); return jsonText({ channelId, sent: true, 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 pacedSend(() => requireCore().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(() => requireCore().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( "get_context", { description: "Assemble recent conversation context for a channel. Fetches new messages incrementally by default.", inputSchema: { channelId: z.string().describe("Channel ID"), maxMessages: z.number().int().min(1).max(50).optional().describe("Message limit"), maxChars: z.number().int().min(200).max(20000).optional().describe("Character limit"), refresh: z.boolean().optional().describe("Fetch new messages first. Defaults to true.") } }, async ({ channelId, maxMessages, maxChars, refresh }) => jsonText(await requirePuppet().getContext(channelId, { maxMessages, maxChars, refresh })) ); server.registerTool( "get_puppet_config", { description: "Read the response policy and context settings." }, async () => jsonText(requirePuppet().getConfig()) ); server.registerTool( "set_puppet_config", { description: "Update the response policy and context settings. Sensitive commands reply by DM.", inputSchema: { config: z.object({ contextMessages: z.number().int().min(1).max(50).optional(), contextChars: z.number().int().min(200).max(20000).optional(), fetchCooldownSeconds: z.number().int().min(0).max(300).optional(), continuationSeconds: z.number().int().min(0).max(300).optional(), pollSeconds: z.number().int().min(10).max(3600).optional(), voiceLeaveDelaySeconds: z.number().int().min(0).max(600).optional(), responsePolicy: z .object({ defaultMode: z.enum(["channel", "dm"]).optional(), sensitiveCommands: z.array(z.string()).optional(), overrides: z.record(z.string(), z.enum(["channel", "dm"])).optional() }) .optional() }) } }, async ({ config }) => jsonText(requirePuppet().updateConfig(config)) ); server.registerTool( "get_voice_state", { description: "Which voice channel a user is in and who else is there. Defaults to the logged-in user.", inputSchema: { userId: z.string().optional().describe("User ID. Defaults to the logged-in user."), guildId: z.string().optional().describe("Guild ID. Omit to search all guilds.") } }, async ({ userId, guildId }) => jsonText(await requireCore().voiceGet(userId, guildId)) ); server.registerTool( "get_voice_overview", { description: "List the occupied voice channels in a guild and their members.", inputSchema: { guildId: z.string().describe("Guild ID") } }, async ({ guildId }) => jsonText(await requireCore().voiceOverview(guildId)) ); server.registerTool( "voice_join", { description: "Join a voice channel as the logged-in user. Joins muted by default.", inputSchema: { channelId: z.string().describe("Voice channel ID"), mute: z.boolean().optional().describe("Join muted. Defaults to true.") } }, async ({ channelId, mute }) => jsonText(await pacedVoice(() => requireCore().voiceJoin(channelId, mute ?? true))) ); server.registerTool( "voice_leave", { description: "Leave the current voice channel." }, async () => jsonText(await pacedVoice(() => requireCore().voiceLeave())) ); server.registerTool( "voice_move", { description: "Move the client to another voice channel.", inputSchema: { channelId: z.string().describe("Channel ID") } }, async ({ channelId }) => jsonText(await pacedVoice(() => requireCore().voiceMove(channelId))) ); server.registerTool( "get_user_volume", { description: "Per-user playback volume (0 to 200, 100 is default) and the client's own output volume. A volume of 0 silences that user.", inputSchema: { userIds: z.array(z.string()).min(1).max(50).describe("User IDs to look up") } }, async ({ userIds }) => jsonText(await requireCore().userVolume(userIds)) ); server.registerTool( "set_user_volume", { description: "Set a user's playback volume. 0 silences the user; 100 is default; 200 is the maximum.", inputSchema: { userId: z.string().describe("User ID"), volume: z.number().int().min(0).max(200).describe("Volume from 0 to 200") } }, async ({ userId, volume }) => jsonText(await pacedVoice(() => requireCore().userSetVolume(userId, volume))) ); server.registerTool( "get_audio_status", { description: "Audio stack, virtual devices, TTS providers, queue, ducking, and capture state." }, async () => jsonText(audio.status()) ); server.registerTool( "speak", { description: "Queue text to speech into the bot's virtual microphone.", inputSchema: { text: z.string().min(1).max(1000).describe("Text to speak"), provider: z .enum(["local", "espeak", "command", "api"]) .optional() .describe("TTS engine: command is the configured VESKSLOP_TTS_CMD engine"), voice: z.string().optional().describe("Voice name for the provider"), speed: z.number().min(0.5).max(2).optional().describe("Rate multiplier, 1.0 is natural"), pitch: z.number().min(-12).max(12).optional().describe("Pitch offset in semitones"), expression: z .number() .min(0) .max(100) .optional() .describe("Flextalk Expression profile value (Keith Bell, Kerry Watson)"), profile: z .record(z.string(), z.number()) .optional() .describe("Flextalk profile overrides (pitch, rate, prominences, head_size, lowering)"), highpass: z.number().min(40).max(400).optional().describe("High-pass filter Hz"), echo: z.number().min(0).max(100).optional().describe("Mono echo amount"), words: z.boolean().optional().describe("Synthesize each word and merge with gaps"), wordGapMs: z.number().min(0).max(500).optional().describe("Silence between words in ms") } }, async ({ text, provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) => jsonText( await audio.speak(text, { provider, voice, speed, pitch, expression, profile, highpass, echo, words, wordGapMs }) ) ); server.registerTool( "start_tts_engine", { description: "Start (warm up) the TTS engine so the first spoken line is fast.", inputSchema: { engine: z .enum(["local", "espeak", "command", "api"]) .optional() .describe("Engine to start. Defaults to the configured one.") } }, async ({ engine }) => jsonText(await audio.warmTts(engine)) ); server.registerTool( "stop_audio", { description: "Stop speech and clear the queue." }, async () => { audio.stop(); return jsonText(audio.status()); } ); server.registerTool( "start_capture", { description: "Record the client output sink to a WAV file on the host." }, async () => jsonText(await audio.startCapture()) ); server.registerTool( "stop_capture", { description: "Stop the current capture." }, async () => { audio.stopCapture(); return jsonText(audio.status()); } ); server.registerTool( "list_audio_devices", { description: "List the client's input and output audio devices." }, async () => jsonText(await requireCore().voiceDevices()) ); server.registerTool( "set_audio_devices", { description: "Set the client's input and output audio devices.", inputSchema: { inputDeviceId: z.string().optional().describe("Input device ID"), outputDeviceId: z.string().optional().describe("Output device ID") } }, async ({ inputDeviceId, outputDeviceId }) => jsonText(await requireCore().voiceSetDevices(inputDeviceId, outputDeviceId)) ); server.registerTool( "search_users", { description: "Search users in the client cache by username, global name, guild nickname, or user ID.", inputSchema: { query: z.string().min(1).describe("Username, global name, nickname, or user ID"), guildId: z.string().optional().describe("Limit the search to one guild"), limit: z.number().int().min(1).max(25).default(10).describe("Max results") } }, async ({ query, guildId, limit }) => jsonText(await requireCore().userSearch(query, guildId, limit)) ); server.registerTool( "list_dms", { description: "List the cached DM and group DM channels with their recipients.", inputSchema: { limit: z.number().int().min(1).max(50).default(25).describe("Max channels") } }, async ({ limit }) => jsonText(await requireCore().dmList(limit)) ); server.registerTool( "get_action_log", { description: "Read the persistent action log: triggers, commands, replies, and errors.", inputSchema: { limit: z.number().int().min(1).max(500).default(100).describe("How many entries") } }, async ({ limit }) => jsonText(requirePuppet().readActionLog(limit)) ); server.registerTool( "get_transcript", { description: "Read captured call segments: time, speaker, WAV path, and text when STT is configured.", inputSchema: { limit: z.number().int().min(1).max(500).default(100).describe("How many segments"), sessionId: z.string().optional().describe("Only this capture session") } }, async ({ limit, sessionId }) => jsonText(transcripts.readSegments(limit, sessionId)) ); server.registerTool( "run_commands", { description: "Run several read-only commands in one call, in order.", inputSchema: { commands: z .array( z.object({ tool: z.string().describe("Read-only tool name"), arguments: z.record(z.string(), z.any()).optional() }) ) .min(1) .max(10) } }, async ({ commands }) => { const results: Array> = []; for (const item of commands) { const handler = READ_ONLY_TOOLS[item.tool]; if (!handler) { results.push({ tool: item.tool, ok: false, error: "unknown or non-read-only tool" }); continue; } try { results.push({ tool: item.tool, ok: true, result: await handler((item.arguments ?? {}) as any) }); } catch (error) { results.push({ tool: item.tool, ok: false, error: String((error as Error).message) }); } } return jsonText({ results }); } ); server.registerTool( "whitelist_list", { description: "List the puppet owner and trusted users with their tiers." }, async () => jsonText({ ownerId: requirePuppet().owner, whitelist: requirePuppet().whitelist }) ); server.registerTool( "whitelist_add", { description: "Add users to the puppet whitelist with an optional tier. Defaults to 'user'.", inputSchema: { userIds: z.array(z.string()).min(1).describe("User IDs to trust"), tier: z.enum(["user", "trusted", "admin"]).optional().describe("Trust tier") } }, async ({ userIds, tier }) => jsonText({ whitelist: requirePuppet().addToWhitelist(userIds, tier ?? "user") }) ); 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) }) ); server.registerTool( "scrape_media", { description: "Start a media scrape of a channel. Uses slopbot for guild channels it is in, otherwise the client. Near the storage limit it fails with MEDIA_LIMIT unless confirm is true.", inputSchema: { channelId: z.string().describe("Channel ID to scrape"), guildId: z.string().optional().describe("Guild ID; slopbot needs it to match"), mode: z .enum(["links", "download"]) .optional() .describe("links indexes URLs (default); download fetches attachment bytes"), backend: z .enum(["auto", "slopbot", "puppet"]) .optional() .describe("auto (default) picks slopbot for its guilds, the client otherwise"), confirm: z.boolean().optional().describe("Accept the near-limit warning"), downloadDir: z .string() .optional() .describe("Absolute destination for downloads; previews stay local"), after: z.string().optional().describe("From-date ISO; the walk stops below it"), before: z.string().optional().describe("To-date ISO; the walk starts just below it") } }, async ({ channelId, guildId, mode, backend, confirm, downloadDir, after, before }) => jsonText( await media.startScrape({ channelId, guildId: guildId ?? null, mode, backend, confirm, downloadDir: downloadDir ?? null, after: after ?? null, before: before ?? null }) ) ); server.registerTool( "count_media_messages", { description: "Discord search total for a channel or date range. Guild channels with slopbot only; DMs and group chats are not available.", inputSchema: { channelId: z.string(), guildId: z.string().optional(), after: z.string().optional().describe("From-date ISO"), before: z.string().optional().describe("To-date ISO") } }, async ({ channelId, guildId, after, before }) => jsonText( await media.countMessages({ channelId, guildId: guildId ?? null, after: after ?? null, before: before ?? null }) ) ); server.registerTool( "list_media_scrapes", { description: "List media scrapes with state, pages, messages, and media counts." }, async () => jsonText(media.listScrapes()) ); server.registerTool( "get_media_scrape", { description: "Get one media scrape by ID.", inputSchema: { scrapeId: z.string().describe("Scrape ID") } }, async ({ scrapeId }) => jsonText(media.getScrape(scrapeId)) ); server.registerTool( "resume_media_scrape", { description: "Continue a paused media scrape from its cursor.", inputSchema: { scrapeId: z.string().describe("Scrape ID") } }, async ({ scrapeId }) => jsonText(await media.resumeScrape(scrapeId)) ); server.registerTool( "stop_media_scrape", { description: "Stop a media scrape. It pauses after the current page.", inputSchema: { scrapeId: z.string().describe("Scrape ID") } }, async ({ scrapeId }) => { media.stopScrape(scrapeId); return jsonText({ stopped: scrapeId }); } ); server.registerTool( "get_media_storage", { description: "Media storage usage against the limit, per-kind sizes, and the scrape list. Marks old scrapes when over the limit." }, async () => jsonText(media.refreshRetention()) ); server.registerTool( "query_media", { description: "Query media items across scrapes, newest scrape first.", inputSchema: { kind: z.enum(["image", "gif", "video", "audio"]).optional(), source: z .enum(["attachment", "sticker", "embed"]) .optional() .describe("Where the item came from"), scrapeId: z.string().optional().describe("Restrict to one scrape"), channelId: z.string().optional().describe("Restrict to one source channel"), authorIds: z.array(z.string()).optional().describe("Restrict to these posters"), origin: z.enum(["dm", "guild"]).optional().describe("Server channels or DMs"), cachedOnly: z.boolean().optional().describe("Only items with a cached preview"), exts: z.array(z.string()).optional().describe("Only these file extensions (mp4, webm, png, ...)"), excludeHosts: z.array(z.string()).optional().describe("Hide URLs from these hosts"), aspect: z .enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"]) .optional() .describe("Aspect-ratio bucket"), resolution: z .enum(["sd", "hd", "fhd", "qhd", "uhd", "unknown"]) .optional() .describe("Resolution bucket by shortest side"), after: z.string().optional().describe("Only items posted at or after this ISO time"), before: z.string().optional().describe("Only items posted at or before this ISO time"), limit: z.number().int().min(1).max(500).optional(), offset: z.number().int().min(0).optional() } }, async ({ kind, source, scrapeId, channelId, authorIds, origin, cachedOnly, exts, excludeHosts, aspect, resolution, after, before, limit, offset }) => jsonText( media.queryMedia({ kind, source, scrapeId, channelId, authorIds, origin, cachedOnly, exts, excludeHosts, aspect, resolution, after, before, limit, offset }) ) ); server.registerTool( "get_media_item", { description: "Get one media item by scrape ID and item ID.", inputSchema: { scrapeId: z.string().describe("Scrape ID"), itemId: z.number().int().min(1).describe("Media row ID inside the scrape") } }, async ({ scrapeId, itemId }) => jsonText(media.getMediaItem(scrapeId, itemId)) ); server.registerTool( "delete_media_scrape", { description: "Delete a scrape and its files. Irreversible.", inputSchema: { scrapeId: z.string().describe("Scrape ID") } }, async ({ scrapeId }) => jsonText({ deleted: media.cleanMedia([scrapeId]) === 1 }) ); return server; } void connectLoop(); 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", async (_req, res) => { let selfId = puppet?.self; if (!selfId && core) { try { selfId = await core.getSelfId(); } catch { // The core may not be ready yet. } } res.json({ ok: true, coreConnected, socketPath: coreSocketPath, ownerId: puppet?.owner, selfId, queries: getQueryCount() }); }); app.listen(PORT, HOST, (error?: Error) => { if (error) { console.error("Failed to start control bridge:", error); process.exit(1); } console.log(`[daemon] MCP listening on http://${HOST}:${PORT}/mcp`); const stale = staleBundleWarning(); if (stale) console.warn(`[daemon] ${stale}`); }); function shutdown(keepCore = false) { stopSpeakingPoll(); core?.close(); const tasks: Array> = [audio.dispose()]; if (!keepCore) tasks.push(coreProcess.dispose(4000)); void Promise.allSettled(tasks).finally(() => process.exit(0)); } /** Replace this daemon with a fresh copy, leaving the client running. */ let restartingDaemon = false; function restartDaemon(): void { if (restartingDaemon) return; restartingDaemon = true; const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; const args = [...process.execArgv, ...process.argv.slice(1)]; const command = `${quote(process.execPath)} ${args.map(quote).join(" ")}`; const logPath = process.env.VESKSLOP_DAEMON_LOG ?? join(dirname(tokenFile), "daemon.log"); console.log(`[daemon] restart requested: spawning a replacement, appending to ${logPath}`); const logFd = openSync(logPath, "a"); const child = spawn("sh", ["-c", `sleep 2; exec ${command}`], { detached: true, stdio: ["ignore", logFd, logFd], cwd: process.cwd(), env: { ...process.env, VESKSLOP_DAEMON_RESTART: "1" } }); child.unref(); closeSync(logFd); setTimeout(() => shutdown(true), 300); } process.on("SIGINT", () => shutdown()); process.on("SIGTERM", () => shutdown());