add MCP server and Redstone (Gemini) AI assistant
Drops two new capabilities on top of the existing Telegram bot + Minecraft stack, both built around a shared toolkit so there's one source of truth for the actions either side can take. mcp/ — a Bun-native MCP server exposing 38 tools to MCP clients (Claude Code, etc.): server lifecycle, rcon, backup, player/db CRUD, plus 23 Telegram Bot API methods (messaging, reactions, polls, dice, photos, stickers, pins, forum topics, chat config). Runs over stdio. mcp/lib/telegram-tools.ts — the Telegram tool catalog as Zod-typed handlers. Imported by both mcp/server.ts (registers each as an MCP tool) and bot/bot.ts (exposes each as a Gemini function declaration), so adding a tool in one place lights it up everywhere. bot/bot.ts — replaces the silent-on-unknown-text behaviour with Redstone, an in-bot persona driven by gemini-2.5-flash-lite with native function calling. In DMs it always responds; in groups only when @-mentioned or replied to. The tool-use loop (max 4 rounds) lets it decide to send a poll, react with an emoji, roll dice, etc. via the shared handlers rather than just text. Thinking budget zeroed and system prompt locked down so the model doesn't leak its reasoning into replies. docker-compose.yml — adds google_key as a docker secret and passes GEMINI_API_KEY_FILE + GEMINI_MODEL to the bot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
mcp/.gitignore
vendored
Normal file
2
mcp/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
bun.lockb
|
||||
461
mcp/lib/telegram-tools.ts
Normal file
461
mcp/lib/telegram-tools.ts
Normal file
@@ -0,0 +1,461 @@
|
||||
// Shared Telegram-action toolkit.
|
||||
// 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 { 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 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 ----
|
||||
|
||||
const chatField = z.union([z.number().int(), z.string().min(1)])
|
||||
.describe("Telegram chat id (integer) OR a public @username string.");
|
||||
|
||||
const ParseMode = z.enum(["HTML", "MarkdownV2", "Markdown"]).optional();
|
||||
|
||||
const ButtonSchema = z.object({
|
||||
text: z.string().min(1).max(64),
|
||||
url: z.string().url().optional(),
|
||||
callback_data: z.string().max(64).optional(),
|
||||
copy_text: z.string().max(256).optional(),
|
||||
switch_inline_query: z.string().optional(),
|
||||
switch_inline_query_current_chat: z.string().optional(),
|
||||
});
|
||||
|
||||
function buildKeyboard(rows: z.infer<typeof ButtonSchema>[][] | undefined) {
|
||||
if (!rows || rows.length === 0) return undefined;
|
||||
return {
|
||||
inline_keyboard: rows.map((row) =>
|
||||
row.map((b) => {
|
||||
const out: Record<string, unknown> = { text: b.text };
|
||||
if (b.url) out.url = b.url;
|
||||
if (b.callback_data) out.callback_data = b.callback_data;
|
||||
if (b.copy_text) out.copy_text = { text: b.copy_text };
|
||||
if (b.switch_inline_query !== undefined) out.switch_inline_query = b.switch_inline_query;
|
||||
if (b.switch_inline_query_current_chat !== undefined) out.switch_inline_query_current_chat = b.switch_inline_query_current_chat;
|
||||
return out;
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ---- 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>[] = [
|
||||
// ---- discovery ----
|
||||
tool({
|
||||
name: "tg_me",
|
||||
title: "Bot identity",
|
||||
description: "Return basic info about the bot — id, username, name, capability flags.",
|
||||
parameters: {},
|
||||
handler: async (_a, { tg }) => tg.call("getMe"),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_resolve_chat",
|
||||
title: "Resolve a user to a DM chat id",
|
||||
description: "Look up a user in users.db and return their telegram_id (== their DM chat_id). Provide exactly one of telegram_id, minecraft_name, 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 n = [telegram_id, minecraft_name, telegram_username].filter((v) => v !== undefined).length;
|
||||
if (n !== 1) throw new Error("Provide exactly one of telegram_id, minecraft_name, telegram_username.");
|
||||
const sql = "SELECT telegram_id, name, role, minecraft_name, username, status 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 { telegram_id: number } | undefined;
|
||||
if (!row) throw new Error("Not found in users table.");
|
||||
return { chat_id: row.telegram_id, user: row };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "tg_known_chats",
|
||||
title: "Known chats",
|
||||
description: "List chat_ids the bot has ever sent or received a message in — find group chat_ids here.",
|
||||
parameters: {},
|
||||
handler: async (_a, { db }) => ({
|
||||
chats: db.prepare(
|
||||
"SELECT chat_id, COUNT(*) AS n, MAX(created_at) AS last_at FROM messages GROUP BY chat_id ORDER BY last_at DESC",
|
||||
).all(),
|
||||
}),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_get_chat",
|
||||
title: "Get chat info",
|
||||
description: "Telegram getChat — full info about a chat (type, title, description, photo, member count for groups).",
|
||||
parameters: { chat: chatField },
|
||||
handler: async ({ chat }, { tg }) => tg.call("getChat", { chat_id: chat }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_get_chat_admins",
|
||||
title: "List chat admins",
|
||||
description: "Telegram getChatAdministrators — list admin members of a group/supergroup/channel.",
|
||||
parameters: { chat: chatField },
|
||||
handler: async ({ chat }, { tg }) => tg.call("getChatAdministrators", { chat_id: chat }),
|
||||
}),
|
||||
|
||||
// ---- messaging ----
|
||||
tool({
|
||||
name: "tg_send_message",
|
||||
title: "Send a text message",
|
||||
description: "sendMessage. Supports HTML/Markdown, quote-replies, silent sends, link-preview suppression, and inline keyboards.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
text: z.string().min(1).max(4096),
|
||||
parse_mode: ParseMode,
|
||||
reply_to_message_id: z.number().int().optional(),
|
||||
disable_notification: z.boolean().optional(),
|
||||
disable_link_preview: z.boolean().optional(),
|
||||
protect_content: z.boolean().optional(),
|
||||
buttons: z.array(z.array(ButtonSchema)).optional(),
|
||||
message_thread_id: z.number().int().optional(),
|
||||
},
|
||||
handler: async (a, { tg }) => {
|
||||
const p: Record<string, unknown> = {
|
||||
chat_id: a.chat, text: a.text, parse_mode: a.parse_mode,
|
||||
disable_notification: a.disable_notification, protect_content: a.protect_content,
|
||||
message_thread_id: a.message_thread_id,
|
||||
};
|
||||
if (a.reply_to_message_id !== undefined) p.reply_parameters = { message_id: a.reply_to_message_id };
|
||||
if (a.disable_link_preview) p.link_preview_options = { is_disabled: true };
|
||||
const kb = buildKeyboard(a.buttons);
|
||||
if (kb) p.reply_markup = kb;
|
||||
return tg.call("sendMessage", p);
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "tg_edit_message",
|
||||
title: "Edit a message",
|
||||
description: "editMessageText — replace text + keyboard of a previously-sent message.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
message_id: z.number().int(),
|
||||
text: z.string().min(1).max(4096),
|
||||
parse_mode: ParseMode,
|
||||
disable_link_preview: z.boolean().optional(),
|
||||
buttons: z.array(z.array(ButtonSchema)).optional(),
|
||||
},
|
||||
handler: async (a, { tg }) => {
|
||||
const p: Record<string, unknown> = { chat_id: a.chat, message_id: a.message_id, text: a.text, parse_mode: a.parse_mode };
|
||||
if (a.disable_link_preview) p.link_preview_options = { is_disabled: true };
|
||||
const kb = buildKeyboard(a.buttons);
|
||||
if (kb) p.reply_markup = kb;
|
||||
return tg.call("editMessageText", p);
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "tg_delete_message",
|
||||
title: "Delete a message",
|
||||
description: "deleteMessage. Bots can always delete their own messages; others require admin rights.",
|
||||
parameters: { chat: chatField, message_id: z.number().int() },
|
||||
handler: async ({ chat, message_id }, { tg }) => ({ deleted: await tg.call("deleteMessage", { chat_id: chat, message_id }) }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_pin_message",
|
||||
title: "Pin a message",
|
||||
description: "pinChatMessage. Needs admin rights in groups.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
message_id: z.number().int(),
|
||||
disable_notification: z.boolean().optional(),
|
||||
},
|
||||
handler: async ({ chat, message_id, disable_notification }, { tg }) =>
|
||||
({ pinned: await tg.call("pinChatMessage", { chat_id: chat, message_id, disable_notification }) }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_unpin_message",
|
||||
title: "Unpin a message",
|
||||
description: "unpinChatMessage. Omit message_id to unpin the most recent pin.",
|
||||
parameters: { chat: chatField, message_id: z.number().int().optional() },
|
||||
handler: async ({ chat, message_id }, { tg }) =>
|
||||
({ unpinned: await tg.call("unpinChatMessage", { chat_id: chat, message_id }) }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_forward_message",
|
||||
title: "Forward a message",
|
||||
description: "forwardMessage — relay a message between chats, preserving original author attribution.",
|
||||
parameters: {
|
||||
from_chat: chatField,
|
||||
to_chat: chatField,
|
||||
message_id: z.number().int(),
|
||||
disable_notification: z.boolean().optional(),
|
||||
},
|
||||
handler: async ({ from_chat, to_chat, message_id, disable_notification }, { tg }) =>
|
||||
tg.call("forwardMessage", { from_chat_id: from_chat, chat_id: to_chat, message_id, disable_notification }),
|
||||
}),
|
||||
|
||||
// ---- interactivity ----
|
||||
tool({
|
||||
name: "tg_chat_action",
|
||||
title: "Show a busy indicator",
|
||||
description: "sendChatAction — show 'typing…' or similar for ~5s. Re-call to extend.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
action: z.enum([
|
||||
"typing", "upload_photo", "record_video", "upload_video", "record_voice", "upload_voice",
|
||||
"upload_document", "choose_sticker", "find_location", "record_video_note", "upload_video_note",
|
||||
]),
|
||||
message_thread_id: z.number().int().optional(),
|
||||
},
|
||||
handler: async ({ chat, action, message_thread_id }, { tg }) =>
|
||||
({ shown: await tg.call("sendChatAction", { chat_id: chat, action, message_thread_id }) }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_react",
|
||||
title: "React to a message",
|
||||
description: "setMessageReaction — attach emoji reactions or clear them by passing emojis=[].",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
message_id: z.number().int(),
|
||||
emojis: z.array(z.string().min(1).max(8)).max(11)
|
||||
.describe("Emoji to apply. Pass [] to clear all reactions."),
|
||||
is_big: z.boolean().optional(),
|
||||
},
|
||||
handler: async ({ chat, message_id, emojis, is_big }, { tg }) => {
|
||||
const reaction = emojis.map((e) => ({ type: "emoji" as const, emoji: e }));
|
||||
return { reacted: await tg.call("setMessageReaction", { chat_id: chat, message_id, reaction, is_big }) };
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "tg_send_poll",
|
||||
title: "Send a poll",
|
||||
description: "sendPoll — regular or quiz-style. For quiz, set type='quiz' and correct_option_id (0-based).",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
question: z.string().min(1).max(300),
|
||||
options: z.array(z.string().min(1).max(100)).min(2).max(12),
|
||||
type: z.enum(["regular", "quiz"]).default("regular"),
|
||||
is_anonymous: z.boolean().default(true),
|
||||
allows_multiple_answers: z.boolean().optional(),
|
||||
correct_option_id: z.number().int().min(0).optional(),
|
||||
explanation: z.string().max(200).optional(),
|
||||
open_period: z.number().int().min(5).max(600).optional(),
|
||||
},
|
||||
handler: async (a, { tg }) => tg.call("sendPoll", {
|
||||
chat_id: a.chat, question: a.question,
|
||||
options: a.options.map((text) => ({ text })),
|
||||
type: a.type, is_anonymous: a.is_anonymous,
|
||||
allows_multiple_answers: a.allows_multiple_answers,
|
||||
correct_option_id: a.correct_option_id,
|
||||
explanation: a.explanation, open_period: a.open_period,
|
||||
}),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_send_dice",
|
||||
title: "Send a dice / slot / dart",
|
||||
description: "sendDice — animated random emoji. Telegram decides the outcome; check result.dice.value.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
emoji: z.enum(["🎲", "🎯", "🏀", "⚽", "🎳", "🎰"]).default("🎲"),
|
||||
},
|
||||
handler: async ({ chat, emoji }, { tg }) => tg.call("sendDice", { chat_id: chat, emoji }),
|
||||
}),
|
||||
|
||||
// ---- rich content ----
|
||||
tool({
|
||||
name: "tg_send_photo",
|
||||
title: "Send a photo",
|
||||
description: "sendPhoto — pass an HTTPS URL Telegram can fetch, or a previously-uploaded file_id.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
photo: z.string().min(1).describe("HTTPS URL or Telegram file_id."),
|
||||
caption: z.string().max(1024).optional(),
|
||||
parse_mode: ParseMode,
|
||||
has_spoiler: z.boolean().optional(),
|
||||
disable_notification: z.boolean().optional(),
|
||||
reply_to_message_id: z.number().int().optional(),
|
||||
buttons: z.array(z.array(ButtonSchema)).optional(),
|
||||
},
|
||||
handler: async (a, { tg }) => {
|
||||
const p: Record<string, unknown> = {
|
||||
chat_id: a.chat, photo: a.photo, caption: a.caption,
|
||||
parse_mode: a.parse_mode, has_spoiler: a.has_spoiler, disable_notification: a.disable_notification,
|
||||
};
|
||||
if (a.reply_to_message_id !== undefined) p.reply_parameters = { message_id: a.reply_to_message_id };
|
||||
const kb = buildKeyboard(a.buttons);
|
||||
if (kb) p.reply_markup = kb;
|
||||
return tg.call("sendPhoto", p);
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "tg_send_sticker",
|
||||
title: "Send a sticker",
|
||||
description: "sendSticker — pass a sticker file_id (see tg_stickers_known).",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
file_id: z.string().min(1),
|
||||
reply_to_message_id: z.number().int().optional(),
|
||||
disable_notification: z.boolean().optional(),
|
||||
},
|
||||
handler: async ({ chat, file_id, reply_to_message_id, disable_notification }, { tg }) => {
|
||||
const p: Record<string, unknown> = { chat_id: chat, sticker: file_id, disable_notification };
|
||||
if (reply_to_message_id !== undefined) p.reply_parameters = { message_id: reply_to_message_id };
|
||||
return tg.call("sendSticker", p);
|
||||
},
|
||||
}),
|
||||
tool({
|
||||
name: "tg_stickers_known",
|
||||
title: "List bot-bound stickers",
|
||||
description: "Return event→file_id mappings the bot stored via /setsticker.",
|
||||
parameters: {},
|
||||
handler: async (_a, { db }) => {
|
||||
const rows = db.prepare("SELECT event, file_id FROM stickers ORDER BY event").all();
|
||||
return { count: rows.length, stickers: rows };
|
||||
},
|
||||
}),
|
||||
|
||||
// ---- forum topics ----
|
||||
tool({
|
||||
name: "tg_create_topic",
|
||||
title: "Create a forum topic",
|
||||
description: "createForumTopic — only in forum-mode supergroups. Returns message_thread_id.",
|
||||
parameters: {
|
||||
chat: chatField,
|
||||
name: z.string().min(1).max(128),
|
||||
icon_color: z.number().int().optional(),
|
||||
},
|
||||
handler: async ({ chat, name, icon_color }, { tg }) =>
|
||||
tg.call("createForumTopic", { chat_id: chat, name, icon_color }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_close_topic",
|
||||
title: "Close a forum topic",
|
||||
description: "closeForumTopic.",
|
||||
parameters: { chat: chatField, message_thread_id: z.number().int() },
|
||||
handler: async ({ chat, message_thread_id }, { tg }) =>
|
||||
({ closed: await tg.call("closeForumTopic", { chat_id: chat, message_thread_id }) }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_reopen_topic",
|
||||
title: "Reopen a forum topic",
|
||||
description: "reopenForumTopic.",
|
||||
parameters: { chat: chatField, message_thread_id: z.number().int() },
|
||||
handler: async ({ chat, message_thread_id }, { tg }) =>
|
||||
({ reopened: await tg.call("reopenForumTopic", { chat_id: chat, message_thread_id }) }),
|
||||
}),
|
||||
|
||||
// ---- bot config ----
|
||||
tool({
|
||||
name: "tg_set_chat_title",
|
||||
title: "Set a chat title",
|
||||
description: "setChatTitle — requires bot admin rights.",
|
||||
parameters: { chat: chatField, title: z.string().min(1).max(128) },
|
||||
handler: async ({ chat, title }, { tg }) =>
|
||||
({ updated: await tg.call("setChatTitle", { chat_id: chat, title }) }),
|
||||
}),
|
||||
tool({
|
||||
name: "tg_set_my_commands",
|
||||
title: "Set the bot's slash-command menu",
|
||||
description: "setMyCommands. Scope=default for the global menu; pass `chat` for a single chat.",
|
||||
parameters: {
|
||||
commands: z.array(z.object({
|
||||
command: z.string().min(1).max(32),
|
||||
description: z.string().min(1).max(256),
|
||||
})).min(0).max(100),
|
||||
scope: z.enum([
|
||||
"default", "all_private_chats", "all_group_chats", "all_chat_administrators",
|
||||
]).optional(),
|
||||
chat: chatField.optional(),
|
||||
language_code: z.string().optional(),
|
||||
},
|
||||
handler: async ({ commands, scope, chat, language_code }, { tg }) => {
|
||||
const p: Record<string, unknown> = { commands, language_code };
|
||||
if (chat !== undefined) p.scope = { type: scope === "all_chat_administrators" ? "chat_administrators" : "chat", chat_id: chat };
|
||||
else if (scope) p.scope = { type: scope };
|
||||
return { updated: await tg.call("setMyCommands", p) };
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
// ---- 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: {} },
|
||||
};
|
||||
}
|
||||
18
mcp/package.json
Normal file
18
mcp/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "minecraft-mcp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
423
mcp/server.ts
Normal file
423
mcp/server.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
#!/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());
|
||||
Reference in New Issue
Block a user