#!/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"; 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"; const COMPOSE_FILE = process.env.COMPOSE_FILE ?? `${MC_DIR}/docker-compose.yml`; const SERVER_SVC = process.env.SERVER_SERVICE ?? "minecraft"; const SERVER_CONTAINER = process.env.SERVER_CONTAINER ?? "minecraft"; const PF_SVC = process.env.PORTFORWARD_SERVICE ?? "autossh"; 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 = { tg: TG_TOKEN ? createTgClient(TG_TOKEN) : { call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); }, }, db, }; for (const t of 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); return json(result); } catch (e) { return fail(`${t.name}: ${(e as Error).message}`); } }, ); } // ---------- start ---------- await server.connect(new StdioServerTransport());