Files
Minecraft-Server/mcp/lib/minecraft-tools.ts
Paul Kloppers cab5337e3f stream Redstone replies via sendMessageDraft + fix self-killing server_down
Two related changes to how Redstone behaves in chat.

Streaming: replies now animate word-by-word into Telegram using the new
sendMessageDraft endpoint (Bot API 9.5, March 2026) via the @grammyjs/stream
plugin, with @grammyjs/auto-retry on the API layer to swallow 429s
transparently. The previous editMessageText-based approach is gone —
sendMessageDraft is designed for this and animates natively on the client
without hitting the 1/sec-per-chat edit limit. Pace lives in
STREAM_WORD_DELAY_MS=50; tunable in one spot.

server_down footgun: the MCP tool was running `compose down` with no
service arg, which tore down the whole project — including the bot
container running the tool call, which then got SIGTERM mid-conversation
and didn't come back (unless-stopped doesn't restart on a clean compose
down). Behaviour now matches the existing /stop slash command:
compose stop minecraft autossh, leaving the bot up. Pass an explicit
service to stop just that one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 08:47:35 +02:00

271 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<z.ZodRawShape>[] = [
// ---- 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: "Stop the minecraft + autossh services. The bot container is left running on purpose — a full `compose down` would shut the bot off mid-conversation. Pass a specific service to stop just that one.",
parameters: { service: z.string().optional() },
handler: async ({ service }, { mc }) => {
// Without a service arg, stop only minecraft + autossh and leave the bot
// running. `compose down` would tear down everything including the bot,
// which kills the very process executing this tool call.
const r = service
? await mc.compose("stop", service)
: await mc.compose("stop", mc.config.serverSvc, mc.config.pfSvc);
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 (316 chars, AZ, 09, _).",
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 (316 chars, AZ, 09, _).`);
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 };
},
}),
];