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>
2026-05-13 23:29:45 +02:00
// 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" ;
feat(redstone): wiki_recipe_image — composite real recipe PNG and upload
Previous wiki_recipe only returned the page thumbnail, so "send me the
recipe image" delivered a picture of the item, not the recipe itself.
minecraft.wiki renders recipes as live HTML grids, not standalone PNGs,
so we now generate the image ourselves.
mcp/lib/minecraft-wiki-tools.ts:
- parseCraftingGrid(html): pulls the 3×3 input grid + result item URL
from <span class="mcui mcui-Crafting_Table">. Each <span class="invslot">
is either empty (chunk starts with </span>) or has an <img src="/images/
Invicon_X.png?h">. Reading-order chunks → grid; first non-null after
the 9th input slot → result.
- composeRecipePng(grid, result): fetches each unique sprite, resizes
to 64×64 via sharp (nearest-neighbour to keep pixel art crisp),
composites onto a #8b8b8b inventory-style background with #6f6f6f
slot squares + dark borders, places an SVG-rendered "→" arrow,
then the result cell next to it. PNG buffer returned.
- New tool wiki_recipe_image(title, chat, caption?) that runs the
pipeline end-to-end and uploads via tg.sendPhotoBytes — one tool
call, image already in the chat when it returns.
mcp/lib/types.ts:
- TgClient gains sendPhotoBytes({ chat_id, bytes, filename?, caption?,
parse_mode?, reply_to_message_id? }). Multipart/form-data POST to
sendPhoto, since Telegram's URL-mode can only fetch public URLs and
the composited PNG only lives in memory. 30s timeout (vs the 20s on
the JSON call) because uploads are bigger.
mcp/package.json + bot/package.json: add sharp ^0.33.5. mcp/ pulls it
in too so /mc/mcp/lib resolves it at the bot's runtime (the bot's
node_modules is at /app while the lib files live at /mc/mcp/lib).
Persona prompt: wiki_recipe_image is now the FIRST call for any
"recipe" / "send me the recipe image" request. Single tool turn ends
with the photo already in the chat. wiki_recipe stays as a fallback
when the page has no 3×3 crafting widget (smelting / brewing / smithing
use different mcui classes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:54:20 +02:00
import sharp from "sharp" ;
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>
2026-05-13 23:29:45 +02:00
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 , "_" ) ) } ` ;
}
feat(redstone): wiki_recipe_image — composite real recipe PNG and upload
Previous wiki_recipe only returned the page thumbnail, so "send me the
recipe image" delivered a picture of the item, not the recipe itself.
minecraft.wiki renders recipes as live HTML grids, not standalone PNGs,
so we now generate the image ourselves.
mcp/lib/minecraft-wiki-tools.ts:
- parseCraftingGrid(html): pulls the 3×3 input grid + result item URL
from <span class="mcui mcui-Crafting_Table">. Each <span class="invslot">
is either empty (chunk starts with </span>) or has an <img src="/images/
Invicon_X.png?h">. Reading-order chunks → grid; first non-null after
the 9th input slot → result.
- composeRecipePng(grid, result): fetches each unique sprite, resizes
to 64×64 via sharp (nearest-neighbour to keep pixel art crisp),
composites onto a #8b8b8b inventory-style background with #6f6f6f
slot squares + dark borders, places an SVG-rendered "→" arrow,
then the result cell next to it. PNG buffer returned.
- New tool wiki_recipe_image(title, chat, caption?) that runs the
pipeline end-to-end and uploads via tg.sendPhotoBytes — one tool
call, image already in the chat when it returns.
mcp/lib/types.ts:
- TgClient gains sendPhotoBytes({ chat_id, bytes, filename?, caption?,
parse_mode?, reply_to_message_id? }). Multipart/form-data POST to
sendPhoto, since Telegram's URL-mode can only fetch public URLs and
the composited PNG only lives in memory. 30s timeout (vs the 20s on
the JSON call) because uploads are bigger.
mcp/package.json + bot/package.json: add sharp ^0.33.5. mcp/ pulls it
in too so /mc/mcp/lib resolves it at the bot's runtime (the bot's
node_modules is at /app while the lib files live at /mc/mcp/lib).
Persona prompt: wiki_recipe_image is now the FIRST call for any
"recipe" / "send me the recipe image" request. Single tool turn ends
with the photo already in the chat. wiki_recipe stays as a fallback
when the page has no 3×3 crafting widget (smelting / brewing / smithing
use different mcui classes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:54:20 +02:00
function absolutize ( url : string ) : string {
if ( url . startsWith ( "//" ) ) return ` https: ${ url } ` ;
if ( url . startsWith ( "/" ) ) return ` ${ WIKI_BASE } ${ url } ` ;
return url ;
}
// Parse the rendered HTML of a Minecraft wiki page and pull out:
// - the 3× 3 input grid as [[url|null, …], …] (9 slots, in reading order)
// - the result item URL (the contents of <span class="mcui-output">)
// Returns null when the page has no `mcui-Crafting_Table` block (uncraftable,
// or only smelting / brewing / smithing recipes, which use other mcui classes).
function parseCraftingGrid ( html : string ) : { grid : ( string | null ) [ ] [ ] ; result : string | null } | null {
const tableMatch = html . match ( /<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/ ) ;
if ( ! tableMatch ) return null ;
const tableHtml = tableMatch [ 1 ] ;
// The crafting widget: <span class="mcui mcui-Crafting_Table …"> … </span>. Find the input + output sections.
const widget = tableHtml . match ( /<span class="mcui mcui-Crafting[^"]*"[^>]*>([\s\S]*?)<\/span>\s*<\/div>/ ) ;
if ( ! widget ) return null ;
const w = widget [ 1 ] ;
// Each slot starts with `<span class="invslot">`. Splitting on that marker gives us one chunk per slot
// (the first chunk is whatever preceded the first invslot — discarded). An empty slot's chunk starts
// with `</span>` immediately; a filled slot's chunk contains an <img src="..."> first.
const chunks = w . split ( '<span class="invslot">' ) . slice ( 1 ) ;
const allSlots : ( string | null ) [ ] = chunks . map ( ( chunk ) = > {
if ( chunk . startsWith ( "</span>" ) ) return null ;
const img = chunk . match ( /<img\b[^>]*\bsrc="([^"]+)"/ ) ;
return img ? absolutize ( img [ 1 ] ) : null ;
} ) ;
// The output uses `invslot invslot-large`, but the split keys on `invslot">` so the trailing
// -large slot is split too. The first 9 slots are the input grid; the next non-null is the
// result (any "auxiliary" empty filler slots between input and output are tolerable to skip).
if ( allSlots . length < 9 ) return null ;
const inputs = allSlots . slice ( 0 , 9 ) ;
const result = allSlots . slice ( 9 ) . find ( ( s ) = > s != null ) ? ? null ;
return {
grid : [ inputs . slice ( 0 , 3 ) , inputs . slice ( 3 , 6 ) , inputs . slice ( 6 , 9 ) ] ,
result ,
} ;
}
async function fetchBytes ( url : string ) : Promise < Buffer > {
const res = await fetch ( url , {
headers : { "User-Agent" : UA } ,
signal : AbortSignal.timeout ( TIMEOUT_MS ) ,
} ) ;
if ( ! res . ok ) throw new Error ( ` ${ url } → ${ res . status } ` ) ;
return Buffer . from ( await res . arrayBuffer ( ) ) ;
}
// Compose a Minecraft-style crafting recipe PNG. Layout (each cell 64× 64 px,
// scaled up from the wiki's native 32 px for clarity on phones):
// [grid 3× 3] [arrow "→"] [result]
// Background is the classic inventory gray (#8b8b8b) with darker slot borders.
const CELL = 64 ;
const GAP = 4 ;
const PAD = 8 ;
const ARROW_W = 56 ;
async function composeRecipePng ( grid : ( string | null ) [ ] [ ] , result : string | null ) : Promise < Buffer > {
// Collect unique URLs and fetch each once.
const unique = new Set < string > ( ) ;
for ( const row of grid ) for ( const u of row ) if ( u ) unique . add ( u ) ;
if ( result ) unique . add ( result ) ;
const blobs : Record < string , Buffer > = { } ;
await Promise . all (
[ . . . unique ] . map ( async ( u ) = > {
blobs [ u ] = await fetchBytes ( u ) ;
} ) ,
) ;
const gridW = CELL * 3 + GAP * 2 ;
const gridH = CELL * 3 + GAP * 2 ;
const totalW = PAD + gridW + ( result ? PAD + ARROW_W + PAD + CELL : 0 ) + PAD ;
const totalH = PAD + gridH + PAD ;
// Resize every needed sprite to CELL× CELL up front, with nearest-neighbour so
// the 16-pixel inventory icons stay crisp (no antialiasing). Sharp's input
// is the original buffer; outputs are 64× 64 PNG buffers.
const sized : Record < string , Buffer > = { } ;
await Promise . all (
[ . . . unique ] . map ( async ( u ) = > {
sized [ u ] = await sharp ( blobs [ u ] ) . resize ( CELL , CELL , { kernel : "nearest" } ) . png ( ) . toBuffer ( ) ;
} ) ,
) ;
// Arrow rendered as SVG → PNG.
const arrowSvg = Buffer . from (
` <svg xmlns="http://www.w3.org/2000/svg" width=" ${ ARROW_W } " height=" ${ CELL } " viewBox="0 0 ${ ARROW_W } ${ CELL } "> ` +
` <text x=" ${ ARROW_W / 2 } " y=" ${ CELL / 2 + 16 } " text-anchor="middle" font-family="serif" font-size="48" fill="#222">→</text> ` +
` </svg> ` ,
) ;
const arrowPng = await sharp ( arrowSvg ) . png ( ) . toBuffer ( ) ;
const composites : { input : Buffer ; top : number ; left : number } [ ] = [ ] ;
// Slot backgrounds (slightly darker squares to look like inventory cells).
for ( let r = 0 ; r < 3 ; r ++ ) {
for ( let c = 0 ; c < 3 ; c ++ ) {
const left = PAD + c * ( CELL + GAP ) ;
const top = PAD + r * ( CELL + GAP ) ;
composites . push ( {
input : Buffer.from (
` <svg xmlns="http://www.w3.org/2000/svg" width=" ${ CELL } " height=" ${ CELL } "> ` +
` <rect width=" ${ CELL } " height=" ${ CELL } " fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/> ` +
` </svg> ` ,
) ,
top , left ,
} ) ;
const u = grid [ r ] [ c ] ;
if ( u ) composites . push ( { input : sized [ u ] , top , left } ) ;
}
}
if ( result ) {
const ax = PAD + gridW + PAD ;
const rx = ax + ARROW_W + PAD ;
composites . push ( { input : arrowPng , top : PAD + ( gridH - CELL ) / 2 , left : ax } ) ;
composites . push ( {
input : Buffer.from (
` <svg xmlns="http://www.w3.org/2000/svg" width=" ${ CELL } " height=" ${ CELL } "> ` +
` <rect width=" ${ CELL } " height=" ${ CELL } " fill="#6f6f6f" stroke="#3b3b3b" stroke-width="2"/> ` +
` </svg> ` ,
) ,
top : PAD + ( gridH - CELL ) / 2 ,
left : rx ,
} ) ;
composites . push ( { input : sized [ result ] , top : PAD + ( gridH - CELL ) / 2 , left : rx } ) ;
}
return sharp ( {
create : { width : totalW , height : totalH , channels : 4 , background : { r : 139 , g : 139 , b : 139 , alpha : 1 } } ,
} )
. composite ( composites )
. png ( )
. toBuffer ( ) ;
}
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>
2026-05-13 23:29:45 +02:00
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 ) ,
} ;
} ,
} ) ,
feat(redstone): wiki_recipe_image — composite real recipe PNG and upload
Previous wiki_recipe only returned the page thumbnail, so "send me the
recipe image" delivered a picture of the item, not the recipe itself.
minecraft.wiki renders recipes as live HTML grids, not standalone PNGs,
so we now generate the image ourselves.
mcp/lib/minecraft-wiki-tools.ts:
- parseCraftingGrid(html): pulls the 3×3 input grid + result item URL
from <span class="mcui mcui-Crafting_Table">. Each <span class="invslot">
is either empty (chunk starts with </span>) or has an <img src="/images/
Invicon_X.png?h">. Reading-order chunks → grid; first non-null after
the 9th input slot → result.
- composeRecipePng(grid, result): fetches each unique sprite, resizes
to 64×64 via sharp (nearest-neighbour to keep pixel art crisp),
composites onto a #8b8b8b inventory-style background with #6f6f6f
slot squares + dark borders, places an SVG-rendered "→" arrow,
then the result cell next to it. PNG buffer returned.
- New tool wiki_recipe_image(title, chat, caption?) that runs the
pipeline end-to-end and uploads via tg.sendPhotoBytes — one tool
call, image already in the chat when it returns.
mcp/lib/types.ts:
- TgClient gains sendPhotoBytes({ chat_id, bytes, filename?, caption?,
parse_mode?, reply_to_message_id? }). Multipart/form-data POST to
sendPhoto, since Telegram's URL-mode can only fetch public URLs and
the composited PNG only lives in memory. 30s timeout (vs the 20s on
the JSON call) because uploads are bigger.
mcp/package.json + bot/package.json: add sharp ^0.33.5. mcp/ pulls it
in too so /mc/mcp/lib resolves it at the bot's runtime (the bot's
node_modules is at /app while the lib files live at /mc/mcp/lib).
Persona prompt: wiki_recipe_image is now the FIRST call for any
"recipe" / "send me the recipe image" request. Single tool turn ends
with the photo already in the chat. wiki_recipe stays as a fallback
when the page has no 3×3 crafting widget (smelting / brewing / smithing
use different mcui classes).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:54:20 +02:00
tool ( {
name : "wiki_recipe_image" ,
title : "Send a Minecraft crafting recipe image to a chat" ,
description :
"Generate a 3× 3 crafting-grid image of the recipe (with the result item beside an arrow) and send it directly to a Telegram chat. Use this for 'send me the recipe' / 'image of recipe' requests instead of chaining wiki_recipe + tg_send_photo — minecraft.wiki has no standalone recipe PNG, so this tool composites one from the per-slot ingredient sprites. Returns { sent: true, message_id } on success. Fails if the page has no 3× 3 crafting recipe (smelting, brewing, smithing all use different mcui widgets)." ,
parameters : {
title : z.string ( ) . min ( 1 ) . describe ( "Page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'" ) ,
chat : z.union ( [ z . number ( ) . int ( ) , z . string ( ) ] ) . describe ( "Telegram chat id to send the photo into" ) ,
caption : z.string ( ) . max ( 1024 ) . optional ( ) . describe ( "Optional caption — defaults to the page title if omitted" ) ,
} ,
handler : async ( { title , chat , caption } , { tg } ) = > {
type ParseRes = { parse ? : { title? : string ; text? : string } } ;
const data = await wikiGet < ParseRes > ( {
action : "parse" ,
page : title ,
prop : "text" ,
redirects : "1" ,
disableeditsection : "1" ,
} ) ;
const html = data . parse ? . text ? ? "" ;
if ( ! html ) return { error : ` no wiki page titled ' ${ title } ' ` } ;
const resolved = data . parse ? . title ? ? title ;
const parsed = parseCraftingGrid ( html ) ;
if ( ! parsed ) {
return {
error :
` no 3× 3 crafting recipe on ' ${ resolved } ' (page may use smelting / brewing / smithing, or item is uncraftable) ` ,
} ;
}
const png = await composeRecipePng ( parsed . grid , parsed . result ) ;
const sent = await tg . sendPhotoBytes ( {
chat_id : chat ,
bytes : png ,
filename : ` ${ resolved . replace ( /[^A-Za-z0-9._-]+/g , "_" ) } _recipe.png ` ,
caption : caption ? ? ` ${ resolved } — crafting recipe ` ,
} ) ;
return { sent : true , message_id : sent.message_id , title : resolved } ;
} ,
} ) ,
feat(redstone): wiki_recipe tool — extract recipe + thumbnail in one call
Adds a new MCP tool that parses minecraft.wiki's <table data-description=
"Crafting recipes"> directly, pulling the ingredients column out of each
row (e.g. "Block of Iron + Iron Ingot" for Anvil), then chases up the
page's main item thumbnail via prop=pageimages so the bot can deliver
text + a real image in one tool round.
Why a dedicated tool: minecraft.wiki does not host standalone "recipe.png"
files — recipes are rendered live as HTML grids composed from individual
BlockSprite icons. Filtering wiki_page_images for 'recipe' / 'craft' /
'grid' returned empty for every craftable item, leaving the model with
nothing to call tg_send_photo with. Confirmed by inspecting the Anvil
page's image inventory (Anvil1-6.png + GUI + sound files, no recipe).
Tool returns {
title, page_url,
recipes: [{ ingredients: "…" }, …], // strips <br>/<a>/ cleanly
thumbnail_url, // for tg_send_photo
note?, // present when no recipe rows
}
Persona prompt tightened:
- wiki_recipe is now the FIRST call for "how do I craft X" / "recipe for X"
- wiki_page_images is downgraded to "specific non-thumbnail images only"
- IMAGE DELIVERY MANDATORY clause: must end the turn with tg_send_photo
using a real URL or say plainly that no thumbnail was found — no more
describing-the-image-in-prose-and-stopping failures.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 23:47:25 +02:00
tool ( {
name : "wiki_recipe" ,
title : "Get a crafting recipe from the Minecraft wiki" ,
description :
"Extracts the crafting recipe ingredient list from a wiki page (parses the live recipe table — minecraft.wiki renders recipes as HTML grids, not standalone images, so wiki_page_images is the wrong tool for recipe queries). Returns the ingredients string for each recipe variant, plus the page's main item thumbnail URL to send with tg_send_photo. Use this whenever a user asks 'how do I craft X' or 'recipe for X'." ,
parameters : {
title : z.string ( ) . min ( 1 ) . describe ( "Exact wiki page title — e.g. 'Anvil', 'Beacon', 'Crafting Table'. Use wiki_search first if unsure." ) ,
} ,
handler : async ( { title } ) = > {
// 1. Parsed HTML — the recipe lives in a wikitable with data-description="Crafting recipes"
type ParseRes = { parse ? : { title? : string ; text? : string } } ;
const data = await wikiGet < ParseRes > ( {
action : "parse" ,
page : title ,
prop : "text" ,
redirects : "1" ,
disableeditsection : "1" ,
} ) ;
const html = data . parse ? . text ? ? "" ;
if ( ! html ) return { error : ` no wiki page titled ' ${ title } ' ` } ;
const resolvedTitle = data . parse ? . title ? ? title ;
// 2. Find each crafting-recipes table and pull the first <td> (ingredients) of each data row.
const stripHtml = ( s : string ) = >
s . replace ( /<br\s*\/?>/gi , " + " )
. replace ( /<[^>]+>/g , "" )
. replace ( / | /g , " " )
. replace ( /&/g , "&" )
. replace ( /\s*\+\s*/g , " + " )
. replace ( /\s+/g , " " )
. trim ( ) ;
const tableRe = /<table\b[^>]*data-description="Crafting recipes"[^>]*>([\s\S]*?)<\/table>/g ;
const recipes : { ingredients : string } [ ] = [ ] ;
let m : RegExpExecArray | null ;
while ( ( m = tableRe . exec ( html ) ) !== null ) {
const rowRe = /<tr\b[^>]*>([\s\S]*?)<\/tr>/g ;
let r : RegExpExecArray | null ;
while ( ( r = rowRe . exec ( m [ 1 ] ) ) !== null ) {
if ( /<th\b/ . test ( r [ 1 ] ) ) continue ;
const td = /<td\b[^>]*>([\s\S]*?)<\/td>/ . exec ( r [ 1 ] ) ;
if ( ! td ) continue ;
const text = stripHtml ( td [ 1 ] ) ;
if ( text ) recipes . push ( { ingredients : text } ) ;
}
}
// 3. Pull the page thumbnail in a second query — gives the bot a URL to send.
type ThumbRes = { query ? : { pages ? : { title : string ; thumbnail ? : { source : string } } [ ] } } ;
const meta = await wikiGet < ThumbRes > ( {
action : "query" ,
prop : "pageimages" ,
titles : resolvedTitle ,
piprop : "thumbnail" ,
pithumbsize : 600 ,
redirects : "1" ,
} ) ;
const thumbnail = meta . query ? . pages ? . [ 0 ] ? . thumbnail ? . source ? ? null ;
return {
title : resolvedTitle ,
recipes , // [{ ingredients: "Block of Iron + Iron Ingot" }, ...]
thumbnail_url : thumbnail , // page's main item image — pass to tg_send_photo
page_url : pageUrl ( resolvedTitle ) ,
note : recipes.length === 0
? "No recipe table on this page — the item may be uncraftable, found only via loot/structure, or listed under a different page."
: undefined ,
} ;
} ,
} ) ,
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>
2026-05-13 23:29:45 +02:00
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 ) } ;
} ,
} ) ,
] ;