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