feat(redstone): connect minecraft.wiki via three new MCP tools

Redstone can now query the official Minecraft wiki and deliver
crafting recipe / item images straight into the chat.

New: mcp/lib/minecraft-wiki-tools.ts exports three tools, all hitting
https://minecraft.wiki/api.php (MediaWiki API, formatversion=2):

- wiki_search(query, limit?) — opensearch top results, namespace 0,
  returns { title, description, url } for each hit.
- wiki_page(title, thumb_size?) — extracts|pageimages → intro extract
  (plain text, ~600 chars) + thumbnail_url + page_url.
- wiki_page_images(title, filter?, limit?) — generator=images +
  prop=imageinfo (iiprop=url|mime|size) returns every image embedded
  on the page along with its direct https URL. The `filter`
  substring is case-insensitive against the filename — pass 'recipe'
  / 'craft' / 'grid' to surface crafting-recipe diagrams.

Bot wiring (bot/bot.ts):
- import minecraftWikiTools, merge into allTools so it flows through
  the existing OpenRouter + Gemini function/tool schemas automatically.
- Persona prompt teaches the model the canonical flow:
  wiki_search → wiki_page → wiki_page_images(filter='recipe') →
  tg_send_photo(url, caption). Composes with the already-existing
  tg_send_photo tool — no new telegram tool needed.

User-Agent on every wiki request, 15s timeout, JSON-only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:29:45 +02:00
parent 404fee3405
commit f05db39903
2 changed files with 149 additions and 1 deletions

View File

@@ -1059,6 +1059,7 @@ import {
} from "/mc/mcp/lib/types.ts";
import { telegramTools } from "/mc/mcp/lib/telegram-tools.ts";
import { minecraftTools } from "/mc/mcp/lib/minecraft-tools.ts";
import { minecraftWikiTools } from "/mc/mcp/lib/minecraft-wiki-tools.ts";
const tgClient = createTgClient(TOKEN);
const mcRuntime = createMcRuntime({
@@ -1072,7 +1073,7 @@ const mcRuntime = createMcRuntime({
backupSh: `${MC_DIR}/backup.sh`,
});
const toolCtx: ToolCtx = { tg: tgClient, db, mc: mcRuntime };
const allTools = [...telegramTools, ...minecraftTools];
const allTools = [...telegramTools, ...minecraftTools, ...minecraftWikiTools];
const toolsByName = new Map(allTools.map((t) => [t.name, t]));
// Hidden from Redstone's view: emoji→sticker is handled by post-processing
// the reply text, so the model never has to manage file_ids itself.
@@ -1202,6 +1203,7 @@ function redstonePersona(user: User, chatType: string, chatId: number, srv: stri
"- Telegram chat: send messages/polls/reactions/dice/stickers/photos, edit/pin/delete your own messages, query chat info (tg_*).",
"- 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).",
"- 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.",
"- Minecraft wiki: look things up on minecraft.wiki and surface images into the chat. Usual flow for a 'how do I craft X?' / 'what is X?' question: wiki_search to pick the exact page title → wiki_page for a one-line summary + thumbnail → wiki_page_images with filter='recipe' or 'craft' or 'grid' to find a crafting-grid image, then tg_send_photo to send the image URL straight into the chat (caption it briefly).",
"- 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.",
"",
"Stickers — drop emojis inline and the bot does the rest:",

View File

@@ -0,0 +1,146 @@
// Minecraft Wiki tool catalog. Hits https://minecraft.wiki (MediaWiki API).
// Composes well with telegram-tools.ts → after wiki_page_images returns a URL
// the model can pass it to tg_send_photo to surface a recipe / item image
// directly into the chat.
import { z } from "zod";
import { tool, type Tool } from "./types.ts";
const WIKI_BASE = "https://minecraft.wiki";
const WIKI_API = `${WIKI_BASE}/api.php`;
const UA = "Redstone-bot/1.0 (Minecraft container; contact via telegram bot)";
const TIMEOUT_MS = 15_000;
async function wikiGet<T>(params: Record<string, string | number | undefined>): Promise<T> {
const url = new URL(WIKI_API);
url.searchParams.set("format", "json");
url.searchParams.set("formatversion", "2");
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, String(v));
}
const res = await fetch(url.toString(), {
headers: { "User-Agent": UA, Accept: "application/json" },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) throw new Error(`wiki ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
function pageUrl(title: string): string {
return `${WIKI_BASE}/w/${encodeURIComponent(title.replace(/ /g, "_"))}`;
}
export const minecraftWikiTools: Tool<z.ZodRawShape>[] = [
tool({
name: "wiki_search",
title: "Search the Minecraft wiki",
description:
"Search minecraft.wiki for pages matching a query. Returns top matches with title, short description, and canonical page URL. Use this first to find the exact page title before calling wiki_page or wiki_page_images.",
parameters: {
query: z.string().min(1).describe("Search terms, e.g. 'iron pickaxe', 'crafting table', 'beacon'"),
limit: z.number().int().min(1).max(15).optional().describe("Max results (default 5)"),
},
handler: async ({ query, limit }) => {
// opensearch returns a 4-tuple: [query, titles[], descriptions[], urls[]]
type OpenSearch = [string, string[], string[], string[]];
const data = await wikiGet<OpenSearch>({
action: "opensearch",
search: query,
limit: limit ?? 5,
namespace: 0,
});
const [, titles, descriptions, urls] = data;
return {
results: titles.map((title, i) => ({
title,
description: descriptions[i] || "",
url: urls[i] || pageUrl(title),
})),
};
},
}),
tool({
name: "wiki_page",
title: "Get a Minecraft wiki page summary",
description:
"Fetch the intro extract (plain text) plus the page's thumbnail image URL and canonical URL. Good for a one-line answer about an item, mob, or mechanic, plus a representative image to send.",
parameters: {
title: z.string().min(1).describe("Exact wiki page title (use wiki_search first if unsure)"),
thumb_size: z.number().int().min(120).max(1200).optional().describe("Thumbnail pixel width (default 600)"),
},
handler: async ({ title, thumb_size }) => {
type PageRes = {
query?: {
pages?: {
title: string;
missing?: boolean;
extract?: string;
thumbnail?: { source: string; width: number; height: number };
pageimage?: string;
}[];
};
};
const data = await wikiGet<PageRes>({
action: "query",
prop: "extracts|pageimages",
titles: title,
exintro: "1",
explaintext: "1",
exchars: 600,
piprop: "thumbnail|name",
pithumbsize: thumb_size ?? 600,
redirects: "1",
});
const page = data.query?.pages?.[0];
if (!page || page.missing) return { error: `no wiki page titled '${title}'` };
return {
title: page.title,
extract: page.extract ?? "",
thumbnail_url: page.thumbnail?.source ?? null,
page_url: pageUrl(page.title),
};
},
}),
tool({
name: "wiki_page_images",
title: "List images on a Minecraft wiki page (with full URLs)",
description:
"Returns every image embedded on a wiki page along with its direct https URL. Use the optional `filter` to narrow by filename substring (case-insensitive) — pass 'recipe' or 'craft' or 'grid' to surface crafting-recipe diagrams. Hand a URL from this result to tg_send_photo to deliver the image into a chat.",
parameters: {
title: z.string().min(1).describe("Exact wiki page title"),
filter: z.string().optional().describe("Case-insensitive substring filter on the filename (e.g. 'recipe', 'craft', 'grid')"),
limit: z.number().int().min(1).max(50).optional().describe("Max images to return (default 20)"),
},
handler: async ({ title, filter, limit }) => {
type ImgRes = {
query?: {
pages?: {
title: string;
imageinfo?: { url: string; mime?: string; width?: number; height?: number }[];
}[];
};
};
const data = await wikiGet<ImgRes>({
action: "query",
generator: "images",
titles: title,
prop: "imageinfo",
iiprop: "url|mime|size",
gimlimit: 50,
redirects: "1",
});
const pages = data.query?.pages ?? [];
let images = pages.map((p) => ({
filename: p.title.replace(/^File:/, ""),
url: p.imageinfo?.[0]?.url ?? "",
mime: p.imageinfo?.[0]?.mime,
})).filter((i) => i.url && (!i.mime || i.mime.startsWith("image/")));
if (filter) {
const needle = filter.toLowerCase();
images = images.filter((i) => i.filename.toLowerCase().includes(needle));
}
return { images: images.slice(0, limit ?? 20) };
},
}),
];