7 Commits

Author SHA1 Message Date
4ebbcef76b feat(redstone): add OpenRouter primary AI with Gemini fallback
Redstone now hits OpenRouter's `openrouter/free` auto-router first,
falling back to direct Gemini if the OpenRouter call fails (no key,
network error, or `:free` upstream rejected tool use). The auto-
router filters its free-model pool by the request's tool-calling
requirement, so we don't have to pin a specific free model.

- bot/bot.ts:
  - OPENROUTER_API_KEY / _FILE env (mirrors GEMINI_API_KEY_FILE)
  - OPENROUTER_MODEL defaults to "openrouter/free"
  - openRouterTools[] derived from existing geminiFunctionDeclarations
    (OpenAI-style {type:"function", function:{name,description,parameters}})
  - callOpenRouterOnce + runRedstoneTurnViaOpenRouter (parses JSON-string
    tool_calls.arguments, replies with role:"tool" + tool_call_id)
  - Existing Gemini path moved into runRedstoneTurnViaGemini
  - runRedstoneTurn dispatches OpenRouter first, Gemini on null
  - Early-return gate now passes if either key is configured

- docker-compose.yml:
  - new openrouter_token secret -> ./openrouter.token (gitignored)
  - OPENROUTER_API_KEY_FILE + OPENROUTER_MODEL env wired to the bot

- .gitignore: add openrouter.token (plus pre-existing gitea.token entry
  that was sitting uncommitted).

The key file itself is NOT committed (verified via git check-ignore).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 22:42:33 +02:00
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
7976de7da4 shape Redstone's voice: hippy dialect, name 'Redstone Steve', persistent typing, stochastic sticker swap
Persona polish that grew out of using the bot in the group.

Voice: replace the generic 'friendly assistant' personality block with
laid-back hippy dialect — 'man', 'groovy', 'good vibes', occasional
loose grammar — wrapped around the existing Minecraft-themed flavor.
Capped to one or two hippy words per reply so it doesn't read as a
caricature; still helpful first, vibe second; dials down if the user
is terse.

Name: persona is now 'Redstone Steve' — combines the mineral and the
default player. Prompt notes it also answers to plain 'Redstone' or
'Steve' so existing chat history isn't suddenly off-character.

Typing indicator: startTypingLoop() re-ticks sendChatAction every 4s
for the whole AI roundtrip (Telegram auto-clears after ~5s), wrapped
in a try/finally so the indicator clears (fades naturally) on both
success and failure paths. Previously it fired once and faded mid-call
on long requests, making the bot look stalled.

Sticker swap: only 35% of palette-emoji matches actually fire a
sticker now (STICKER_SEND_PROBABILITY = 0.35). Keeps stickers as a
treat rather than a tic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:01:44 +02:00
7cb432195a bundle several Redstone QoL fixes: /start+/stop+/clear, auto-collect stickers, emoji→sticker swap
A handful of related Telegram-bot improvements that piled up in one
working session.

Slash menu: /start now brings the whole stack up (was: panel render —
duplicated by /menu) and a new /stop takes it down. /up and /down kept
as undocumented aliases. /clear wipes the current chat's rows from the
messages table — DM-self for users, admin-only in groups — so Redstone's
recentTurns history can be reset on demand.

Sticker auto-collection: every incoming sticker is upserted into a new
sticker_library table (file_id PK, plus emoji, set_name, first_seen,
last_seen, seen_count). No /setsticker needed. The original event-keyed
stickers table is untouched and still drives the bot's own event
triggers (maybeSendSticker("up") etc).

Emoji → sticker swap (replaces the AI-driven sticker tool): instead of
listing 15+ file_ids in the system prompt and exposing tg_send_sticker
as a function call, the prompt now lists just the *emoji palette* the
library knows about. After Redstone replies, pickStickerForReply()
scans the text for the first palette-emoji that appears and fires the
matching sticker as a follow-up via ctx.replyWithSticker — without
inserting a row into the messages table, so the audit log stays
text-only and Redstone's next recentTurns isn't polluted. tg_send_sticker
and tg_stickers_known are filtered out of Redstone's geminiFunctionDeclarations
(still exposed via the MCP server for Claude / external clients).

Reliability: Gemini call timeout bumped from 25s to 45s after a real
timeout in production with the previous (too-large) sticker prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:52:00 +02:00
d22cb73a2c 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>
2026-05-11 23:19:27 +02:00
875d6fd6dd 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>
2026-05-11 22:45:41 +02:00
9ac3705d5e add telegram bot and autossh tunnel 2026-05-11 21:51:59 +02:00
14 changed files with 2618 additions and 5 deletions

24
.gitignore vendored
View File

@@ -21,3 +21,27 @@ portforward.pid
# Local Claude Code state
.claude/settings.local.json
# Telegram bot
bot.token
bot/node_modules/
bot/bun.lockb
bot/bun.lock
bot/data/
# Google Generative Language API key
google_key.txt
# Gitea API token (mounted as a docker secret into the bot for release uploads)
gitea.token
# OpenRouter API key
openrouter.token
# MCP server
mcp/node_modules/
mcp/bun.lock
# autossh service key material
ssh/spotbot_key
ssh/known_hosts

4
bot/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
bun.lockb
.git
*.log

24
bot/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM oven/bun:1-debian AS deps
WORKDIR /app
COPY package.json ./
RUN bun install --no-save
FROM oven/bun:1-debian
WORKDIR /app
# docker CLI (to talk to host docker.sock), ssh + autossh (for portforward)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl gnupg openssh-client autossh procps \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update && apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY bot.ts tsconfig.json* ./
CMD ["bun", "run", "bot.ts"]

1604
bot/bot.ts Normal file

File diff suppressed because it is too large Load Diff

18
bot/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "minecraft-telegram-bot",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "bun run bot.ts"
},
"dependencies": {
"@grammyjs/auto-retry": "^2.0.2",
"@grammyjs/stream": "^1.0.1",
"grammy": "^1.30.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.5.0"
}
}

View File

@@ -1,5 +1,5 @@
#Minecraft server properties
#Mon Apr 13 16:37:31 UTC 2026
#Thu May 07 17:17:47 UTC 2026
accepts-transfers=false
allow-flight=false
broadcast-console-to-ops=true
@@ -48,7 +48,7 @@ player-idle-timeout=0
prevent-proxy-connections=false
query.port=25565
rate-limit=0
rcon.password=1539e45bbdd3cee797c79a51
rcon.password=95a08280a713c687fb8efae0
rcon.port=25575
region-file-compression=deflate
require-resource-pack=false

View File

@@ -1 +1 @@
[{"uuid":"e84e6487-5f30-3493-b553-5359d8267873","name":"Hertz333","expiresOn":"2026-05-13 19:09:05 +0000"},{"uuid":"c7691bb7-41cd-3372-82cd-ef0e9b9033bf","name":"codehac","expiresOn":"2026-05-13 18:39:42 +0000"},{"uuid":"aa22ac3b-3014-306f-a390-8acb9ecd602a","name":"Goudvis_1","expiresOn":"2026-05-13 18:32:04 +0000"},{"uuid":"dd86f659-b0fd-3d49-96ea-38c6923a9049","name":"Wietsche","expiresOn":"2026-05-13 16:38:08 +0000"}]
[{"uuid":"e84e6487-5f30-3493-b553-5359d8267873","name":"Hertz333","expiresOn":"2026-06-10 14:31:54 +0000"},{"uuid":"c7691bb7-41cd-3372-82cd-ef0e9b9033bf","name":"codehac","expiresOn":"2026-06-09 19:43:18 +0000"},{"uuid":"dd86f659-b0fd-3d49-96ea-38c6923a9049","name":"Wietsche","expiresOn":"2026-06-04 16:15:44 +0000"},{"uuid":"aa22ac3b-3014-306f-a390-8acb9ecd602a","name":"Goudvis_1","expiresOn":"2026-05-13 18:32:04 +0000"}]

View File

@@ -2,8 +2,8 @@ services:
minecraft:
image: itzg/minecraft-server:latest
container_name: minecraft
ports:
- "25565:25565"
expose:
- "25565"
environment:
EULA: "TRUE"
ONLINE_MODE: "FALSE"
@@ -15,3 +15,76 @@ services:
restart: unless-stopped
stdin_open: true
tty: true
autossh:
image: jnovack/autossh:2.0.1
container_name: minecraft-autossh
restart: unless-stopped
depends_on:
- minecraft
environment:
SSH_REMOTE_USER: root
SSH_REMOTE_HOST: spotbotdev1.dedicated.co.za
SSH_REMOTE_PORT: "22"
SSH_BIND_IP: "0.0.0.0" # bind on the *remote* (spotbot) public iface
SSH_TUNNEL_PORT: "25565" # spotbot:25565 (public side) …
SSH_TARGET_HOST: "minecraft" # → service name on the compose network …
SSH_TARGET_PORT: "25565" # → minecraft container's :25565
SSH_MODE: "-R"
SSH_KEY_FILE: "/id_rsa"
SSH_KNOWN_HOSTS_FILE: "/known_hosts"
SSH_SERVER_ALIVE_INTERVAL: "20"
SSH_SERVER_ALIVE_COUNT_MAX: "3"
AUTOSSH_GATETIME: "0"
AUTOSSH_PORT: "0" # disable monitoring port; rely on ServerAlive
AUTOSSH_LOGLEVEL: "1"
volumes:
- ./ssh/spotbot_key:/id_rsa:ro
- ./ssh/known_hosts:/known_hosts:ro
bot:
build: ./bot
image: minecraft-telegram-bot:latest
container_name: minecraft-bot
restart: unless-stopped
environment:
BOT_TOKEN_FILE: /run/secrets/bot_token
GEMINI_API_KEY_FILE: /run/secrets/google_key
GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.5-flash-lite}"
# OpenRouter primary; Gemini stays as fallback. `openrouter/free` auto-
# selects a free model that supports tool calling for this request.
OPENROUTER_API_KEY_FILE: /run/secrets/openrouter_token
OPENROUTER_MODEL: "${OPENROUTER_MODEL:-openrouter/free}"
MC_DIR: /mc
DB_PATH: /data/users.db
ADMIN_BOOTSTRAP: "${ADMIN_BOOTSTRAP:-1311866578:Paul}"
COMPOSE_HOST_DIR: "${COMPOSE_HOST_DIR:-/home/paul/containers/minecraft}"
TZ: Africa/Johannesburg
# Gitea release upload from inside the container. The token is mounted
# as a docker secret (see `secrets:` below + ./gitea.token, gitignored);
# backup.sh reads it via $GITEA_TOKEN_FILE. GITEA_API_BASE / GITEA_REPO
# let backup.sh skip its `git config remote.origin.url` parse — needed
# because the host's origin URL points at localhost:3000, which the bot
# container can't reach.
GITEA_TOKEN_FILE: /run/secrets/gitea_token
GITEA_API_BASE: "${GITEA_API_BASE:-https://thinkstation-web.spot-bot.co.za/api/v1}"
GITEA_REPO: "${GITEA_REPO:-paul/Minecraft-Server}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- .:/mc
- ./bot/data:/data
secrets:
- bot_token
- google_key
- gitea_token
- openrouter_token
secrets:
bot_token:
file: ./bot.token
google_key:
file: ./google_key.txt
gitea_token:
file: ./gitea.token
openrouter_token:
file: ./openrouter.token

2
mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules/
bun.lockb

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

@@ -0,0 +1,270 @@
// 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 };
},
}),
];

370
mcp/lib/telegram-tools.ts Normal file
View File

@@ -0,0 +1,370 @@
// Telegram tool catalog. Shared types + helpers live in ./types.ts.
import { z } from "zod";
import { tool, type Tool } from "./types.ts";
export { createTgClient, toolToGeminiFunction, type ToolCtx, type Tool, type TgClient } from "./types.ts";
// ---- 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 ----
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) };
},
}),
];

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: {} },
};
}

18
mcp/package.json Normal file
View 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"
}
}

71
mcp/server.ts Normal file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bun
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Database } from "bun:sqlite";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
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 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 db = new Database(DB_PATH);
db.exec("PRAGMA journal_mode = WAL");
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 ctx: ToolCtx = {
tg: TG_TOKEN ? createTgClient(TG_TOKEN) : {
call: async () => { throw new Error("No bot token (set BOT_TOKEN or BOT_TOKEN_FILE)."); },
},
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,
}),
};
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(
t.name,
{ title: t.title, description: t.description, inputSchema: t.parameters },
async (args) => {
try {
const result = await t.handler(args as Record<string, unknown>, ctx);
return json(result);
} catch (e) {
return fail(`${t.name}: ${(e as Error).message}`);
}
},
);
}
await server.connect(new StdioServerTransport());