extract Minecraft tools to shared toolkit; gate Redstone replies to authorized senders

Redstone previously only saw the 23 tg_* tools — it had no idea the
Minecraft stack existed, so questions like "is the server up?" went
unanswered. This change extracts the 15 Minecraft tools (lifecycle,
rcon, backup, players, seen) into the same shared catalog the Telegram
tools already used, so both Gemini and external MCP clients see them.

mcp/lib/types.ts (new) holds the shared shape: Tool<P>, ToolCtx,
createTgClient, createMcRuntime (a Bun.spawn-based wrapper for docker
compose / docker exec), and toolToGeminiFunction (zod → Gemini schema,
now also stripping exclusiveMinimum/Maximum since Gemini rejects them).

mcp/lib/minecraft-tools.ts (new) is the catalog itself. Eight handlers
are flagged requiresAdmin: rcon, backup, and all destructive player_*
writes plus seen_list. mcp/server.ts trusts the caller (Claude / Paul
on the host) and ignores the flag; bot/bot.ts honours it at dispatch
time, returning {error: "...requires admin role..."} to Gemini so it
can explain to the user instead of attempting the call.

mcp/server.ts shrinks from 423 lines to 70 — a single loop over both
catalogs replaces the hand-rolled registrations.

bot/bot.ts wires both catalogs into the function declarations and adds
the admin gate. It also gains a defensive re-check on every incoming
group/DM text: the Redstone handler now does its own lookup of
ctx.from.id against the users table and refuses to reply unless
status='active'. This is belt-and-braces — the auth middleware already
short-circuits unauthorized callers earlier in the chain, but with
privacy-mode-off groups now feeding every message through, a future
refactor that reorders middleware shouldn't be able to make Redstone
respond to strangers.

Drops thinkingConfig from the Gemini call (gemini-2.5-flash-lite
rejects it outright with HTTP 400).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 23:19:27 +02:00
parent 875d6fd6dd
commit d22cb73a2c
5 changed files with 467 additions and 489 deletions

View File

@@ -868,13 +868,26 @@ function escape(s: string): string { return s.replace(/&/g, "&amp;").replace(/</
// ---- Redstone (Gemini + shared Telegram toolkit) ---- // ---- Redstone (Gemini + shared Telegram toolkit) ----
import { import {
createTgClient, telegramTools, toolToGeminiFunction, type ToolCtx, createTgClient, createMcRuntime, toolToGeminiFunction, type ToolCtx,
} from "/mc/mcp/lib/telegram-tools.ts"; } from "/mc/mcp/lib/types.ts";
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts";
const tgClient = createTgClient(TOKEN); const tgClient = createTgClient(TOKEN);
const toolCtx: ToolCtx = { tg: tgClient, db }; const mcRuntime = createMcRuntime({
const toolsByName = new Map(telegramTools.map((t) => [t.name, t])); mcDir: MC_DIR,
const geminiFunctionDeclarations = telegramTools.map(toolToGeminiFunction); 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 = type GeminiPart =
| { text: string } | { 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 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).", "- 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:", "What you can do — call tools to act:",
"- 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.", "- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).",
"- Use a tool when the user wants you to *do* something in the chat (e.g. 'run a poll', 'react with fire', 'pin that').", "- 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).",
"- 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.", "- 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:", "Rules:",
"- 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.",
"- Never reveal API keys, tokens, or other secrets.", "- 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:", "Context for this turn:",
`- Chat type: ${chatType}`, `- Chat type: ${chatType}`,
@@ -985,9 +999,6 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string):
generationConfig: { generationConfig: {
temperature: 0.6, temperature: 0.6,
maxOutputTokens: 600, 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), signal: AbortSignal.timeout(25_000),
@@ -1005,10 +1016,9 @@ async function callGeminiOnce(contents: GeminiContent[], systemPrompt: string):
type TurnResult = { text: string | null; ranTool: boolean }; type TurnResult = { text: string | null; ranTool: boolean };
async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string): Promise<TurnResult> { async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], userText: string, callerRole: Role): Promise<TurnResult> {
const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }]; const contents: GeminiContent[] = [...history, { role: "user", parts: [{ text: userText }] }];
let ranTool = false; let ranTool = false;
// Cap tool-use iterations so a confused model can't loop forever.
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
const data = await callGeminiOnce(contents, systemPrompt); const data = await callGeminiOnce(contents, systemPrompt);
if (!data) return { text: null, ranTool }; 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<string, unknown> } } => const functionCalls = parts.filter((p): p is { functionCall: { name: string; args: Record<string, unknown> } } =>
"functionCall" in p && !!p.functionCall); "functionCall" in p && !!p.functionCall);
if (functionCalls.length === 0) { if (functionCalls.length === 0) {
// Drop any thought parts (`thought: true`) if they slipped through.
const text = parts const text = parts
.filter((p) => "text" in p && !(p as { thought?: boolean }).thought) .filter((p) => "text" in p && !(p as { thought?: boolean }).thought)
.map((p) => (p as { text: string }).text) .map((p) => (p as { text: string }).text)
@@ -1034,6 +1043,8 @@ async function runRedstoneTurn(systemPrompt: string, history: GeminiContent[], u
let response: Record<string, unknown>; let response: Record<string, unknown>;
if (!tool) { if (!tool) {
response = { error: `unknown tool '${fc.functionCall.name}'` }; 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 { } else {
try { try {
const result = await tool.handler(fc.functionCall.args, toolCtx); const result = await tool.handler(fc.functionCall.args, toolCtx);
@@ -1055,8 +1066,18 @@ bot.on("message:text", async (ctx) => {
if (!me) return; if (!me) return;
if (!botAddressed(ctx, me.id, me.username)) return; if (!botAddressed(ctx, me.id, me.username)) return;
if (!GEMINI_API_KEY) 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 raw = ctx.message.text ?? "";
const text = stripMention(raw, me.username); 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 latest = Q_latestMsgId.get(chatId)?.id ?? 0;
const history = recentTurns(chatId, latest, 6); 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 (!reply) {
// If Redstone took an action (e.g. sent a poll) and didn't add commentary, // 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. // staying silent is correct. Only warn-silently when nothing happened.

265
mcp/lib/minecraft-tools.ts Normal file
View File

@@ -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<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: "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 (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 };
},
}),
];

View File

@@ -1,52 +1,7 @@
// Shared Telegram-action toolkit. // Telegram tool catalog. Shared types + helpers live in ./types.ts.
// 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.
import { z } from "zod"; import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema"; import { tool, type Tool } from "./types.ts";
import type { Database } from "bun:sqlite"; export { createTgClient, toolToGeminiFunction, type ToolCtx, type Tool, type TgClient } from "./types.ts";
// ---- Telegram HTTP client ----
export type TgClient = {
call: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
};
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<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
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<Params extends z.ZodRawShape = z.ZodRawShape> = {
name: string;
title: string;
description: string;
parameters: Params;
handler: (args: z.infer<z.ZodObject<Params>>, ctx: ToolCtx) => Promise<unknown>;
};
// ---- Shared sub-schemas ---- // ---- Shared sub-schemas ----
@@ -83,11 +38,6 @@ function buildKeyboard(rows: z.infer<typeof ButtonSchema>[][] | undefined) {
// ---- The tool catalog ---- // ---- The tool catalog ----
// Helper to keep the array strictly typed without losing each tool's specific param shape.
function tool<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
return t as Tool<z.ZodRawShape>;
}
export const telegramTools: Tool<z.ZodRawShape>[] = [ export const telegramTools: Tool<z.ZodRawShape>[] = [
// ---- discovery ---- // ---- discovery ----
tool({ tool({
@@ -418,44 +368,3 @@ export const telegramTools: Tool<z.ZodRawShape>[] = [
}), }),
]; ];
// ---- 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<string, unknown> = {};
for (const [k, v] of Object.entries(s as Record<string, unknown>)) {
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<string, unknown>).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<z.ZodRawShape>) {
// 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<string, unknown>;
return {
name: t.name,
description: t.description,
parameters: params.type ? params : { type: "object", properties: {} },
};
}

135
mcp/lib/types.ts Normal file
View File

@@ -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: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
};
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<T>(method: string, params: Record<string, unknown> = {}): Promise<T> {
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<ShResult>;
compose: (...args: string[]) => Promise<ShResult>;
containerRunning: (name: string) => Promise<boolean>;
};
export function createMcRuntime(config: McConfig): McRuntime {
async function sh(cmd: string[]): Promise<ShResult> {
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<Params extends z.ZodRawShape = z.ZodRawShape> = {
name: string;
title: string;
description: string;
parameters: Params;
handler: (args: z.infer<z.ZodObject<Params>>, ctx: ToolCtx) => Promise<unknown>;
/** 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<P extends z.ZodRawShape>(t: Tool<P>): Tool<z.ZodRawShape> {
return t as Tool<z.ZodRawShape>;
}
// ---- 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<string, unknown> = {};
for (const [k, v] of Object.entries(s as Record<string, unknown>)) {
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<string, unknown>).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<z.ZodRawShape>) {
const schema = zodToJsonSchema(z.object(t.parameters), {
$refStrategy: "none",
target: "openApi3",
});
const params = cleanForGemini(schema) as Record<string, unknown>;
return {
name: t.name,
description: t.description,
parameters: params.type ? params : { type: "object", properties: {} },
};
}

View File

@@ -1,12 +1,15 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { Database } from "bun:sqlite"; import { Database } from "bun:sqlite";
import { resolve, dirname } from "node:path"; import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { readFileSync } from "node:fs"; 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 HERE = dirname(fileURLToPath(import.meta.url));
const MC_DIR = process.env.MC_DIR ?? resolve(HERE, ".."); const MC_DIR = process.env.MC_DIR ?? resolve(HERE, "..");
const COMPOSE_PROJECT = process.env.COMPOSE_PROJECT_NAME ?? "minecraft"; 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 BACKUP_SH = process.env.BACKUP_SH ?? `${MC_DIR}/backup.sh`;
const DB_PATH = process.env.DB_PATH ?? `${MC_DIR}/bot/data/users.db`; 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); const db = new Database(DB_PATH);
db.exec("PRAGMA journal_mode = WAL"); 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 = (() => { const TG_TOKEN = (() => {
if (process.env.BOT_TOKEN) return process.env.BOT_TOKEN.trim(); if (process.env.BOT_TOKEN) return process.env.BOT_TOKEN.trim();
const file = process.env.BOT_TOKEN_FILE ?? `${MC_DIR}/bot.token`; const file = process.env.BOT_TOKEN_FILE ?? `${MC_DIR}/bot.token`;
try { return readFileSync(file, "utf8").trim(); } catch { return ""; } try { return readFileSync(file, "utf8").trim(); } catch { return ""; }
})(); })();
const tgCtx: ToolCtx = { const ctx: ToolCtx = {
tg: TG_TOKEN ? createTgClient(TG_TOKEN) : { tg: TG_TOKEN ? createTgClient(TG_TOKEN) : {
call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); }, call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); },
}, },
db, 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( server.registerTool(
t.name, t.name,
{ title: t.title, description: t.description, inputSchema: t.parameters }, { title: t.title, description: t.description, inputSchema: t.parameters },
async (args) => { async (args) => {
try { try {
const result = await t.handler(args as Record<string, unknown>, tgCtx); const result = await t.handler(args as Record<string, unknown>, ctx);
return json(result); return json(result);
} catch (e) { } catch (e) {
return fail(`${t.name}: ${(e as Error).message}`); return fail(`${t.name}: ${(e as Error).message}`);
@@ -418,6 +68,4 @@ for (const t of telegramTools) {
); );
} }
// ---------- start ----------
await server.connect(new StdioServerTransport()); await server.connect(new StdioServerTransport());