424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
|
|
#!/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<User, []>(`SELECT ${USER_COLS} FROM users ORDER BY role DESC, added_at ASC`),
|
|||
|
|
userById: db.query<User, [number]>(`SELECT ${USER_COLS} FROM users WHERE telegram_id = ?`),
|
|||
|
|
userByMc: db.query<User, [string]>(`SELECT ${USER_COLS} FROM users WHERE minecraft_name = ?`),
|
|||
|
|
userByUsername: db.query<User, [string]>(`SELECT ${USER_COLS} FROM users WHERE username = ?`),
|
|||
|
|
pending: db.query<User, []>(
|
|||
|
|
`SELECT ${USER_COLS} FROM users WHERE status = 'pending' ORDER BY added_at ASC`,
|
|||
|
|
),
|
|||
|
|
upsert: db.query<unknown, [number, string | null, Role]>(
|
|||
|
|
`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<unknown, [string | null, number]>(
|
|||
|
|
"UPDATE users SET minecraft_name = ? WHERE telegram_id = ?",
|
|||
|
|
),
|
|||
|
|
setStatus: db.query<unknown, [Status, number]>(
|
|||
|
|
"UPDATE users SET status = ? WHERE telegram_id = ?",
|
|||
|
|
),
|
|||
|
|
setRole: db.query<unknown, [Role, number]>(
|
|||
|
|
"UPDATE users SET role = ? WHERE telegram_id = ?",
|
|||
|
|
),
|
|||
|
|
del: db.query<unknown, [number]>("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<boolean> {
|
|||
|
|
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<string, unknown>, tgCtx);
|
|||
|
|
return json(result);
|
|||
|
|
} catch (e) {
|
|||
|
|
return fail(`${t.name}: ${(e as Error).message}`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- start ----------
|
|||
|
|
|
|||
|
|
await server.connect(new StdioServerTransport());
|