Files
Minecraft-Server/mcp/server.ts

424 lines
14 KiB
TypeScript
Raw Normal View History

#!/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 (316 chars, AZ, 09, _).",
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 (316 chars, AZ, 09, _).`);
}
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());