diff --git a/bot/bot.ts b/bot/bot.ts index 32d4cf6..1797c10 100644 --- a/bot/bot.ts +++ b/bot/bot.ts @@ -868,13 +868,26 @@ function escape(s: string): string { return s.replace(/&/g, "&").replace(/ [t.name, t])); -const geminiFunctionDeclarations = telegramTools.map(toolToGeminiFunction); +const mcRuntime = createMcRuntime({ + mcDir: MC_DIR, + composeFile: COMPOSE_FILE, + composeProject: COMPOSE_PROJECT, + serverSvc: SERVER_SVC, + serverContainer: SERVER_CONTAINER, + pfSvc: PF_SVC, + pfContainer: PF_CONTAINER, + backupSh: `${MC_DIR}/backup.sh`, +}); +const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime }; +const allTools = [...telegramTools, ...minecraftTools]; +const toolsByName = new Map(allTools.map((t) => [t.name, t])); +const geminiFunctionDeclarations = allTools.map(toolToGeminiFunction); type GeminiPart = | { text: string } @@ -947,14 +960,15 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri "- Do NOT prefix your reply with phrases like 'The user said…', 'I am Redstone…', 'Plan:', 'I should…', or any restatement of the situation.", "- Do NOT narrate what you are about to do — just do it (either by sending the reply, or by calling a tool).", "", - "What you can do:", - "- You have tools to act inside this Telegram chat: send messages, polls, reactions, dice rolls, stickers, edit/pin/delete your own messages, query chat info, etc.", - "- Use a tool when the user wants you to *do* something in the chat (e.g. 'run a poll', 'react with fire', 'pin that').", - "- When you do use a tool, you usually do not need a separate text reply unless you're adding context — the tool's effect IS the response.", + "What you can do — call tools to act:", + "- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).", + "- Minecraft server: check status (server_status), start/stop/restart services (server_up/down/restart), tail logs (server_logs), look up players in the bot's database (players_list/player_get).", + "- Admin-only tools (the caller's role is shown in the context block): run RCON commands (rcon), trigger a world backup (backup), and modify player records (player_upsert/set_*/remove, seen_list). If a non-admin asks for one, politely refuse and don't call it.", + "- When you use a tool, you usually don't need a separate text reply unless you're adding context — the tool's effect IS the response. After a server_status / players_list lookup, summarize the answer in one sentence.", "", - "What you cannot do:", - "- You CANNOT start/stop the server, run rcon commands, take backups, or change Minecraft state. Those have slash commands (/up, /down, /status, /logs, /setmcname, /whois, /menu) — point users at them instead of pretending.", + "Rules:", "- Never reveal API keys, tokens, or other secrets.", + "- For chat-management requests in groups, remember you may not have admin rights; let Telegram's error response speak for itself if the call fails.", "", "Context for this turn:", `- Chat type: ${chatType}`, @@ -985,9 +999,6 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string): generationConfig: { temperature: 0.6, maxOutputTokens: 600, - // Disable Gemini's internal reasoning trace — Flash Lite sometimes leaks - // its plan into the user-visible text part. Zero budget = no thinking. - thinkingConfig: { thinkingBudget: 0, includeThoughts: false }, }, }), signal: AbortSignal.timeout(25_000), @@ -1005,10 +1016,9 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string): type TurnResult = { text: string | null; ranTool: boolean }; -async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string): Promise { +async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise { const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; let ranTool = false; - // Cap tool-use iterations so a confused model can't loop forever. for (let i = 0; i < 4; i++) { const data = await callGeminiOnce(contents, systemPrompt); if (!data) return { text: null, ranTool }; @@ -1018,7 +1028,6 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u const functionCalls = parts.filter((p): p is { functionCall: { name: string; args: Record } } => "functionCall" in p && !!p.functionCall); if (functionCalls.length === 0) { - // Drop any thought parts (`thought: true`) if they slipped through. const text = parts .filter((p) => "text" in p && !(p as { thought?: boolean }).thought) .map((p) => (p as { text: string }).text) @@ -1034,6 +1043,8 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u let response: Record; if (!tool) { response = { error: `unknown tool '${fc.functionCall.name}'` }; + } else if (tool.requiresAdmin && callerRole !== "admin") { + response = { error: `tool '${fc.functionCall.name}' requires admin role; the calling user is '${callerRole}'. Inform the user, do not retry.` }; } else { try { const result = await tool.handler(fc.functionCall.args, toolCtx); @@ -1055,8 +1066,18 @@ bot.on("message:text", async (ctx) => { if (!me) return; if (!botAddressed(ctx, me.id, me.username)) return; if (!GEMINI_API_KEY) return; - const user = (ctx as Context & { user?: User }).user; - if (!user) return; + + // Hard authorization gate. Re-checks the DB at handler time rather than + // trusting the upstream auth middleware, so any future refactor that + // accidentally bypasses the middleware still can't make Redstone reply + // to a stranger — even when @-mentioned in a public group. + const fromId = ctx.from?.id; + if (fromId === undefined) return; + const user = lookup(fromId); + if (!user || user.status !== "active") { + console.warn(`redstone: refusing unauthorized sender ${fromId} (${ctx.from?.username ?? "?"}) in chat ${ctx.chat?.id} (${ctx.chat?.type})`); + return; + } const raw = ctx.message.text ?? ""; const text = stripMention(raw, me.username); @@ -1072,7 +1093,7 @@ bot.on("message:text", async (ctx) => { const latest = Q_latestMsgId.get(chatId)?.id ?? 0; const history = recentTurns(chatId, latest, 6); - const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text); + const { text: reply, ranTool } = await runRedstoneTurn(sys, history, text, user.role); if (!reply) { // If Redstone took an action (e.g. sent a poll) and didn't add commentary, // staying silent is correct. Only warn-silently when nothing happened. diff --git a/mcp/lib/minecraft-tools.ts b/mcp/lib/minecraft-tools.ts new file mode 100644 index 0000000..d2eaec6 --- /dev/null +++ b/mcp/lib/minecraft-tools.ts @@ -0,0 +1,265 @@ +// Minecraft tool catalog (server lifecycle, rcon, backup, player DB). +// Imported by mcp/server.ts and bot/bot.ts via the shared toolkit. +import { z } from "zod"; +import { tool, type Tool } from "./types.ts"; + +const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/; + +type Role = "admin" | "user"; +type Status = "active" | "pending"; +type DbUser = { + telegram_id: number; + name: string | null; + role: Role; + minecraft_name: string | null; + username: string | null; + status: Status; +}; + +const USER_COLS = "telegram_id, name, role, minecraft_name, username, status"; + +export const minecraftTools: Tool[] = [ + // ---- server lifecycle ---- + tool({ + name: "server_status", + title: "Server status", + description: "Report whether the minecraft and autossh tunnel containers are running.", + parameters: {}, + handler: async (_a, { mc }) => { + const [server, tunnel] = await Promise.all([ + mc.containerRunning(mc.config.serverContainer), + mc.containerRunning(mc.config.pfContainer), + ]); + return { + minecraft: { container: mc.config.serverContainer, running: server }, + autossh: { container: mc.config.pfContainer, running: tunnel }, + }; + }, + }), + tool({ + name: "server_up", + title: "Start services", + description: "docker compose up -d. Pass a service name (minecraft / autossh / bot) to start just one; omit for the whole stack.", + parameters: { service: z.string().optional() }, + handler: async ({ service }, { mc }) => { + const r = await (service ? mc.compose("up", "-d", service) : mc.compose("up", "-d")); + return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; + }, + }), + tool({ + name: "server_down", + title: "Stop services", + description: "docker compose down. Pass a service to stop just one (uses `stop` instead of `down`).", + parameters: { service: z.string().optional() }, + handler: async ({ service }, { mc }) => { + const r = service ? await mc.compose("stop", service) : await mc.compose("down"); + return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; + }, + }), + tool({ + name: "server_restart", + title: "Restart a service", + description: "docker compose restart of a single service (defaults to minecraft).", + parameters: { service: z.string().optional() }, + handler: async ({ service }, { mc }) => { + const svc = service ?? mc.config.serverSvc; + const r = await mc.compose("restart", svc); + return { ok: r.ok, code: r.code, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; + }, + }), + tool({ + name: "server_logs", + title: "Container logs", + description: "Tail recent logs from one of the project's containers.", + parameters: { + container: z.enum(["minecraft", "autossh", "bot"]).optional() + .describe("Which container; default minecraft."), + lines: z.number().int().positive().max(2000).optional() + .describe("How many lines to return; default 100."), + }, + handler: async ({ container, lines }, { mc }) => { + const name = container === "autossh" ? mc.config.pfContainer + : container === "bot" ? "minecraft-bot" + : mc.config.serverContainer; + const r = await mc.sh(["docker", "logs", "--tail", String(lines ?? 100), name]); + return { ok: r.ok, output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)" }; + }, + }), + + // ---- rcon (admin-only) ---- + tool({ + name: "rcon", + title: "RCON command", + description: "Run an RCON command on the live server via `rcon-cli` inside the minecraft container. Examples: 'list', 'say hello', 'whitelist add Steve', 'op Steve', 'gamemode creative @a'. Server must be running.", + parameters: { command: z.string().min(1).describe("Full RCON command, e.g. 'list' or 'say hi'.") }, + requiresAdmin: true, + handler: async ({ command }, { mc }) => { + if (!(await mc.containerRunning(mc.config.serverContainer))) { + throw new Error(`minecraft container '${mc.config.serverContainer}' is not running — start it with server_up first.`); + } + const parts = command.trim().split(/\s+/); + const r = await mc.sh(["docker", "exec", mc.config.serverContainer, "rcon-cli", ...parts]); + if (!r.ok) throw new Error(`rcon failed (exit ${r.code}): ${[r.out, r.err].filter(Boolean).join(" ")}`); + return { command, output: r.out || "(no output)" }; + }, + }), + + // ---- backup (admin-only) ---- + tool({ + name: "backup", + title: "Run world backup", + description: "Runs backup.sh: flushes the world via rcon if the server is running, archives data/world to backups/, and uploads it as a Gitea release asset if a token is configured. May take a while for large worlds.", + parameters: { + label: z.string().regex(/^[A-Za-z0-9._-]+$/).max(40).optional() + .describe("Optional label suffix for the archive filename, e.g. 'pre-update'."), + }, + requiresAdmin: true, + handler: async ({ label }, { mc }) => { + const args = label ? [mc.config.backupSh, label] : [mc.config.backupSh]; + const r = await mc.sh(args); + return { + ok: r.ok, + code: r.code, + output: [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)", + }; + }, + }), + + // ---- players (users db) ---- + tool({ + name: "players_list", + title: "List players", + description: "List all rows in the bot users table (telegram_id, name, role, minecraft_name, username, status). Optional status / role filter.", + parameters: { + status: z.enum(["active", "pending"]).optional(), + role: z.enum(["admin", "user"]).optional(), + }, + handler: async ({ status, role }, { db }) => { + let rows = db.prepare(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`).all() as DbUser[]; + if (status) rows = rows.filter((r) => r.status === status); + if (role) rows = rows.filter((r) => r.role === role); + return { count: rows.length, players: rows }; + }, + }), + tool({ + name: "player_get", + title: "Get player", + description: "Look up a single player. Provide exactly one of telegram_id, minecraft_name, or telegram_username.", + parameters: { + telegram_id: z.number().int().optional(), + minecraft_name: z.string().optional(), + telegram_username: z.string().optional(), + }, + handler: async ({ telegram_id, minecraft_name, telegram_username }, { db }) => { + const provided = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length; + if (provided !== 1) throw new Error("Provide exactly one of telegram_id, minecraft_name, telegram_username."); + const sql = `SELECT ${USER_COLS} FROM users WHERE ` + ( + telegram_id !== undefined ? "telegram_id = ?" + : minecraft_name !== undefined ? "minecraft_name = ?" + : "username = ?" + ); + const arg = telegram_id ?? minecraft_name ?? telegram_username!.replace(/^@/, ""); + const row = db.prepare(sql).get(arg) as DbUser | undefined; + if (!row) throw new Error("Not found."); + return row; + }, + }), + tool({ + name: "player_upsert", + title: "Add or update a player", + description: "Insert a player by telegram_id, or update their name/role if they exist. Use player_set_minecraft_name to attach an in-game name.", + parameters: { + telegram_id: z.number().int(), + name: z.string().nullable().optional(), + role: z.enum(["admin", "user"]).default("user"), + }, + requiresAdmin: true, + handler: async ({ telegram_id, name, role }, { db }) => { + db.prepare( + `INSERT INTO users (telegram_id, name, role) VALUES (?, ?, ?) + ON CONFLICT(telegram_id) DO UPDATE SET name = COALESCE(excluded.name, users.name), role = excluded.role`, + ).run(telegram_id, name ?? null, role); + return { saved: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; + }, + }), + tool({ + name: "player_set_minecraft_name", + title: "Set/clear a player's Minecraft name", + description: "Update the minecraft_name for an existing player. Pass null/empty to clear. Names must match Mojang's rules (3–16 chars, A–Z, 0–9, _).", + parameters: { + telegram_id: z.number().int(), + minecraft_name: z.string().nullable().describe("New name, or null/'' to clear."), + }, + requiresAdmin: true, + handler: async ({ telegram_id, minecraft_name }, { db }) => { + const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) as DbUser | undefined; + if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`); + const clean = minecraft_name && minecraft_name.length > 0 ? minecraft_name : null; + if (clean !== null && !MC_NAME_RE.test(clean)) throw new Error(`'${clean}' is not a valid Minecraft name (3–16 chars, A–Z, 0–9, _).`); + if (clean !== null) { + const taken = db.prepare(`SELECT telegram_id FROM users WHERE minecraft_name = ?`).get(clean) as { telegram_id: number } | undefined; + if (taken && taken.telegram_id !== telegram_id) throw new Error(`'${clean}' is already linked to telegram_id ${taken.telegram_id}.`); + } + db.prepare(`UPDATE users SET minecraft_name = ? WHERE telegram_id = ?`).run(clean, telegram_id); + return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; + }, + }), + tool({ + name: "player_set_role", + title: "Change a player's role", + description: "Promote/demote between 'admin' and 'user'.", + parameters: { + telegram_id: z.number().int(), + role: z.enum(["admin", "user"]), + }, + requiresAdmin: true, + handler: async ({ telegram_id, role }, { db }) => { + const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id); + if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`); + db.prepare(`UPDATE users SET role = ? WHERE telegram_id = ?`).run(role, telegram_id); + return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; + }, + }), + tool({ + name: "player_set_status", + title: "Set a player's status", + description: "Mark a player as 'active' (approved) or 'pending' (awaiting approval).", + parameters: { + telegram_id: z.number().int(), + status: z.enum(["active", "pending"]), + }, + requiresAdmin: true, + handler: async ({ telegram_id, status }, { db }) => { + const existing = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id); + if (!existing) throw new Error(`No player with telegram_id ${telegram_id}.`); + db.prepare(`UPDATE users SET status = ? WHERE telegram_id = ?`).run(status, telegram_id); + return { updated: db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) }; + }, + }), + tool({ + name: "player_remove", + title: "Delete a player", + description: "Remove a player record from the users table.", + parameters: { telegram_id: z.number().int() }, + requiresAdmin: true, + handler: async ({ telegram_id }, { db }) => { + const before = db.prepare(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`).get(telegram_id) as DbUser | undefined; + if (!before) throw new Error(`No player with telegram_id ${telegram_id}.`); + db.prepare(`DELETE FROM users WHERE telegram_id = ?`).run(telegram_id); + return { removed: before }; + }, + }), + tool({ + name: "seen_list", + title: "List seen-but-unauthorized users", + description: "List Telegram users the bot has observed but who aren't in the users table yet — useful for approving pairings.", + parameters: {}, + requiresAdmin: true, + handler: async (_a, { db }) => { + const rows = db.prepare( + "SELECT telegram_id, username, name, first_seen, last_seen FROM seen_users ORDER BY last_seen DESC", + ).all(); + return { count: rows.length, seen: rows }; + }, + }), +]; diff --git a/mcp/lib/telegram-tools.ts b/mcp/lib/telegram-tools.ts index ad61d4d..85a694a 100644 --- a/mcp/lib/telegram-tools.ts +++ b/mcp/lib/telegram-tools.ts @@ -1,52 +1,7 @@ -// Shared Telegram-action toolkit. -// Imported by mcp/server.ts (registers each as an MCP tool) and by bot/bot.ts -// (exposes each as a Gemini function declaration and dispatches calls). -// Imports of npm packages resolve via this directory's node_modules (i.e. mcp/node_modules) -// regardless of which process loads the file. +// Telegram tool catalog. Shared types + helpers live in ./types.ts. import { z } from "zod"; -import { zodToJsonSchema } from "zod-to-json-schema"; -import type { Database } from "bun:sqlite"; - -// ---- Telegram HTTP client ---- - -export type TgClient = { - call: (method: string, params?: Record) => Promise; -}; - -export function createTgClient(token: string): TgClient { - if (!token) throw new Error("createTgClient: empty token"); - const base = `https://api.telegram.org/bot${token}`; - return { - async call(method: string, params: Record = {}): Promise { - const res = await fetch(`${base}/${method}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(params), - signal: AbortSignal.timeout(20_000), - }); - const data = (await res.json()) as { ok: boolean; result?: T; description?: string; error_code?: number }; - if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`); - return data.result as T; - }, - }; -} - -// ---- Tool context passed to every handler ---- - -export type ToolCtx = { - tg: TgClient; - db: Database; -}; - -// ---- Tool definition shape ---- - -export type Tool = { - name: string; - title: string; - description: string; - parameters: Params; - handler: (args: z.infer>, ctx: ToolCtx) => Promise; -}; +import { tool, type Tool } from "./types.ts"; +export { createTgClient, toolToGeminiFunction, type ToolCtx, type Tool, type TgClient } from "./types.ts"; // ---- Shared sub-schemas ---- @@ -83,11 +38,6 @@ function buildKeyboard(rows: z.infer[][] | undefined) { // ---- The tool catalog ---- -// Helper to keep the array strictly typed without losing each tool's specific param shape. -function tool

(t: Tool

): Tool { - return t as Tool; -} - export const telegramTools: Tool[] = [ // ---- discovery ---- tool({ @@ -418,44 +368,3 @@ export const telegramTools: Tool[] = [ }), ]; -// ---- JSON Schema conversion for Gemini function declarations ---- - -// Gemini's parameters block accepts a JSON-Schema-ish dialect. zod-to-json-schema -// emits JSON-Schema draft-07; the field shapes we use (string/integer/number/boolean/array/object/enum) -// pass through unchanged. We strip `$schema` since Gemini ignores it. -function cleanForGemini(s: unknown): unknown { - if (Array.isArray(s)) return s.map(cleanForGemini); - if (s && typeof s === "object") { - const out: Record = {}; - for (const [k, v] of Object.entries(s as Record)) { - if (k === "$schema" || k === "additionalProperties" || k === "default") continue; - // Gemini doesn't accept `union/anyOf` for chat fields — flatten to "string" for compatibility. - if (k === "anyOf" || k === "oneOf") { - // Pick the first non-null subtype heuristically. - const variants = (v as unknown[]).filter((x) => x && (x as Record).type !== "null"); - if (variants.length === 1) return cleanForGemini(variants[0]); - // For mixed types we fall back to string. - return { type: "string" }; - } - out[k] = cleanForGemini(v); - } - return out; - } - return s; -} - -export function toolToGeminiFunction(t: Tool) { - // Gemini's schema dialect rejects $ref/$defs/$schema/additionalProperties, so: - // - $refStrategy "none" inlines shared sub-schemas (e.g. ButtonSchema reused across tools). - // - cleanForGemini strips the rest. - const schema = zodToJsonSchema(z.object(t.parameters), { - $refStrategy: "none", - target: "openApi3", - }); - const params = cleanForGemini(schema) as Record; - return { - name: t.name, - description: t.description, - parameters: params.type ? params : { type: "object", properties: {} }, - }; -} diff --git a/mcp/lib/types.ts b/mcp/lib/types.ts new file mode 100644 index 0000000..2b5d144 --- /dev/null +++ b/mcp/lib/types.ts @@ -0,0 +1,135 @@ +// Shared types + helpers used by both telegram-tools and minecraft-tools. +import { z } from "zod"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import type { Database } from "bun:sqlite"; + +// ---- Telegram HTTP client ---- + +export type TgClient = { + call: (method: string, params?: Record) => Promise; +}; + +export function createTgClient(token: string): TgClient { + if (!token) throw new Error("createTgClient: empty token"); + const base = `https://api.telegram.org/bot${token}`; + return { + async call(method: string, params: Record = {}): Promise { + const res = await fetch(`${base}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + signal: AbortSignal.timeout(20_000), + }); + const data = (await res.json()) as { ok: boolean; result?: T; description?: string; error_code?: number }; + if (!data.ok) throw new Error(`${method}: ${data.description} (code ${data.error_code})`); + return data.result as T; + }, + }; +} + +// ---- Minecraft runtime (docker/host shell) ---- + +export type ShResult = { ok: boolean; code: number; out: string; err: string }; + +export type McConfig = { + mcDir: string; + composeFile: string; + composeProject: string; + serverSvc: string; + serverContainer: string; + pfSvc: string; + pfContainer: string; + backupSh: string; +}; + +export type McRuntime = { + config: McConfig; + sh: (cmd: string[]) => Promise; + compose: (...args: string[]) => Promise; + containerRunning: (name: string) => Promise; +}; + +export function createMcRuntime(config: McConfig): McRuntime { + async function sh(cmd: string[]): Promise { + const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" }); + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { ok: code === 0, code, out: out.trimEnd(), err: err.trimEnd() }; + } + return { + config, + sh, + compose: (...args) => sh([ + "docker", "compose", + "--project-directory", config.mcDir, + "-p", config.composeProject, + "-f", config.composeFile, + ...args, + ]), + async containerRunning(name) { + const r = await sh(["docker", "inspect", "-f", "{{.State.Running}}", name]); + return r.ok && r.out === "true"; + }, + }; +} + +// ---- Tool definition shape ---- + +export type ToolCtx = { + tg: TgClient; + db: Database; + mc: McRuntime; +}; + +export type Tool = { + name: string; + title: string; + description: string; + parameters: Params; + handler: (args: z.infer>, ctx: ToolCtx) => Promise; + /** Bot-side: only admins may invoke. MCP server ignores this (caller is trusted). */ + requiresAdmin?: boolean; +}; + +// Strip per-tool shape so a heterogeneous catalog type-checks. +export function tool

(t: Tool

): Tool { + return t as Tool; +} + +// ---- Zod -> Gemini function declaration ---- + +function cleanForGemini(s: unknown): unknown { + if (Array.isArray(s)) return s.map(cleanForGemini); + if (s && typeof s === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(s as Record)) { + if (k === "$schema" || k === "additionalProperties" || k === "default") continue; + // Gemini's schema dialect uses minimum/maximum only, not the exclusive variants. + if (k === "exclusiveMinimum" || k === "exclusiveMaximum") continue; + if (k === "anyOf" || k === "oneOf") { + const variants = (v as unknown[]).filter((x) => x && (x as Record).type !== "null"); + if (variants.length === 1) return cleanForGemini(variants[0]); + return { type: "string" }; + } + out[k] = cleanForGemini(v); + } + return out; + } + return s; +} + +export function toolToGeminiFunction(t: Tool) { + const schema = zodToJsonSchema(z.object(t.parameters), { + $refStrategy: "none", + target: "openApi3", + }); + const params = cleanForGemini(schema) as Record; + return { + name: t.name, + description: t.description, + parameters: params.type ? params : { type: "object", properties: {} }, + }; +} diff --git a/mcp/server.ts b/mcp/server.ts index 214ad14..d7f91f3 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -1,12 +1,15 @@ #!/usr/bin/env bun import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; import { Database } from "bun:sqlite"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { readFileSync } from "node:fs"; +import { createTgClient, createMcRuntime, type ToolCtx } from "./lib/types.ts"; +import { telegramTools } from "./lib/telegram-tools.ts"; +import { minecraftTools } from "./lib/minecraft-tools.ts"; + const HERE = dirname(fileURLToPath(import.meta.url)); const MC_DIR = process.env.MC_DIR ?? resolve(HERE, ".."); const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? "minecraft"; @@ -18,398 +21,45 @@ const PF_CONTAINER = process.env.PORTFORWARD_CONTAINER ?? "minecraft-autossh"; const BACKUP_SH = process.env.BACKUP_SH ?? `${MC_DIR}/backup.sh`; const DB_PATH = process.env.DB_PATH ?? `${MC_DIR}/bot/data/users.db`; -const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/; - -type Role = "admin" | "user"; -type Status = "active" | "pending"; -type User = { - telegram_id: number; - name: string | null; - role: Role; - minecraft_name: string | null; - username: string | null; - status: Status; -}; - -const USER_COLS = "telegram_id, name, role, minecraft_name, username, status"; - const db = new Database(DB_PATH); db.exec("PRAGMA journal_mode = WAL"); -const Q = { - users: db.query(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`), - userById: db.query(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`), - userByMc: db.query(`SELECT ${USER_COLS} FROM users WHERE minecraft_name = ?`), - userByUsername: db.query(`SELECT ${USER_COLS} FROM users WHERE username = ?`), - pending: db.query( - `SELECT ${USER_COLS} FROM users WHERE status = 'pending' ORDER BY added_at ASC`, - ), - upsert: db.query( - `INSERT INTO users (telegram_id, name, role) VALUES (?, ?, ?) - ON CONFLICT(telegram_id) DO UPDATE SET - name = COALESCE(excluded.name, users.name), role = excluded.role`, - ), - setMc: db.query( - "UPDATE users SET minecraft_name = ? WHERE telegram_id = ?", - ), - setStatus: db.query( - "UPDATE users SET status = ? WHERE telegram_id = ?", - ), - setRole: db.query( - "UPDATE users SET role = ? WHERE telegram_id = ?", - ), - del: db.query("DELETE FROM users WHERE telegram_id = ?"), - seen: db.query<{ telegram_id: number; username: string | null; name: string | null; first_seen: string; last_seen: string }, []>( - `SELECT telegram_id, username, name, first_seen, last_seen - FROM seen_users ORDER BY last_seen DESC`, - ), -}; - -async function sh(cmd: string[], opts: { input?: string } = {}) { - const proc = Bun.spawn(cmd, { - stdout: "pipe", - stderr: "pipe", - stdin: opts.input ? "pipe" : "ignore", - }); - if (opts.input) { - proc.stdin.write(opts.input); - proc.stdin.end(); - } - const [out, err] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), - ]); - const code = await proc.exited; - return { ok: code === 0, code, out: out.trimEnd(), err: err.trimEnd() }; -} - -async function compose(...args: string[]) { - return sh([ - "docker", "compose", - "--project-directory", MC_DIR, - "-p", COMPOSE_PROJECT, - "-f", COMPOSE_FILE, - ...args, - ]); -} - -async function containerRunning(name: string): Promise { - const r = await sh(["docker", "inspect", "-f", "{{.State.Running}}", name]); - return r.ok && r.out === "true"; -} - -function ok(text: string) { - return { content: [{ type: "text" as const, text }] }; -} -function fail(text: string) { - return { content: [{ type: "text" as const, text }], isError: true }; -} -function json(obj: unknown) { - return ok(JSON.stringify(obj, null, 2)); -} -function shResult(label: string, r: { ok: boolean; code: number; out: string; err: string }) { - const body = [r.out, r.err].filter(Boolean).join("\n").trim() || "(no output)"; - const head = `${label} ${r.ok ? "ok" : `failed (exit ${r.code})`}`; - return r.ok ? ok(`${head}\n${body}`) : fail(`${head}\n${body}`); -} - -const server = new McpServer({ name: "minecraft", version: "0.1.0" }); - -// ---------- server lifecycle ---------- - -server.registerTool( - "server_status", - { - title: "Server status", - description: "Report whether the minecraft and autossh tunnel containers are running.", - inputSchema: {}, - }, - async () => { - const [mc, pf] = await Promise.all([ - containerRunning(SERVER_CONTAINER), - containerRunning(PF_CONTAINER), - ]); - return json({ - minecraft: { container: SERVER_CONTAINER, running: mc }, - autossh: { container: PF_CONTAINER, running: pf }, - }); - }, -); - -server.registerTool( - "server_up", - { - title: "Start services", - description: "docker compose up -d. Pass a service name to start just one (minecraft / autossh / bot); omit to bring up the whole stack.", - inputSchema: { service: z.string().optional() }, - }, - async ({ service }) => { - const args = service ? ["up", "-d", service] : ["up", "-d"]; - const r = await compose(...args); - return shResult(`compose ${args.join(" ")}`, r); - }, -); - -server.registerTool( - "server_down", - { - title: "Stop services", - description: "docker compose down. Pass a service to stop just one (uses `stop` instead of `down`).", - inputSchema: { service: z.string().optional() }, - }, - async ({ service }) => { - const r = service ? await compose("stop", service) : await compose("down"); - return shResult(service ? `compose stop ${service}` : "compose down", r); - }, -); - -server.registerTool( - "server_restart", - { - title: "Restart service", - description: "docker compose restart of a single service (defaults to minecraft).", - inputSchema: { service: z.string().optional() }, - }, - async ({ service }) => { - const svc = service ?? SERVER_SVC; - const r = await compose("restart", svc); - return shResult(`compose restart ${svc}`, r); - }, -); - -server.registerTool( - "server_logs", - { - title: "Container logs", - description: "Tail recent logs from one of the project's containers.", - inputSchema: { - container: z.enum(["minecraft", "autossh", "bot"]).optional() - .describe("Which container; default minecraft."), - lines: z.number().int().positive().max(2000).optional() - .describe("How many lines to return; default 100."), - }, - }, - async ({ container, lines }) => { - const name = container === "autossh" ? PF_CONTAINER - : container === "bot" ? "minecraft-bot" - : SERVER_CONTAINER; - const r = await sh(["docker", "logs", "--tail", String(lines ?? 100), name]); - return shResult(`logs ${name}`, r); - }, -); - -// ---------- rcon ---------- - -server.registerTool( - "rcon", - { - title: "RCON command", - description: "Run an RCON command on the live server via `rcon-cli` inside the minecraft container. Examples: 'list', 'say hello', 'whitelist add Steve', 'op Steve', 'gamemode creative @a'. Requires the server to be running.", - inputSchema: { command: z.string().min(1).describe("The full RCON command, e.g. 'list' or 'say hi'.") }, - }, - async ({ command }) => { - if (!(await containerRunning(SERVER_CONTAINER))) { - return fail(`minecraft container '${SERVER_CONTAINER}' is not running — start it with server_up first.`); - } - const parts = command.trim().split(/\s+/); - const r = await sh(["docker", "exec", SERVER_CONTAINER, "rcon-cli", ...parts]); - return shResult(`rcon: ${command}`, r); - }, -); - -// ---------- backup ---------- - -server.registerTool( - "backup", - { - title: "Run world backup", - description: "Runs backup.sh which flushes the world via rcon (if running), archives data/world to backups/, and uploads it as a Gitea release asset if a token is configured. Returns the script's full stdout/stderr.", - inputSchema: { - label: z.string().regex(/^[A-Za-z0-9._-]+$/).max(40).optional() - .describe("Optional label suffix for the archive filename, e.g. 'pre-update'."), - }, - }, - async ({ label }) => { - const args = label ? [BACKUP_SH, label] : [BACKUP_SH]; - const r = await sh(args); - return shResult(`backup${label ? ` ${label}` : ""}`, r); - }, -); - -// ---------- players (users db) ---------- - -server.registerTool( - "players_list", - { - title: "List players", - description: "List all rows in the bot users table (telegram_id, name, role, minecraft_name, username, status). Optionally filter to one status or role.", - inputSchema: { - status: z.enum(["active", "pending"]).optional(), - role: z.enum(["admin", "user"]).optional(), - }, - }, - async ({ status, role }) => { - let rows = Q.users.all(); - if (status) rows = rows.filter((r) => r.status === status); - if (role) rows = rows.filter((r) => r.role === role); - return json({ count: rows.length, players: rows }); - }, -); - -server.registerTool( - "player_get", - { - title: "Get player", - description: "Look up a single player. Provide exactly one of telegram_id, minecraft_name, or telegram_username.", - inputSchema: { - telegram_id: z.number().int().optional(), - minecraft_name: z.string().optional(), - telegram_username: z.string().optional(), - }, - }, - async ({ telegram_id, minecraft_name, telegram_username }) => { - const provided = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length; - if (provided !== 1) return fail("Provide exactly one of telegram_id, minecraft_name, or telegram_username."); - const row = telegram_id !== undefined ? Q.userById.get(telegram_id) - : minecraft_name !== undefined ? Q.userByMc.get(minecraft_name) - : Q.userByUsername.get(telegram_username!.replace(/^@/, "")); - return row ? json(row) : fail("Not found."); - }, -); - -server.registerTool( - "player_upsert", - { - title: "Add or update a player", - description: "Insert a player by telegram_id, or update their name/role if they already exist. Use player_set_minecraft_name to attach an in-game name.", - inputSchema: { - telegram_id: z.number().int(), - name: z.string().nullable().optional(), - role: z.enum(["admin", "user"]).default("user"), - }, - }, - async ({ telegram_id, name, role }) => { - Q.upsert.run(telegram_id, name ?? null, role); - const row = Q.userById.get(telegram_id); - return json({ saved: row }); - }, -); - -server.registerTool( - "player_set_minecraft_name", - { - title: "Set/clear a player's Minecraft name", - description: "Update the minecraft_name for an existing player. Pass null/empty to clear. Names must match Mojang's rules (3–16 chars, A–Z, 0–9, _).", - inputSchema: { - telegram_id: z.number().int(), - minecraft_name: z.string().nullable().describe("New name, or null/'' to clear."), - }, - }, - async ({ telegram_id, minecraft_name }) => { - if (!Q.userById.get(telegram_id)) return fail(`No player with telegram_id ${telegram_id}.`); - const clean = minecraft_name && minecraft_name.length > 0 ? minecraft_name : null; - if (clean !== null && !MC_NAME_RE.test(clean)) { - return fail(`'${clean}' is not a valid Minecraft name (3–16 chars, A–Z, 0–9, _).`); - } - if (clean !== null) { - const taken = Q.userByMc.get(clean); - if (taken && taken.telegram_id !== telegram_id) { - return fail(`'${clean}' is already linked to telegram_id ${taken.telegram_id}.`); - } - } - Q.setMc.run(clean, telegram_id); - return json({ updated: Q.userById.get(telegram_id) }); - }, -); - -server.registerTool( - "player_set_role", - { - title: "Change a player's role", - description: "Promote/demote between 'admin' and 'user'.", - inputSchema: { - telegram_id: z.number().int(), - role: z.enum(["admin", "user"]), - }, - }, - async ({ telegram_id, role }) => { - if (!Q.userById.get(telegram_id)) return fail(`No player with telegram_id ${telegram_id}.`); - Q.setRole.run(role, telegram_id); - return json({ updated: Q.userById.get(telegram_id) }); - }, -); - -server.registerTool( - "player_set_status", - { - title: "Set a player's status", - description: "Mark a player as 'active' (approved) or 'pending' (awaiting approval).", - inputSchema: { - telegram_id: z.number().int(), - status: z.enum(["active", "pending"]), - }, - }, - async ({ telegram_id, status }) => { - if (!Q.userById.get(telegram_id)) return fail(`No player with telegram_id ${telegram_id}.`); - Q.setStatus.run(status, telegram_id); - return json({ updated: Q.userById.get(telegram_id) }); - }, -); - -server.registerTool( - "player_remove", - { - title: "Delete a player", - description: "Remove a player record from the users table.", - inputSchema: { telegram_id: z.number().int() }, - }, - async ({ telegram_id }) => { - const before = Q.userById.get(telegram_id); - if (!before) return fail(`No player with telegram_id ${telegram_id}.`); - Q.del.run(telegram_id); - return json({ removed: before }); - }, -); - -server.registerTool( - "seen_list", - { - title: "List seen-but-unauthorized users", - description: "List Telegram users the bot has observed but who aren't in the users table yet — useful for approving pairings.", - inputSchema: {}, - }, - async () => { - const rows = Q.seen.all(); - return json({ count: rows.length, seen: rows }); - }, -); - - -// ---------- Telegram Bot API (shared toolkit) ---------- -// The same tool catalog is imported by bot/bot.ts so Gemini can call these via -// function calling. Everything below just adapts each entry into an MCP tool. -import { createTgClient, telegramTools, type ToolCtx } from "./lib/telegram-tools.js"; - const TG_TOKEN = (() => { if (process.env.BOT_TOKEN) return process.env.BOT_TOKEN.trim(); const file = process.env.BOT_TOKEN_FILE ?? `${MC_DIR}/bot.token`; try { return readFileSync(file, "utf8").trim(); } catch { return ""; } })(); -const tgCtx: ToolCtx = { +const ctx: ToolCtx = { tg: TG_TOKEN ? createTgClient(TG_TOKEN) : { call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); }, }, db, + mc: createMcRuntime({ + mcDir: MC_DIR, + composeFile: COMPOSE_FILE, + composeProject: COMPOSE_PROJECT, + serverSvc: SERVER_SVC, + serverContainer: SERVER_CONTAINER, + pfSvc: PF_SVC, + pfContainer: PF_CONTAINER, + backupSh: BACKUP_SH, + }), }; -for (const t of telegramTools) { +const server = new McpServer({ name: "minecraft", version: "0.2.0" }); + +function ok(text: string) { return { content: [{ type: "text" as const, text }] }; } +function fail(text: string) { return { content: [{ type: "text" as const, text }], isError: true }; } +function json(obj: unknown) { return ok(JSON.stringify(obj, null, 2)); } + +for (const t of [...minecraftTools, ...telegramTools]) { server.registerTool( t.name, { title: t.title, description: t.description, inputSchema: t.parameters }, async (args) => { try { - const result = await t.handler(args as Record, tgCtx); + const result = await t.handler(args as Record, ctx); return json(result); } catch (e) { return fail(`${t.name}: ${(e as Error).message}`); @@ -418,6 +68,4 @@ for (const t of telegramTools) { ); } -// ---------- start ---------- - await server.connect(new StdioServerTransport());