├── .gitattributes ├── .github ├── pull_request_template.md ├── dependabot.yml └── workflows │ └── build.yml ├── src ├── types.d.ts ├── logger.ts ├── tools │ ├── gamestate-tools.ts │ ├── chat-tools.ts │ ├── entity-tools.ts │ ├── flight-tools.ts │ ├── inventory-tools.ts │ ├── position-tools.ts │ └── block-tools.ts ├── stdio-filter.ts ├── config.ts ├── message-store.ts ├── tool-factory.ts ├── main.ts └── bot-connection.ts ├── tsconfig.build.json ├── .gitignore ├── tsconfig.json ├── eslint.config.mjs ├── package.json ├── tests ├── config.test.ts ├── message-store.test.ts ├── gamestate-tools.test.ts ├── position-tools.test.ts ├── stdio-filter.test.ts ├── flight-tools.test.ts ├── logger.test.ts ├── inventory-tools.test.ts ├── entity-tools.test.ts ├── chat-tools.test.ts ├── bot-connection.test.ts ├── tool-factory.test.ts └── block-tools.test.ts ├── CONTRIBUTING.md ├── README.md └── LICENSE /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | *.ts linguist-language=TypeScript -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Changes: 2 | 3 | 4 | 5 | --- 6 | 7 | - [ ] I have read and am familiar with [CONTRIBUTING.md](CONTRIBUTING.md) 8 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'mineflayer-pathfinder' { 2 | import type { Bot } from 'mineflayer'; 3 | 4 | export class Movements { 5 | constructor(_bot: Bot, _mcData: unknown); 6 | } 7 | } -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | export function log(level: string, message: string): void { 2 | const timestamp = new Date().toISOString(); 3 | process.stderr.write(`${timestamp} [minecraft] [mcp-server] [${level}] ${message}\n`); 4 | } -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": false, 5 | "outDir": "dist", 6 | "declaration": true 7 | }, 8 | "include": [ 9 | "src/**/*.ts" 10 | ], 11 | "exclude": [ 12 | "tests", 13 | "dist" 14 | ] 15 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dist folder 2 | dist/ 3 | 4 | # Node.js dependencies 5 | node_modules/ 6 | 7 | # Log files 8 | logs/ 9 | *.log 10 | npm-debug.log* 11 | 12 | # Process IDs 13 | pids/ 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # NPM cache 19 | .npm/ 20 | 21 | # REPL history 22 | .node_repl_history 23 | 24 | # Claude Code configuration 25 | .claude -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "NodeNext", 5 | "moduleResolution": "NodeNext", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "isolatedModules": true, 10 | "noEmit": true 11 | }, 12 | "include": [ 13 | "src", 14 | "tests" 15 | ] 16 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "npm" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | open-pull-requests-limit: 10 9 | versioning-strategy: increase 10 | 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | open-pull-requests-limit: 5 16 | -------------------------------------------------------------------------------- /src/tools/gamestate-tools.ts: -------------------------------------------------------------------------------- 1 | import mineflayer from 'mineflayer'; 2 | import { ToolFactory } from '../tool-factory.js'; 3 | 4 | export function registerGameStateTools(factory: ToolFactory, getBot: () => mineflayer.Bot): void { 5 | factory.registerTool( 6 | "detect-gamemode", 7 | "Detect the gamemode on game", 8 | {}, 9 | async () => { 10 | const bot = getBot(); 11 | return factory.createResponse(`Bot gamemode: "${bot.game.gameMode}"`); 12 | } 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/stdio-filter.ts: -------------------------------------------------------------------------------- 1 | export function setupStdioFiltering(): void { 2 | const originalStdoutWrite = process.stdout.write.bind(process.stdout); 3 | 4 | process.stdout.write = function(chunk: string | Uint8Array, ...args: never[]): boolean { 5 | const message = chunk.toString(); 6 | if (message.match(/^(\{|[\r\n]+$)/) || message.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)) { 7 | return originalStdoutWrite(chunk, ...args); 8 | } 9 | return true; 10 | } as typeof process.stdout.write; 11 | 12 | console.error = function() { return; }; 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v6 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version: '22.x' 20 | cache: 'npm' 21 | 22 | - name: Install dependencies 23 | run: npm ci 24 | 25 | - name: Lint 26 | run: npm run lint 27 | 28 | - name: TypeCheck 29 | run: npx tsc --noEmit 30 | 31 | - name: Build 32 | run: npm run build 33 | 34 | - name: Test 35 | run: npm test 36 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | import { hideBin } from 'yargs/helpers'; 3 | 4 | export interface ServerConfig { 5 | host: string; 6 | port: number; 7 | username: string; 8 | } 9 | 10 | export function parseConfig(): ServerConfig { 11 | return yargs(hideBin(process.argv)) 12 | .option('host', { 13 | type: 'string', 14 | description: 'Minecraft server host', 15 | default: 'localhost' 16 | }) 17 | .option('port', { 18 | type: 'number', 19 | description: 'Minecraft server port', 20 | default: 25565 21 | }) 22 | .option('username', { 23 | type: 'string', 24 | description: 'Bot username', 25 | default: 'LLMBot' 26 | }) 27 | .help() 28 | .alias('help', 'h') 29 | .parseSync(); 30 | } 31 | -------------------------------------------------------------------------------- /src/message-store.ts: -------------------------------------------------------------------------------- 1 | interface StoredMessage { 2 | timestamp: number; 3 | username: string; 4 | content: string; 5 | } 6 | 7 | const MAX_STORED_MESSAGES = 100; 8 | 9 | export class MessageStore { 10 | private messages: StoredMessage[] = []; 11 | private maxMessages = MAX_STORED_MESSAGES; 12 | 13 | addMessage(username: string, content: string): void { 14 | const message: StoredMessage = { 15 | timestamp: Date.now(), 16 | username, 17 | content 18 | }; 19 | 20 | this.messages.push(message); 21 | 22 | if (this.messages.length > this.maxMessages) { 23 | this.messages.shift(); 24 | } 25 | } 26 | 27 | getRecentMessages(count: number = 10): StoredMessage[] { 28 | if (count <= 0) { 29 | return []; 30 | } 31 | return this.messages.slice(-count); 32 | } 33 | 34 | getMaxMessages(): number { 35 | return this.maxMessages; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tsParser from "@typescript-eslint/parser"; 4 | import tsPlugin from "@typescript-eslint/eslint-plugin"; 5 | import { defineConfig } from "eslint/config"; 6 | 7 | const avaPlugin = await import("eslint-plugin-ava"); 8 | 9 | export default defineConfig([ 10 | js.configs.recommended, 11 | 12 | { 13 | files: ["src/**/*.ts", "tests/**/*.ts"], 14 | languageOptions: { 15 | parser: tsParser, 16 | parserOptions: { 17 | project: "./tsconfig.json", 18 | tsconfigRootDir: process.cwd(), 19 | }, 20 | sourceType: "module", 21 | globals: { 22 | ...globals.node, 23 | }, 24 | }, 25 | plugins: { 26 | "@typescript-eslint": tsPlugin, 27 | ava: avaPlugin.default, 28 | }, 29 | rules: { 30 | "@typescript-eslint/no-explicit-any": "warn", 31 | "no-unused-vars": "off", 32 | "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 33 | "@typescript-eslint/explicit-module-boundary-types": "off", 34 | "no-console": "off", 35 | "ava/no-only-test": "error", 36 | "ava/no-skip-test": "warn", 37 | }, 38 | }, 39 | ]); 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "minecraft-mcp-server", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "main": "dist/main.js", 6 | "bin": { 7 | "minecraft-mcp-server": "dist/main.js" 8 | }, 9 | "ava": { 10 | "extensions": [ 11 | "ts" 12 | ], 13 | "nodeArguments": [ 14 | "--import=tsx" 15 | ] 16 | }, 17 | "scripts": { 18 | "dev": "node --loader=ts-node/esm src/main.ts", 19 | "build": "tsc -p tsconfig.build.json", 20 | "prepare": "npm run build", 21 | "start": "node dist/main.js", 22 | "test": "ava", 23 | "lint": "eslint \"{src,tests}/**/*.ts\"", 24 | "lint:fix": "eslint \"{src,tests}/**/*.ts\" --fix" 25 | }, 26 | "dependencies": { 27 | "@modelcontextprotocol/sdk": "^1.24.3", 28 | "minecraft-data": "^3.101.0", 29 | "mineflayer": "^4.33.0", 30 | "mineflayer-pathfinder": "^2.4.2", 31 | "vec3": "^0.1.8", 32 | "yargs": "^18.0.0", 33 | "zod": "^3.25.76" 34 | }, 35 | "devDependencies": { 36 | "@ava/typescript": "^6.0.0", 37 | "@types/node": "^25.0.2", 38 | "@types/sinon": "^21.0.0", 39 | "@types/vec3": "^0.1.4", 40 | "@types/yargs": "^17.0.35", 41 | "@typescript-eslint/eslint-plugin": "^8.49.0", 42 | "@typescript-eslint/parser": "^8.48.1", 43 | "ava": "^6.4.1", 44 | "eslint": "^9.39.2", 45 | "eslint-plugin-ava": "^15.1.0", 46 | "globals": "^16.5.0", 47 | "sinon": "^21.0.0", 48 | "ts-node": "^10.9.2", 49 | "tsx": "^4.21.0", 50 | "typescript": "^5.9.3" 51 | }, 52 | "engines": { 53 | "node": ">=20.10.0" 54 | } 55 | } -------------------------------------------------------------------------------- /src/tools/chat-tools.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import mineflayer from 'mineflayer'; 3 | import { ToolFactory } from '../tool-factory.js'; 4 | import { MessageStore } from '../message-store.js'; 5 | 6 | export function registerChatTools(factory: ToolFactory, getBot: () => mineflayer.Bot, messageStore: MessageStore): void { 7 | factory.registerTool( 8 | "send-chat", 9 | "Send a chat message in-game", 10 | { 11 | message: z.string().describe("Message to send in chat") 12 | }, 13 | async ({ message }) => { 14 | const bot = getBot(); 15 | bot.chat(message); 16 | return factory.createResponse(`Sent message: "${message}"`); 17 | } 18 | ); 19 | 20 | factory.registerTool( 21 | "read-chat", 22 | "Get recent chat messages from players", 23 | { 24 | count: z.number().optional().describe("Number of recent messages to retrieve (default: 10, max: 100)") 25 | }, 26 | async ({ count = 10 }) => { 27 | const maxCount = Math.min(count, messageStore.getMaxMessages()); 28 | const messages = messageStore.getRecentMessages(maxCount); 29 | 30 | if (messages.length === 0) { 31 | return factory.createResponse("No chat messages found"); 32 | } 33 | 34 | let output = `Found ${messages.length} chat message(s):\n\n`; 35 | messages.forEach((msg, index) => { 36 | const timestamp = new Date(msg.timestamp).toISOString(); 37 | output += `${index + 1}. ${timestamp} - ${msg.username}: ${msg.content}\n`; 38 | }); 39 | 40 | return factory.createResponse(output); 41 | } 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/tools/entity-tools.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import type { Bot } from 'mineflayer'; 3 | import { ToolFactory } from '../tool-factory.js'; 4 | 5 | type Entity = ReturnType; 6 | 7 | export function registerEntityTools(factory: ToolFactory, getBot: () => Bot): void { 8 | factory.registerTool( 9 | "find-entity", 10 | "Find the nearest entity of a specific type", 11 | { 12 | type: z.string().optional().describe("Type of entity to find (empty for any entity)"), 13 | maxDistance: z.number().optional().describe("Maximum search distance (default: 16)") 14 | }, 15 | async ({ type = '', maxDistance = 16 }) => { 16 | const bot = getBot(); 17 | const entityFilter = (entity: NonNullable) => { 18 | if (!type) return true; 19 | if (type === 'player') return entity.type === 'player'; 20 | if (type === 'mob') return entity.type === 'mob'; 21 | return Boolean(entity.name && entity.name.includes(type.toLowerCase())); 22 | }; 23 | 24 | const entity = bot.nearestEntity(entityFilter); 25 | 26 | if (!entity || bot.entity.position.distanceTo(entity.position) > maxDistance) { 27 | return factory.createResponse(`No ${type || 'entity'} found within ${maxDistance} blocks`); 28 | } 29 | 30 | const entityName = entity.name || (entity as { username?: string }).username || entity.type; 31 | return factory.createResponse(`Found ${entityName} at position (${Math.floor(entity.position.x)}, ${Math.floor(entity.position.y)}, ${Math.floor(entity.position.z)})`); 32 | } 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/tool-factory.ts: -------------------------------------------------------------------------------- 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { BotConnection } from './bot-connection.js'; 3 | 4 | type McpResponse = { 5 | content: { type: "text"; text: string }[]; 6 | isError?: boolean; 7 | [key: string]: unknown; 8 | }; 9 | 10 | export class ToolFactory { 11 | constructor( 12 | private server: McpServer, 13 | private connection: BotConnection 14 | ) {} 15 | 16 | registerTool( 17 | name: string, 18 | description: string, 19 | schema: Record, 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | executor: (args: any) => Promise 22 | ): void { 23 | this.server.tool(name, description, schema, async (args: unknown): Promise => { 24 | const connectionCheck = await this.connection.checkConnectionAndReconnect(); 25 | 26 | if (!connectionCheck.connected) { 27 | return { 28 | content: [{ type: "text", text: connectionCheck.message! }], 29 | isError: true 30 | }; 31 | } 32 | 33 | try { 34 | return await executor(args); 35 | } catch (error) { 36 | return this.createErrorResponse(error as Error); 37 | } 38 | }); 39 | } 40 | 41 | createResponse(text: string): McpResponse { 42 | return { 43 | content: [{ type: "text", text }] 44 | }; 45 | } 46 | 47 | createErrorResponse(error: Error | string): McpResponse { 48 | const errorMessage = error instanceof Error ? error.message : error; 49 | return { 50 | content: [{ type: "text", text: `Failed: ${errorMessage}` }], 51 | isError: true 52 | }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /tests/config.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { parseConfig } from '../src/config.js'; 3 | 4 | test('parseConfig returns default values', (t) => { 5 | const originalArgv = process.argv; 6 | process.argv = ['node', 'script.js']; 7 | 8 | const config = parseConfig(); 9 | 10 | t.is(config.host, 'localhost'); 11 | t.is(config.port, 25565); 12 | t.is(config.username, 'LLMBot'); 13 | 14 | process.argv = originalArgv; 15 | }); 16 | 17 | test('parseConfig parses custom host', (t) => { 18 | const originalArgv = process.argv; 19 | process.argv = ['node', 'script.js', '--host', 'example.com']; 20 | 21 | const config = parseConfig(); 22 | 23 | t.is(config.host, 'example.com'); 24 | t.is(config.port, 25565); 25 | t.is(config.username, 'LLMBot'); 26 | 27 | process.argv = originalArgv; 28 | }); 29 | 30 | test('parseConfig parses custom port', (t) => { 31 | const originalArgv = process.argv; 32 | process.argv = ['node', 'script.js', '--port', '12345']; 33 | 34 | const config = parseConfig(); 35 | 36 | t.is(config.host, 'localhost'); 37 | t.is(config.port, 12345); 38 | t.is(config.username, 'LLMBot'); 39 | 40 | process.argv = originalArgv; 41 | }); 42 | 43 | test('parseConfig parses custom username', (t) => { 44 | const originalArgv = process.argv; 45 | process.argv = ['node', 'script.js', '--username', 'CustomBot']; 46 | 47 | const config = parseConfig(); 48 | 49 | t.is(config.host, 'localhost'); 50 | t.is(config.port, 25565); 51 | t.is(config.username, 'CustomBot'); 52 | 53 | process.argv = originalArgv; 54 | }); 55 | 56 | test('parseConfig parses all custom options', (t) => { 57 | const originalArgv = process.argv; 58 | process.argv = ['node', 'script.js', '--host', 'server.net', '--port', '9999', '--username', 'TestBot']; 59 | 60 | const config = parseConfig(); 61 | 62 | t.is(config.host, 'server.net'); 63 | t.is(config.port, 9999); 64 | t.is(config.username, 'TestBot'); 65 | 66 | process.argv = originalArgv; 67 | }); 68 | 69 | test('parseConfig handles numeric port as number type', (t) => { 70 | const originalArgv = process.argv; 71 | process.argv = ['node', 'script.js', '--port', '30000']; 72 | 73 | const config = parseConfig(); 74 | 75 | t.is(typeof config.port, 'number'); 76 | t.is(config.port, 30000); 77 | 78 | process.argv = originalArgv; 79 | }); 80 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { setupStdioFiltering } from './stdio-filter.js'; 6 | import { log } from './logger.js'; 7 | import { parseConfig } from './config.js'; 8 | import { BotConnection } from './bot-connection.js'; 9 | import { ToolFactory } from './tool-factory.js'; 10 | import { MessageStore } from './message-store.js'; 11 | import { registerPositionTools } from './tools/position-tools.js'; 12 | import { registerInventoryTools } from './tools/inventory-tools.js'; 13 | import { registerBlockTools } from './tools/block-tools.js'; 14 | import { registerEntityTools } from './tools/entity-tools.js'; 15 | import { registerChatTools } from './tools/chat-tools.js'; 16 | import { registerFlightTools } from './tools/flight-tools.js'; 17 | import { registerGameStateTools } from './tools/gamestate-tools.js'; 18 | 19 | setupStdioFiltering(); 20 | 21 | process.on('unhandledRejection', (reason) => { 22 | log('error', `Unhandled rejection: ${reason}`); 23 | }); 24 | 25 | process.on('uncaughtException', (error) => { 26 | log('error', `Uncaught exception: ${error}`); 27 | }); 28 | 29 | async function main() { 30 | const config = parseConfig(); 31 | const messageStore = new MessageStore(); 32 | 33 | const connection = new BotConnection( 34 | config, 35 | { 36 | onLog: log, 37 | onChatMessage: (username, message) => messageStore.addMessage(username, message) 38 | } 39 | ); 40 | 41 | connection.connect(); 42 | 43 | const server = new McpServer({ 44 | name: "minecraft-mcp-server", 45 | version: "2.0.0" 46 | }); 47 | 48 | const factory = new ToolFactory(server, connection); 49 | const getBot = () => connection.getBot()!; 50 | 51 | registerPositionTools(factory, getBot); 52 | registerInventoryTools(factory, getBot); 53 | registerBlockTools(factory, getBot); 54 | registerEntityTools(factory, getBot); 55 | registerChatTools(factory, getBot, messageStore); 56 | registerFlightTools(factory, getBot); 57 | registerGameStateTools(factory, getBot); 58 | 59 | process.stdin.on('end', () => { 60 | connection.cleanup(); 61 | log('info', 'MCP Client has disconnected. Shutting down...'); 62 | process.exit(0); 63 | }); 64 | 65 | const transport = new StdioServerTransport(); 66 | await server.connect(transport); 67 | } 68 | 69 | main().catch((error) => { 70 | log('error', `Fatal error in main(): ${error}`); 71 | process.exit(1); 72 | }); 73 | -------------------------------------------------------------------------------- /src/tools/flight-tools.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import mineflayer from 'mineflayer'; 3 | import { Vec3 } from 'vec3'; 4 | import { ToolFactory } from '../tool-factory.js'; 5 | 6 | function createCancellableFlightOperation( 7 | bot: mineflayer.Bot, 8 | destination: Vec3, 9 | controller: AbortController 10 | ): Promise { 11 | return new Promise((resolve, reject) => { 12 | let aborted = false; 13 | 14 | controller.signal.addEventListener('abort', () => { 15 | aborted = true; 16 | bot.creative.stopFlying(); 17 | reject(new Error("Flight operation cancelled")); 18 | }); 19 | 20 | bot.creative.flyTo(destination) 21 | .then(() => { 22 | if (!aborted) { 23 | resolve(true); 24 | } 25 | }) 26 | .catch((err: Error) => { 27 | if (!aborted) { 28 | reject(err); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | export function registerFlightTools(factory: ToolFactory, getBot: () => mineflayer.Bot): void { 35 | factory.registerTool( 36 | "fly-to", 37 | "Make the bot fly to a specific position", 38 | { 39 | x: z.number().describe("X coordinate"), 40 | y: z.number().describe("Y coordinate"), 41 | z: z.number().describe("Z coordinate") 42 | }, 43 | async ({ x, y, z }) => { 44 | const bot = getBot(); 45 | 46 | if (!bot.creative) { 47 | return factory.createResponse("Creative mode is not available. Cannot fly."); 48 | } 49 | 50 | const controller = new AbortController(); 51 | const FLIGHT_TIMEOUT_MS = 20000; 52 | 53 | const timeoutId = setTimeout(() => { 54 | if (!controller.signal.aborted) { 55 | controller.abort(); 56 | } 57 | }, FLIGHT_TIMEOUT_MS); 58 | 59 | try { 60 | const destination = new Vec3(x, y, z); 61 | await createCancellableFlightOperation(bot, destination, controller); 62 | return factory.createResponse(`Successfully flew to position (${x}, ${y}, ${z}).`); 63 | } catch (error) { 64 | if (controller.signal.aborted) { 65 | const currentPosAfterTimeout = bot.entity.position; 66 | return factory.createErrorResponse( 67 | `Flight timed out after ${FLIGHT_TIMEOUT_MS / 1000} seconds. The destination may be unreachable. ` + 68 | `Current position: (${Math.floor(currentPosAfterTimeout.x)}, ${Math.floor(currentPosAfterTimeout.y)}, ${Math.floor(currentPosAfterTimeout.z)})` 69 | ); 70 | } 71 | throw error; 72 | } finally { 73 | clearTimeout(timeoutId); 74 | bot.creative.stopFlying(); 75 | } 76 | } 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/tools/inventory-tools.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import mineflayer from 'mineflayer'; 3 | import { ToolFactory } from '../tool-factory.js'; 4 | 5 | interface InventoryItem { 6 | name: string; 7 | count: number; 8 | slot: number; 9 | } 10 | 11 | export function registerInventoryTools(factory: ToolFactory, getBot: () => mineflayer.Bot): void { 12 | factory.registerTool( 13 | "list-inventory", 14 | "List all items in the bot's inventory", 15 | {}, 16 | async () => { 17 | const bot = getBot(); 18 | const items = bot.inventory.items(); 19 | const itemList: InventoryItem[] = items.map((item) => ({ 20 | name: item.name, 21 | count: item.count, 22 | slot: item.slot 23 | })); 24 | 25 | if (items.length === 0) { 26 | return factory.createResponse("Inventory is empty"); 27 | } 28 | 29 | let inventoryText = `Found ${items.length} items in inventory:\n\n`; 30 | itemList.forEach(item => { 31 | inventoryText += `- ${item.name} (x${item.count}) in slot ${item.slot}\n`; 32 | }); 33 | 34 | return factory.createResponse(inventoryText); 35 | } 36 | ); 37 | 38 | factory.registerTool( 39 | "find-item", 40 | "Find a specific item in the bot's inventory", 41 | { 42 | nameOrType: z.string().describe("Name or type of item to find") 43 | }, 44 | async ({ nameOrType }) => { 45 | const bot = getBot(); 46 | const items = bot.inventory.items(); 47 | const item = items.find((item) => 48 | item.name.includes(nameOrType.toLowerCase()) 49 | ); 50 | 51 | if (item) { 52 | return factory.createResponse(`Found ${item.count} ${item.name} in inventory (slot ${item.slot})`); 53 | } else { 54 | return factory.createResponse(`Couldn't find any item matching '${nameOrType}' in inventory`); 55 | } 56 | } 57 | ); 58 | 59 | factory.registerTool( 60 | "equip-item", 61 | "Equip a specific item", 62 | { 63 | itemName: z.string().describe("Name of the item to equip"), 64 | destination: z.string().optional().describe("Where to equip the item (default: 'hand')") 65 | }, 66 | async ({ itemName, destination = 'hand' }) => { 67 | const bot = getBot(); 68 | const items = bot.inventory.items(); 69 | const item = items.find((item) => 70 | item.name.includes(itemName.toLowerCase()) 71 | ); 72 | 73 | if (!item) { 74 | return factory.createResponse(`Couldn't find any item matching '${itemName}' in inventory`); 75 | } 76 | 77 | await bot.equip(item, destination as mineflayer.EquipmentDestination); 78 | return factory.createResponse(`Equipped ${item.name} to ${destination}`); 79 | } 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /src/tools/position-tools.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import mineflayer from 'mineflayer'; 3 | import pathfinderPkg from 'mineflayer-pathfinder'; 4 | const { goals } = pathfinderPkg; 5 | import { Vec3 } from 'vec3'; 6 | import { ToolFactory } from '../tool-factory.js'; 7 | 8 | type Direction = 'forward' | 'back' | 'left' | 'right'; 9 | 10 | export function registerPositionTools(factory: ToolFactory, getBot: () => mineflayer.Bot): void { 11 | factory.registerTool( 12 | "get-position", 13 | "Get the current position of the bot", 14 | {}, 15 | async () => { 16 | const bot = getBot(); 17 | const position = bot.entity.position; 18 | const pos = { 19 | x: Math.floor(position.x), 20 | y: Math.floor(position.y), 21 | z: Math.floor(position.z) 22 | }; 23 | return factory.createResponse(`Current position: (${pos.x}, ${pos.y}, ${pos.z})`); 24 | } 25 | ); 26 | 27 | factory.registerTool( 28 | "move-to-position", 29 | "Move the bot to a specific position", 30 | { 31 | x: z.number().describe("X coordinate"), 32 | y: z.number().describe("Y coordinate"), 33 | z: z.number().describe("Z coordinate"), 34 | range: z.number().optional().describe("How close to get to the target (default: 1)") 35 | }, 36 | async ({ x, y, z, range = 1 }) => { 37 | const bot = getBot(); 38 | const goal = new goals.GoalNear(x, y, z, range); 39 | await bot.pathfinder.goto(goal); 40 | return factory.createResponse(`Successfully moved to position near (${x}, ${y}, ${z})`); 41 | } 42 | ); 43 | 44 | factory.registerTool( 45 | "look-at", 46 | "Make the bot look at a specific position", 47 | { 48 | x: z.number().describe("X coordinate"), 49 | y: z.number().describe("Y coordinate"), 50 | z: z.number().describe("Z coordinate"), 51 | }, 52 | async ({ x, y, z }) => { 53 | const bot = getBot(); 54 | await bot.lookAt(new Vec3(x, y, z), true); 55 | return factory.createResponse(`Looking at position (${x}, ${y}, ${z})`); 56 | } 57 | ); 58 | 59 | factory.registerTool( 60 | "jump", 61 | "Make the bot jump", 62 | {}, 63 | async () => { 64 | const bot = getBot(); 65 | bot.setControlState('jump', true); 66 | setTimeout(() => bot.setControlState('jump', false), 250); 67 | return factory.createResponse("Successfully jumped"); 68 | } 69 | ); 70 | 71 | factory.registerTool( 72 | "move-in-direction", 73 | "Move the bot in a specific direction for a duration", 74 | { 75 | direction: z.enum(['forward', 'back', 'left', 'right']).describe("Direction to move"), 76 | duration: z.number().optional().describe("Duration in milliseconds (default: 1000)") 77 | }, 78 | async ({ direction, duration = 1000 }: { direction: Direction, duration?: number }) => { 79 | const bot = getBot(); 80 | return new Promise((resolve) => { 81 | bot.setControlState(direction, true); 82 | setTimeout(() => { 83 | bot.setControlState(direction, false); 84 | resolve(factory.createResponse(`Moved ${direction} for ${duration}ms`)); 85 | }, duration); 86 | }); 87 | } 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Welcome to Minecraft-MCP-Server Contributing Guide 2 | 3 | ## Prerequisites 4 | - Git 5 | - Node.js (>=20.10.0) 6 | - A running Minecraft game (tested with Minecraft 1.21.8 Java Edition) 7 | - Claude Desktop (or another MCP-compatible client) 8 | 9 | ### 1. Fork and Clone the Repository 10 | 11 | 1. Fork this repository on GitHub 12 | 2. Clone your fork locally: 13 | ```bash 14 | git clone https://github.com/YOUR_USERNAME/minecraft-mcp-server.git 15 | cd minecraft-mcp-server 16 | ``` 17 | 18 | ### 2. Create a Feature Branch 19 | 20 | Create a new branch for your feature or bug fix: 21 | ```bash 22 | git checkout -b your-feature-name 23 | ``` 24 | 25 | ### 3. Setup Minecraft Server 26 | 27 | Create a singleplayer world and open it to LAN (ESC -> Open to LAN). The bot will connect using port 25565 and hostname localhost by default. 28 | 29 | For a more detailed setup guide, see the [README](README.md). 30 | 31 | ### 4. Configure Your MCP Client 32 | 33 | #### For Claude Desktop Users 34 | 35 | Make sure [Claude Desktop](https://claude.ai/download) is installed. Open your desktop config file via Claude Desktop: `File -> Settings -> Developer -> Edit Config` 36 | 37 | Alternatively, you can edit the config file directly: 38 | 39 | **MacOS/Linux** 40 | ```bash 41 | code ~/Library/Application\ Support/Claude/claude_desktop_config.json 42 | ``` 43 | 44 | **Windows** 45 | ```bash 46 | code $env:AppData\Claude\claude_desktop_config.json 47 | ``` 48 | 49 | #### For Other MCP Clients 50 | 51 | If you're using a different MCP client, configure it according to your client's documentation to point to the npx command and arguments shown below. 52 | 53 | ### 5. Point Your Client to Your Development Branch 54 | 55 | Update your MCP configuration to use your fork and branch: 56 | 57 | **For Claude Desktop (`claude_desktop_config.json`):** 58 | ```json 59 | { 60 | "mcpServers": { 61 | "minecraft": { 62 | "command": "npx", 63 | "args": [ 64 | "-y", 65 | "github:YOUR_USERNAME/minecraft-mcp-server#your-feature-branch-name", 66 | "--host", 67 | "localhost", 68 | "--port", 69 | "25565", 70 | "--username", 71 | "ClaudeBot" 72 | ] 73 | } 74 | } 75 | } 76 | ``` 77 | 78 | ### 6. Development Workflow 79 | 80 | 1. Make your changes and commit them to your feature branch 81 | 2. Push your branch to your fork: 82 | ```bash 83 | git push origin your-feature-name 84 | ``` 85 | 3. Restart your MCP client completely (for Claude Desktop, close from system tray) 86 | 4. Open your MCP client - it will automatically pull and run your latest changes 87 | 5. Test your changes through your client's chat interface 88 | 89 | ### 7. Debugging with MCP Inspector 90 | 91 | The [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is an excellent tool for debugging and testing your MCP server during development. It provides a user-friendly interface to: 92 | 93 | - Test individual tools without needing a full MCP client 94 | - Inspect requests and responses 95 | - Debug connection issues 96 | - Validate tool schemas and responses 97 | 98 | To use the MCP Inspector with your development branch, follow the guide at: https://modelcontextprotocol.io/docs/tools/inspector 99 | 100 | ## Submitting Changes 101 | 102 | Once you're happy with your changes: 103 | 1. Push your feature branch to your fork 104 | 2. Create a Pull Request from your fork to the main repository 105 | 3. Others can test your changes by pointing their config to your fork/branch 106 | -------------------------------------------------------------------------------- /tests/message-store.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { MessageStore } from '../src/message-store.js'; 3 | 4 | test('adds and retrieves messages', (t) => { 5 | const store = new MessageStore(); 6 | 7 | store.addMessage('player1', 'Hello world'); 8 | store.addMessage('player2', 'Hi there'); 9 | 10 | const messages = store.getRecentMessages(10); 11 | 12 | t.is(messages.length, 2); 13 | t.is(messages[0].username, 'player1'); 14 | t.is(messages[0].content, 'Hello world'); 15 | t.is(messages[1].username, 'player2'); 16 | t.is(messages[1].content, 'Hi there'); 17 | t.true(typeof messages[0].timestamp === 'number'); 18 | t.true(typeof messages[1].timestamp === 'number'); 19 | }); 20 | 21 | test('limits messages to maximum of 100', (t) => { 22 | const store = new MessageStore(); 23 | 24 | for (let i = 0; i < 150; i++) { 25 | store.addMessage(`player${i}`, `Message ${i}`); 26 | } 27 | 28 | const allMessages = store.getRecentMessages(200); 29 | 30 | t.is(allMessages.length, 100); 31 | t.is(allMessages[0].content, 'Message 50'); 32 | t.is(allMessages[99].content, 'Message 149'); 33 | }); 34 | 35 | test('getRecentMessages returns limited count', (t) => { 36 | const store = new MessageStore(); 37 | 38 | for (let i = 0; i < 20; i++) { 39 | store.addMessage('player', `Message ${i}`); 40 | } 41 | 42 | const messages = store.getRecentMessages(5); 43 | 44 | t.is(messages.length, 5); 45 | t.is(messages[0].content, 'Message 15'); 46 | t.is(messages[4].content, 'Message 19'); 47 | }); 48 | 49 | test('returns empty array when no messages', (t) => { 50 | const store = new MessageStore(); 51 | 52 | const messages = store.getRecentMessages(10); 53 | 54 | t.is(messages.length, 0); 55 | t.deepEqual(messages, []); 56 | }); 57 | 58 | test('getRecentMessages defaults to 10 messages', (t) => { 59 | const store = new MessageStore(); 60 | 61 | for (let i = 0; i < 20; i++) { 62 | store.addMessage('player', `Message ${i}`); 63 | } 64 | 65 | const messages = store.getRecentMessages(); 66 | 67 | t.is(messages.length, 10); 68 | t.is(messages[0].content, 'Message 10'); 69 | t.is(messages[9].content, 'Message 19'); 70 | }); 71 | 72 | test('getRecentMessages with count larger than stored messages', (t) => { 73 | const store = new MessageStore(); 74 | 75 | store.addMessage('player1', 'Message 1'); 76 | store.addMessage('player2', 'Message 2'); 77 | 78 | const messages = store.getRecentMessages(50); 79 | 80 | t.is(messages.length, 2); 81 | }); 82 | 83 | test('getMaxMessages returns 100', (t) => { 84 | const store = new MessageStore(); 85 | 86 | t.is(store.getMaxMessages(), 100); 87 | }); 88 | 89 | test('messages have increasing timestamps', (t) => { 90 | const store = new MessageStore(); 91 | 92 | store.addMessage('player1', 'First'); 93 | store.addMessage('player2', 'Second'); 94 | 95 | const messages = store.getRecentMessages(2); 96 | 97 | t.true(messages[1].timestamp >= messages[0].timestamp); 98 | }); 99 | 100 | test('handles empty username and content', (t) => { 101 | const store = new MessageStore(); 102 | 103 | store.addMessage('', ''); 104 | 105 | const messages = store.getRecentMessages(1); 106 | 107 | t.is(messages.length, 1); 108 | t.is(messages[0].username, ''); 109 | t.is(messages[0].content, ''); 110 | }); 111 | 112 | test('getRecentMessages with zero count', (t) => { 113 | const store = new MessageStore(); 114 | 115 | store.addMessage('player', 'Message'); 116 | 117 | const messages = store.getRecentMessages(0); 118 | 119 | t.is(messages.length, 0); 120 | }); 121 | -------------------------------------------------------------------------------- /tests/gamestate-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerGameStateTools } from '../src/tools/gamestate-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 6 | import type { BotConnection } from '../src/bot-connection.js'; 7 | import type mineflayer from 'mineflayer'; 8 | 9 | test('registerGameStateTools registers detect-gamemode tool', (t) => { 10 | const mockServer = { 11 | tool: sinon.stub() 12 | } as unknown as McpServer; 13 | const mockConnection = { 14 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 15 | } as unknown as BotConnection; 16 | const factory = new ToolFactory(mockServer, mockConnection); 17 | const mockBot = {} as Partial; 18 | const getBot = () => mockBot as mineflayer.Bot; 19 | 20 | registerGameStateTools(factory, getBot); 21 | 22 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 23 | const detectGamemodeCall = toolCalls.find(call => call.args[0] === 'detect-gamemode'); 24 | 25 | t.truthy(detectGamemodeCall); 26 | t.is(detectGamemodeCall!.args[1], 'Detect the gamemode on game'); 27 | }); 28 | 29 | test('detect-gamemode returns creative mode', async (t) => { 30 | const mockServer = { 31 | tool: sinon.stub() 32 | } as unknown as McpServer; 33 | const mockConnection = { 34 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 35 | } as unknown as BotConnection; 36 | const factory = new ToolFactory(mockServer, mockConnection); 37 | 38 | const mockBot = { 39 | game: { 40 | gameMode: 'creative' 41 | } 42 | } as Partial; 43 | const getBot = () => mockBot as mineflayer.Bot; 44 | 45 | registerGameStateTools(factory, getBot); 46 | 47 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 48 | const detectGamemodeCall = toolCalls.find(call => call.args[0] === 'detect-gamemode'); 49 | const executor = detectGamemodeCall!.args[3]; 50 | 51 | const result = await executor({}); 52 | 53 | t.true(result.content[0].text.includes('creative')); 54 | }); 55 | 56 | test('detect-gamemode returns survival mode', async (t) => { 57 | const mockServer = { 58 | tool: sinon.stub() 59 | } as unknown as McpServer; 60 | const mockConnection = { 61 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 62 | } as unknown as BotConnection; 63 | const factory = new ToolFactory(mockServer, mockConnection); 64 | 65 | const mockBot = { 66 | game: { 67 | gameMode: 'survival' 68 | } 69 | } as Partial; 70 | const getBot = () => mockBot as mineflayer.Bot; 71 | 72 | registerGameStateTools(factory, getBot); 73 | 74 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 75 | const detectGamemodeCall = toolCalls.find(call => call.args[0] === 'detect-gamemode'); 76 | const executor = detectGamemodeCall!.args[3]; 77 | 78 | const result = await executor({}); 79 | 80 | t.true(result.content[0].text.includes('survival')); 81 | }); 82 | 83 | test('detect-gamemode returns adventure mode', async (t) => { 84 | const mockServer = { 85 | tool: sinon.stub() 86 | } as unknown as McpServer; 87 | const mockConnection = { 88 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 89 | } as unknown as BotConnection; 90 | const factory = new ToolFactory(mockServer, mockConnection); 91 | 92 | const mockBot = { 93 | game: { 94 | gameMode: 'adventure' 95 | } 96 | } as Partial; 97 | const getBot = () => mockBot as mineflayer.Bot; 98 | 99 | registerGameStateTools(factory, getBot); 100 | 101 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 102 | const detectGamemodeCall = toolCalls.find(call => call.args[0] === 'detect-gamemode'); 103 | const executor = detectGamemodeCall!.args[3]; 104 | 105 | const result = await executor({}); 106 | 107 | t.true(result.content[0].text.includes('adventure')); 108 | }); 109 | -------------------------------------------------------------------------------- /tests/position-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerPositionTools } from '../src/tools/position-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import { BotConnection } from '../src/bot-connection.js'; 6 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import type mineflayer from 'mineflayer'; 8 | import { Vec3 } from 'vec3'; 9 | 10 | test('registerPositionTools registers get-position tool', (t) => { 11 | const mockServer = { 12 | tool: sinon.stub() 13 | } as unknown as McpServer; 14 | const mockConnection = { 15 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 16 | } as unknown as BotConnection; 17 | const factory = new ToolFactory(mockServer, mockConnection); 18 | const mockBot = {} as Partial; 19 | const getBot = () => mockBot as mineflayer.Bot; 20 | 21 | registerPositionTools(factory, getBot); 22 | 23 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 24 | const getPositionCall = toolCalls.find(call => call.args[0] === 'get-position'); 25 | 26 | t.truthy(getPositionCall); 27 | t.is(getPositionCall!.args[1], 'Get the current position of the bot'); 28 | }); 29 | 30 | test('registerPositionTools registers move-to-position tool', (t) => { 31 | const mockServer = { 32 | tool: sinon.stub() 33 | } as unknown as McpServer; 34 | const mockConnection = { 35 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 36 | } as unknown as BotConnection; 37 | const factory = new ToolFactory(mockServer, mockConnection); 38 | const mockBot = {} as Partial; 39 | const getBot = () => mockBot as mineflayer.Bot; 40 | 41 | registerPositionTools(factory, getBot); 42 | 43 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 44 | const moveToPositionCall = toolCalls.find(call => call.args[0] === 'move-to-position'); 45 | 46 | t.truthy(moveToPositionCall); 47 | t.is(moveToPositionCall!.args[1], 'Move the bot to a specific position'); 48 | }); 49 | 50 | test('get-position returns current bot position', async (t) => { 51 | const mockServer = { 52 | tool: sinon.stub() 53 | } as unknown as McpServer; 54 | const mockConnection = { 55 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 56 | } as unknown as BotConnection; 57 | const factory = new ToolFactory(mockServer, mockConnection); 58 | 59 | const mockBot = { 60 | entity: { 61 | position: new Vec3(100, 64, 200) 62 | } 63 | } as Partial; 64 | const getBot = () => mockBot as mineflayer.Bot; 65 | 66 | registerPositionTools(factory, getBot); 67 | 68 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 69 | const getPositionCall = toolCalls.find(call => call.args[0] === 'get-position'); 70 | const executor = getPositionCall!.args[3]; 71 | 72 | const result = await executor({}); 73 | 74 | t.true(result.content[0].text.includes('100')); 75 | t.true(result.content[0].text.includes('64')); 76 | t.true(result.content[0].text.includes('200')); 77 | }); 78 | 79 | test('move-to-position returns error when pathfinding fails', async (t) => { 80 | const mockServer = { 81 | tool: sinon.stub() 82 | } as unknown as McpServer; 83 | const mockConnection = { 84 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 85 | } as unknown as BotConnection; 86 | const factory = new ToolFactory(mockServer, mockConnection); 87 | 88 | const mockBot = { 89 | pathfinder: { 90 | goto: sinon.stub().rejects(new Error('Cannot find path')) 91 | } 92 | } as Partial; 93 | const getBot = () => mockBot as mineflayer.Bot; 94 | 95 | registerPositionTools(factory, getBot); 96 | 97 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 98 | const moveToPositionCall = toolCalls.find(call => call.args[0] === 'move-to-position'); 99 | const executor = moveToPositionCall!.args[3]; 100 | 101 | const result = await executor({ x: 100, y: 64, z: 200 }); 102 | 103 | t.true(result.isError); 104 | t.true(result.content[0].text.includes('Cannot find path')); 105 | }); 106 | -------------------------------------------------------------------------------- /tests/stdio-filter.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { setupStdioFiltering } from '../src/stdio-filter.js'; 3 | 4 | test('allows JSON messages to pass through', (t) => { 5 | const originalWrite = process.stdout.write; 6 | let capturedOutput = ''; 7 | 8 | process.stdout.write = ((chunk: string | Uint8Array) => { 9 | capturedOutput += chunk.toString(); 10 | return true; 11 | }) as typeof process.stdout.write; 12 | 13 | setupStdioFiltering(); 14 | 15 | const jsonMessage = '{"jsonrpc":"2.0","id":1,"method":"test"}'; 16 | process.stdout.write(jsonMessage); 17 | 18 | t.is(capturedOutput, jsonMessage); 19 | 20 | process.stdout.write = originalWrite; 21 | }); 22 | 23 | test('allows timestamp log messages to pass through', (t) => { 24 | const originalWrite = process.stdout.write; 25 | let capturedOutput = ''; 26 | 27 | process.stdout.write = ((chunk: string | Uint8Array) => { 28 | capturedOutput += chunk.toString(); 29 | return true; 30 | }) as typeof process.stdout.write; 31 | 32 | setupStdioFiltering(); 33 | 34 | const logMessage = '2025-11-05T19:45:29.842Z [minecraft] [mcp-server] [info] Bot connected\n'; 35 | process.stdout.write(logMessage); 36 | 37 | t.is(capturedOutput, logMessage); 38 | 39 | process.stdout.write = originalWrite; 40 | }); 41 | 42 | test('allows newline-only messages to pass through', (t) => { 43 | const originalWrite = process.stdout.write; 44 | let capturedOutput = ''; 45 | 46 | process.stdout.write = ((chunk: string | Uint8Array) => { 47 | capturedOutput += chunk.toString(); 48 | return true; 49 | }) as typeof process.stdout.write; 50 | 51 | setupStdioFiltering(); 52 | 53 | process.stdout.write('\n'); 54 | process.stdout.write('\r\n'); 55 | 56 | t.is(capturedOutput, '\n\r\n'); 57 | 58 | process.stdout.write = originalWrite; 59 | }); 60 | 61 | test('filters out random debug messages', (t) => { 62 | const originalWrite = process.stdout.write; 63 | let capturedOutput = ''; 64 | 65 | process.stdout.write = ((chunk: string | Uint8Array) => { 66 | capturedOutput += chunk.toString(); 67 | return true; 68 | }) as typeof process.stdout.write; 69 | 70 | setupStdioFiltering(); 71 | 72 | process.stdout.write('Minecraft bot debug message'); 73 | process.stdout.write('Some random output'); 74 | 75 | t.is(capturedOutput, ''); 76 | 77 | process.stdout.write = originalWrite; 78 | }); 79 | 80 | test('filters minecraft-protodef library output', (t) => { 81 | const originalWrite = process.stdout.write; 82 | let capturedOutput = ''; 83 | 84 | process.stdout.write = ((chunk: string | Uint8Array) => { 85 | capturedOutput += chunk.toString(); 86 | return true; 87 | }) as typeof process.stdout.write; 88 | 89 | setupStdioFiltering(); 90 | 91 | process.stdout.write('Loading minecraft protocol version 1.20.4'); 92 | process.stdout.write('[protodef] Packet received'); 93 | 94 | t.is(capturedOutput, ''); 95 | 96 | process.stdout.write = originalWrite; 97 | }); 98 | 99 | test('allows JSON while filtering other messages', (t) => { 100 | const originalWrite = process.stdout.write; 101 | let capturedOutput = ''; 102 | 103 | process.stdout.write = ((chunk: string | Uint8Array) => { 104 | capturedOutput += chunk.toString(); 105 | return true; 106 | }) as typeof process.stdout.write; 107 | 108 | setupStdioFiltering(); 109 | 110 | process.stdout.write('Random message'); 111 | process.stdout.write('{"jsonrpc":"2.0","result":"success"}'); 112 | process.stdout.write('More random output'); 113 | 114 | t.is(capturedOutput, '{"jsonrpc":"2.0","result":"success"}'); 115 | 116 | process.stdout.write = originalWrite; 117 | }); 118 | 119 | test('suppresses console.error output', (t) => { 120 | setupStdioFiltering(); 121 | 122 | t.notThrows(() => { 123 | console.error('This should be suppressed'); 124 | console.error('No errors thrown'); 125 | }); 126 | }); 127 | 128 | test('console.error becomes a no-op function', (t) => { 129 | const originalError = console.error; 130 | 131 | setupStdioFiltering(); 132 | 133 | const result = console.error('test'); 134 | 135 | t.is(result, undefined); 136 | 137 | console.error = originalError; 138 | }); 139 | -------------------------------------------------------------------------------- /tests/flight-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerFlightTools } from '../src/tools/flight-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import { BotConnection } from '../src/bot-connection.js'; 6 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import type mineflayer from 'mineflayer'; 8 | import { Vec3 } from 'vec3'; 9 | 10 | test('registerFlightTools registers fly-to tool', (t) => { 11 | const mockServer = { 12 | tool: sinon.stub() 13 | } as unknown as McpServer; 14 | const mockConnection = { 15 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 16 | } as unknown as BotConnection; 17 | const factory = new ToolFactory(mockServer, mockConnection); 18 | const mockBot = {} as Partial; 19 | const getBot = () => mockBot as mineflayer.Bot; 20 | 21 | registerFlightTools(factory, getBot); 22 | 23 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 24 | const flyToCall = toolCalls.find(call => call.args[0] === 'fly-to'); 25 | 26 | t.truthy(flyToCall); 27 | t.is(flyToCall!.args[1], 'Make the bot fly to a specific position'); 28 | }); 29 | 30 | test('fly-to successfully flies to destination', async (t) => { 31 | const mockServer = { 32 | tool: sinon.stub() 33 | } as unknown as McpServer; 34 | const mockConnection = { 35 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 36 | } as unknown as BotConnection; 37 | const factory = new ToolFactory(mockServer, mockConnection); 38 | 39 | const flyToStub = sinon.stub().resolves(); 40 | const stopFlyingStub = sinon.stub(); 41 | const mockBot = { 42 | creative: { 43 | flyTo: flyToStub, 44 | stopFlying: stopFlyingStub 45 | }, 46 | entity: { 47 | position: new Vec3(0, 64, 0) 48 | } 49 | } as unknown as mineflayer.Bot; 50 | const getBot = () => mockBot; 51 | 52 | registerFlightTools(factory, getBot); 53 | 54 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 55 | const flyToCall = toolCalls.find(call => call.args[0] === 'fly-to'); 56 | const executor = flyToCall!.args[3]; 57 | 58 | const result = await executor({ x: 100, y: 80, z: 200 }); 59 | 60 | t.true(flyToStub.calledOnce); 61 | t.true(stopFlyingStub.calledOnce); 62 | t.true(result.content[0].text.includes('Successfully flew')); 63 | t.true(result.content[0].text.includes('100')); 64 | }); 65 | 66 | test('fly-to returns error when creative mode not available', async (t) => { 67 | const mockServer = { 68 | tool: sinon.stub() 69 | } as unknown as McpServer; 70 | const mockConnection = { 71 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 72 | } as unknown as BotConnection; 73 | const factory = new ToolFactory(mockServer, mockConnection); 74 | 75 | const mockBot = { 76 | creative: null 77 | } as unknown as mineflayer.Bot; 78 | const getBot = () => mockBot; 79 | 80 | registerFlightTools(factory, getBot); 81 | 82 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 83 | const flyToCall = toolCalls.find(call => call.args[0] === 'fly-to'); 84 | const executor = flyToCall!.args[3]; 85 | 86 | const result = await executor({ x: 100, y: 80, z: 200 }); 87 | 88 | t.true(result.content[0].text.includes('Creative mode is not available')); 89 | }); 90 | 91 | test('fly-to handles flight errors', async (t) => { 92 | const mockServer = { 93 | tool: sinon.stub() 94 | } as unknown as McpServer; 95 | const mockConnection = { 96 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 97 | } as unknown as BotConnection; 98 | const factory = new ToolFactory(mockServer, mockConnection); 99 | 100 | const flyToStub = sinon.stub().rejects(new Error('Cannot reach destination')); 101 | const stopFlyingStub = sinon.stub(); 102 | const mockBot = { 103 | creative: { 104 | flyTo: flyToStub, 105 | stopFlying: stopFlyingStub 106 | }, 107 | entity: { 108 | position: new Vec3(0, 64, 0) 109 | } 110 | } as unknown as mineflayer.Bot; 111 | const getBot = () => mockBot; 112 | 113 | registerFlightTools(factory, getBot); 114 | 115 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 116 | const flyToCall = toolCalls.find(call => call.args[0] === 'fly-to'); 117 | const executor = flyToCall!.args[3]; 118 | 119 | const result = await executor({ x: 100, y: 80, z: 200 }); 120 | 121 | t.true(result.isError); 122 | t.true(result.content[0].text.includes('Cannot reach destination')); 123 | }); 124 | -------------------------------------------------------------------------------- /tests/logger.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { log } from '../src/logger.js'; 3 | 4 | test('log writes to stderr', (t) => { 5 | const originalWrite = process.stderr.write; 6 | let capturedOutput = ''; 7 | 8 | process.stderr.write = ((chunk: string | Uint8Array) => { 9 | capturedOutput += chunk.toString(); 10 | return true; 11 | }) as typeof process.stderr.write; 12 | 13 | log('info', 'Test message'); 14 | 15 | t.true(capturedOutput.length > 0); 16 | t.true(capturedOutput.includes('Test message')); 17 | 18 | process.stderr.write = originalWrite; 19 | }); 20 | 21 | test('log format includes all required components', (t) => { 22 | const originalWrite = process.stderr.write; 23 | let capturedOutput = ''; 24 | 25 | process.stderr.write = ((chunk: string | Uint8Array) => { 26 | capturedOutput += chunk.toString(); 27 | return true; 28 | }) as typeof process.stderr.write; 29 | 30 | log('error', 'Connection failed'); 31 | 32 | t.true(capturedOutput.includes('[minecraft]')); 33 | t.true(capturedOutput.includes('[mcp-server]')); 34 | t.true(capturedOutput.includes('[error]')); 35 | t.true(capturedOutput.includes('Connection failed')); 36 | t.true(capturedOutput.endsWith('\n')); 37 | 38 | process.stderr.write = originalWrite; 39 | }); 40 | 41 | test('log includes ISO timestamp', (t) => { 42 | const originalWrite = process.stderr.write; 43 | let capturedOutput = ''; 44 | 45 | process.stderr.write = ((chunk: string | Uint8Array) => { 46 | capturedOutput += chunk.toString(); 47 | return true; 48 | }) as typeof process.stderr.write; 49 | 50 | const beforeTime = new Date().toISOString(); 51 | log('info', 'Timing test'); 52 | const afterTime = new Date().toISOString(); 53 | 54 | const timestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/; 55 | t.true(timestampRegex.test(capturedOutput)); 56 | 57 | const timestamp = capturedOutput.match(timestampRegex)?.[0]; 58 | t.true(timestamp !== undefined); 59 | t.true(timestamp! >= beforeTime.substring(0, 19)); 60 | t.true(timestamp! <= afterTime); 61 | 62 | process.stderr.write = originalWrite; 63 | }); 64 | 65 | test('log handles different log levels', (t) => { 66 | const originalWrite = process.stderr.write; 67 | const outputs: string[] = []; 68 | 69 | process.stderr.write = ((chunk: string | Uint8Array) => { 70 | outputs.push(chunk.toString()); 71 | return true; 72 | }) as typeof process.stderr.write; 73 | 74 | log('info', 'Info message'); 75 | log('warn', 'Warning message'); 76 | log('error', 'Error message'); 77 | log('debug', 'Debug message'); 78 | 79 | t.is(outputs.length, 4); 80 | t.true(outputs[0].includes('[info]')); 81 | t.true(outputs[1].includes('[warn]')); 82 | t.true(outputs[2].includes('[error]')); 83 | t.true(outputs[3].includes('[debug]')); 84 | 85 | process.stderr.write = originalWrite; 86 | }); 87 | 88 | test('log handles empty message', (t) => { 89 | const originalWrite = process.stderr.write; 90 | let capturedOutput = ''; 91 | 92 | process.stderr.write = ((chunk: string | Uint8Array) => { 93 | capturedOutput += chunk.toString(); 94 | return true; 95 | }) as typeof process.stderr.write; 96 | 97 | log('info', ''); 98 | 99 | t.true(capturedOutput.includes('[minecraft]')); 100 | t.true(capturedOutput.includes('[info]')); 101 | t.true(capturedOutput.endsWith(' \n')); 102 | 103 | process.stderr.write = originalWrite; 104 | }); 105 | 106 | test('log handles special characters in message', (t) => { 107 | const originalWrite = process.stderr.write; 108 | let capturedOutput = ''; 109 | 110 | process.stderr.write = ((chunk: string | Uint8Array) => { 111 | capturedOutput += chunk.toString(); 112 | return true; 113 | }) as typeof process.stderr.write; 114 | 115 | const specialMessage = 'Error: {json: "value"} & "quotes" \'apostrophes\''; 116 | log('error', specialMessage); 117 | 118 | t.true(capturedOutput.includes(specialMessage)); 119 | t.true(capturedOutput.includes('{json: "value"}')); 120 | t.true(capturedOutput.includes('')); 121 | 122 | process.stderr.write = originalWrite; 123 | }); 124 | 125 | test('log handles multiline message', (t) => { 126 | const originalWrite = process.stderr.write; 127 | let capturedOutput = ''; 128 | 129 | process.stderr.write = ((chunk: string | Uint8Array) => { 130 | capturedOutput += chunk.toString(); 131 | return true; 132 | }) as typeof process.stderr.write; 133 | 134 | const multilineMessage = 'Line 1\nLine 2\nLine 3'; 135 | log('info', multilineMessage); 136 | 137 | t.true(capturedOutput.includes(multilineMessage)); 138 | t.true(capturedOutput.includes('Line 1\nLine 2\nLine 3')); 139 | 140 | process.stderr.write = originalWrite; 141 | }); 142 | 143 | test('log output format is consistent', (t) => { 144 | const originalWrite = process.stderr.write; 145 | let capturedOutput = ''; 146 | 147 | process.stderr.write = ((chunk: string | Uint8Array) => { 148 | capturedOutput += chunk.toString(); 149 | return true; 150 | }) as typeof process.stderr.write; 151 | 152 | log('info', 'Test'); 153 | 154 | const expectedPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z \[minecraft\] \[mcp-server\] \[info\] Test\n$/; 155 | t.true(expectedPattern.test(capturedOutput)); 156 | 157 | process.stderr.write = originalWrite; 158 | }); 159 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Minecraft MCP Server 2 | 3 | 4 | CI 5 | 6 | 7 | Contribution Welcome 8 | 9 | 10 | Latest Release 11 | 12 | 13 | image 14 | 15 | ___ 16 | 17 | > [!IMPORTANT] 18 | > Currently supports Minecraft version 1.21.8. Newer versions may not work with this MCP server, but we will add support as soon as possible. 19 | 20 | https://github.com/user-attachments/assets/6f17f329-3991-4bc7-badd-7cde9aacb92f 21 | 22 | A Minecraft bot powered by large language models and [Mineflayer API](https://github.com/PrismarineJS/mineflayer). This bot uses the [Model Context Protocol](https://github.com/modelcontextprotocol) (MCP) to enable Claude and other supported models to control a Minecraft character. 23 | 24 | 25 | mcp-minecraft MCP server 26 | 27 | 28 | ## Prerequisites 29 | 30 | - Git 31 | - Node.js (>= 20.10.0) 32 | - A running Minecraft game (the setup below was tested with Minecraft 1.21.8 Java Edition included in Microsoft Game Pass) 33 | - An MCP-compatible client. Claude Desktop will be used as an example, but other MCP clients are also supported 34 | 35 | ## Getting started 36 | 37 | This bot is designed to be used with Claude Desktop through the Model Context Protocol (MCP). 38 | 39 | ### Run Minecraft 40 | 41 | Create a singleplayer world and open it to LAN (`ESC -> Open to LAN`). Bot will try to connect using port `25565` and hostname `localhost`. These parameters could be configured in `claude_desktop_config.json` on a next step. 42 | 43 | ### MCP Configuration 44 | 45 | Make sure that [Claude Desktop](https://claude.ai/download) is installed. Open `File -> Settings -> Developer -> Edit Config`. It should open installation directory. Find file with a name `claude_desktop_config.json` and insert the following code: 46 | 47 | ```json 48 | { 49 | "mcpServers": { 50 | "minecraft": { 51 | "command": "npx", 52 | "args": [ 53 | "-y", 54 | "github:yuniko-software/minecraft-mcp-server", 55 | "--host", 56 | "localhost", 57 | "--port", 58 | "25565", 59 | "--username", 60 | "ClaudeBot" 61 | ] 62 | } 63 | } 64 | } 65 | ``` 66 | 67 | Double-check that right `--port` and `--host` parameters were used. Make sure to completely reboot the Claude Desktop application (should be closed in OS tray). 68 | 69 | ## Running 70 | 71 | Make sure Minecraft game is running and the world is opened to LAN. Then start Claude Desktop application and the bot should join the game. 72 | 73 | **It could take some time for Claude Desktop to boot the MCP server**. The marker that the server has booted successfully: 74 | 75 | image 76 | 77 | You can give bot any commands through any active Claude Desktop chat. You can also upload images of buildings and ask bot to build them 😁 78 | 79 | Don't forget to mention that bot should do something in Minecraft in your prompt. Because saying this is a trigger to run MCP server. It will ask for your permissions. 80 | 81 | Using Claude Sonnet could give you some interesting results. The bot-agent would be really smart 🫡 82 | 83 | Example usage: [shared Claude chat](https://claude.ai/share/535d5f69-f102-4cdb-9801-f74ea5709c0b) 84 | 85 | ## Available Commands 86 | 87 | Once connected to a Minecraft server, Claude can use these commands: 88 | 89 | ### Movement 90 | - `get-position` - Get the current position of the bot 91 | - `move-to-position` - Move to specific coordinates 92 | - `look-at` - Make the bot look at specific coordinates 93 | - `jump` - Make the bot jump 94 | - `move-in-direction` - Move in a specific direction for a duration 95 | 96 | ### Flight 97 | - `fly-to` - Make the bot fly directly to specific coordinates 98 | 99 | ### Inventory 100 | - `list-inventory` - List all items in the bot's inventory 101 | - `find-item` - Find a specific item in inventory 102 | - `equip-item` - Equip a specific item 103 | 104 | ### Block Interaction 105 | - `place-block` - Place a block at specified coordinates 106 | - `dig-block` - Dig a block at specified coordinates 107 | - `get-block-info` - Get information about a block 108 | - `find-block` - Find the nearest block of a specific type 109 | 110 | ### Entity Interaction 111 | - `find-entity` - Find the nearest entity of a specific type 112 | 113 | ### Communication 114 | - `send-chat` - Send a chat message in-game 115 | - `read-chat` - Get recent chat messages from players 116 | 117 | ### Game State 118 | - `detect-gamemode` - Detect the gamemode on game 119 | 120 | ## Contributing 121 | 122 | Feel free to submit pull requests or open issues for improvements. All refactoring commits, functional and test contributions, issues and discussion are greatly appreciated! 123 | 124 | To get started with contributing, please see [CONTRIBUTING.md](CONTRIBUTING.md). 125 | 126 | --- 127 | 128 | ⭐ If you find this project useful, please consider giving it a star on GitHub! ⭐ 129 | 130 | Your support helps make this project more visible to other people who might benefit from it. 131 | -------------------------------------------------------------------------------- /src/tools/block-tools.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | import mineflayer from 'mineflayer'; 3 | import pathfinderPkg from 'mineflayer-pathfinder'; 4 | const { goals } = pathfinderPkg; 5 | import { Vec3 } from 'vec3'; 6 | import minecraftData from 'minecraft-data'; 7 | import { ToolFactory } from '../tool-factory.js'; 8 | import { log } from '../logger.js'; 9 | 10 | type FaceDirection = 'up' | 'down' | 'north' | 'south' | 'east' | 'west'; 11 | 12 | interface FaceOption { 13 | direction: string; 14 | vector: Vec3; 15 | } 16 | 17 | export function registerBlockTools(factory: ToolFactory, getBot: () => mineflayer.Bot): void { 18 | factory.registerTool( 19 | "place-block", 20 | "Place a block at the specified position", 21 | { 22 | x: z.number().describe("X coordinate"), 23 | y: z.number().describe("Y coordinate"), 24 | z: z.number().describe("Z coordinate"), 25 | faceDirection: z.enum(['up', 'down', 'north', 'south', 'east', 'west']).optional().describe("Direction to place against (default: 'down')") 26 | }, 27 | async ({ x, y, z, faceDirection = 'down' }: { x: number, y: number, z: number, faceDirection?: FaceDirection }) => { 28 | const bot = getBot(); 29 | const placePos = new Vec3(x, y, z); 30 | const blockAtPos = bot.blockAt(placePos); 31 | 32 | if (blockAtPos && blockAtPos.name !== 'air') { 33 | return factory.createResponse(`There's already a block (${blockAtPos.name}) at (${x}, ${y}, ${z})`); 34 | } 35 | 36 | const possibleFaces: FaceOption[] = [ 37 | { direction: 'down', vector: new Vec3(0, -1, 0) }, 38 | { direction: 'north', vector: new Vec3(0, 0, -1) }, 39 | { direction: 'south', vector: new Vec3(0, 0, 1) }, 40 | { direction: 'east', vector: new Vec3(1, 0, 0) }, 41 | { direction: 'west', vector: new Vec3(-1, 0, 0) }, 42 | { direction: 'up', vector: new Vec3(0, 1, 0) } 43 | ]; 44 | 45 | if (faceDirection !== 'down') { 46 | const specificFace = possibleFaces.find(face => face.direction === faceDirection); 47 | if (specificFace) { 48 | possibleFaces.unshift(possibleFaces.splice(possibleFaces.indexOf(specificFace), 1)[0]); 49 | } 50 | } 51 | 52 | for (const face of possibleFaces) { 53 | const referencePos = placePos.plus(face.vector); 54 | const referenceBlock = bot.blockAt(referencePos); 55 | 56 | if (referenceBlock && referenceBlock.name !== 'air') { 57 | if (!bot.canSeeBlock(referenceBlock)) { 58 | const goal = new goals.GoalNear(referencePos.x, referencePos.y, referencePos.z, 2); 59 | await bot.pathfinder.goto(goal); 60 | } 61 | 62 | await bot.lookAt(placePos, true); 63 | 64 | try { 65 | await bot.placeBlock(referenceBlock, face.vector.scaled(-1)); 66 | return factory.createResponse(`Placed block at (${x}, ${y}, ${z}) using ${face.direction} face`); 67 | } catch (placeError) { 68 | log('warn', `Failed to place using ${face.direction} face: ${placeError}`); 69 | continue; 70 | } 71 | } 72 | } 73 | 74 | return factory.createResponse(`Failed to place block at (${x}, ${y}, ${z}): No suitable reference block found`); 75 | } 76 | ); 77 | 78 | factory.registerTool( 79 | "dig-block", 80 | "Dig a block at the specified position", 81 | { 82 | x: z.number().describe("X coordinate"), 83 | y: z.number().describe("Y coordinate"), 84 | z: z.number().describe("Z coordinate"), 85 | }, 86 | async ({ x, y, z }) => { 87 | const bot = getBot(); 88 | const blockPos = new Vec3(x, y, z); 89 | const block = bot.blockAt(blockPos); 90 | 91 | if (!block || block.name === 'air') { 92 | return factory.createResponse(`No block found at position (${x}, ${y}, ${z})`); 93 | } 94 | 95 | if (!bot.canDigBlock(block) || !bot.canSeeBlock(block)) { 96 | const goal = new goals.GoalNear(x, y, z, 2); 97 | await bot.pathfinder.goto(goal); 98 | } 99 | 100 | await bot.dig(block); 101 | return factory.createResponse(`Dug ${block.name} at (${x}, ${y}, ${z})`); 102 | } 103 | ); 104 | 105 | factory.registerTool( 106 | "get-block-info", 107 | "Get information about a block at the specified position", 108 | { 109 | x: z.number().describe("X coordinate"), 110 | y: z.number().describe("Y coordinate"), 111 | z: z.number().describe("Z coordinate"), 112 | }, 113 | async ({ x, y, z }) => { 114 | const bot = getBot(); 115 | const blockPos = new Vec3(x, y, z); 116 | const block = bot.blockAt(blockPos); 117 | 118 | if (!block) { 119 | return factory.createResponse(`No block information found at position (${x}, ${y}, ${z})`); 120 | } 121 | 122 | return factory.createResponse(`Found ${block.name} (type: ${block.type}) at position (${block.position.x}, ${block.position.y}, ${block.position.z})`); 123 | } 124 | ); 125 | 126 | factory.registerTool( 127 | "find-block", 128 | "Find the nearest block of a specific type", 129 | { 130 | blockType: z.string().describe("Type of block to find"), 131 | maxDistance: z.number().optional().describe("Maximum search distance (default: 16)") 132 | }, 133 | async ({ blockType, maxDistance = 16 }) => { 134 | const bot = getBot(); 135 | const mcData = minecraftData(bot.version); 136 | const blocksByName = mcData.blocksByName; 137 | 138 | if (!blocksByName[blockType]) { 139 | return factory.createResponse(`Unknown block type: ${blockType}`); 140 | } 141 | 142 | const blockId = blocksByName[blockType].id; 143 | 144 | const block = bot.findBlock({ 145 | matching: blockId, 146 | maxDistance: maxDistance 147 | }); 148 | 149 | if (!block) { 150 | return factory.createResponse(`No ${blockType} found within ${maxDistance} blocks`); 151 | } 152 | 153 | return factory.createResponse(`Found ${blockType} at position (${block.position.x}, ${block.position.y}, ${block.position.z})`); 154 | } 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /tests/inventory-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerInventoryTools } from '../src/tools/inventory-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import { BotConnection } from '../src/bot-connection.js'; 6 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import type mineflayer from 'mineflayer'; 8 | 9 | test('registerInventoryTools registers list-inventory tool', (t) => { 10 | const mockServer = { 11 | tool: sinon.stub() 12 | } as unknown as McpServer; 13 | const mockConnection = { 14 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 15 | } as unknown as BotConnection; 16 | const factory = new ToolFactory(mockServer, mockConnection); 17 | const mockBot = {} as Partial; 18 | const getBot = () => mockBot as mineflayer.Bot; 19 | 20 | registerInventoryTools(factory, getBot); 21 | 22 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 23 | const listInventoryCall = toolCalls.find(call => call.args[0] === 'list-inventory'); 24 | 25 | t.truthy(listInventoryCall); 26 | t.is(listInventoryCall!.args[1], 'List all items in the bot\'s inventory'); 27 | }); 28 | 29 | test('registerInventoryTools registers equip-item tool', (t) => { 30 | const mockServer = { 31 | tool: sinon.stub() 32 | } as unknown as McpServer; 33 | const mockConnection = { 34 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 35 | } as unknown as BotConnection; 36 | const factory = new ToolFactory(mockServer, mockConnection); 37 | const mockBot = {} as Partial; 38 | const getBot = () => mockBot as mineflayer.Bot; 39 | 40 | registerInventoryTools(factory, getBot); 41 | 42 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 43 | const equipItemCall = toolCalls.find(call => call.args[0] === 'equip-item'); 44 | 45 | t.truthy(equipItemCall); 46 | t.is(equipItemCall!.args[1], 'Equip a specific item'); 47 | }); 48 | 49 | test('list-inventory returns empty when no items', async (t) => { 50 | const mockServer = { 51 | tool: sinon.stub() 52 | } as unknown as McpServer; 53 | const mockConnection = { 54 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 55 | } as unknown as BotConnection; 56 | const factory = new ToolFactory(mockServer, mockConnection); 57 | 58 | const mockBot = { 59 | inventory: { 60 | items: () => [] 61 | } 62 | } as unknown as mineflayer.Bot; 63 | const getBot = () => mockBot; 64 | 65 | registerInventoryTools(factory, getBot); 66 | 67 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 68 | const listInventoryCall = toolCalls.find(call => call.args[0] === 'list-inventory'); 69 | const executor = listInventoryCall!.args[3]; 70 | 71 | const result = await executor({}); 72 | 73 | t.true(result.content[0].text.includes('empty')); 74 | }); 75 | 76 | test('list-inventory returns items with counts', async (t) => { 77 | const mockServer = { 78 | tool: sinon.stub() 79 | } as unknown as McpServer; 80 | const mockConnection = { 81 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 82 | } as unknown as BotConnection; 83 | const factory = new ToolFactory(mockServer, mockConnection); 84 | 85 | const mockBot = { 86 | inventory: { 87 | items: () => [ 88 | { name: 'diamond_pickaxe', count: 1, slot: 0 }, 89 | { name: 'cobblestone', count: 64, slot: 1 } 90 | ] 91 | } 92 | } as unknown as mineflayer.Bot; 93 | const getBot = () => mockBot; 94 | 95 | registerInventoryTools(factory, getBot); 96 | 97 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 98 | const listInventoryCall = toolCalls.find(call => call.args[0] === 'list-inventory'); 99 | const executor = listInventoryCall!.args[3]; 100 | 101 | const result = await executor({}); 102 | 103 | t.true(result.content[0].text.includes('diamond_pickaxe')); 104 | t.true(result.content[0].text.includes('cobblestone')); 105 | t.true(result.content[0].text.includes('64')); 106 | }); 107 | 108 | test('equip-item calls bot.equip', async (t) => { 109 | const mockServer = { 110 | tool: sinon.stub() 111 | } as unknown as McpServer; 112 | const mockConnection = { 113 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 114 | } as unknown as BotConnection; 115 | const factory = new ToolFactory(mockServer, mockConnection); 116 | 117 | const equipStub = sinon.stub().resolves(); 118 | const mockBot = { 119 | inventory: { 120 | items: () => [ 121 | { name: 'diamond_sword', type: 1 } 122 | ] 123 | }, 124 | equip: equipStub 125 | } as unknown as mineflayer.Bot; 126 | const getBot = () => mockBot; 127 | 128 | registerInventoryTools(factory, getBot); 129 | 130 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 131 | const equipItemCall = toolCalls.find(call => call.args[0] === 'equip-item'); 132 | const executor = equipItemCall!.args[3]; 133 | 134 | const result = await executor({ itemName: 'diamond_sword', destination: 'hand' }); 135 | 136 | t.true(equipStub.calledOnce); 137 | t.true(result.content[0].text.includes('Equipped')); 138 | t.true(result.content[0].text.includes('diamond_sword')); 139 | }); 140 | 141 | test('equip-item returns message when item not found', async (t) => { 142 | const mockServer = { 143 | tool: sinon.stub() 144 | } as unknown as McpServer; 145 | const mockConnection = { 146 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 147 | } as unknown as BotConnection; 148 | const factory = new ToolFactory(mockServer, mockConnection); 149 | 150 | const mockBot = { 151 | inventory: { 152 | items: () => [] 153 | } 154 | } as unknown as mineflayer.Bot; 155 | const getBot = () => mockBot; 156 | 157 | registerInventoryTools(factory, getBot); 158 | 159 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 160 | const equipItemCall = toolCalls.find(call => call.args[0] === 'equip-item'); 161 | const executor = equipItemCall!.args[3]; 162 | 163 | const result = await executor({ itemName: 'diamond_sword', destination: 'hand' }); 164 | 165 | t.true(result.content[0].text.includes('Couldn\'t find')); 166 | }); 167 | -------------------------------------------------------------------------------- /tests/entity-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerEntityTools } from '../src/tools/entity-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 6 | import type { BotConnection } from '../src/bot-connection.js'; 7 | import type mineflayer from 'mineflayer'; 8 | import { Vec3 } from 'vec3'; 9 | 10 | test('registerEntityTools registers find-entity tool', (t) => { 11 | const mockServer = { 12 | tool: sinon.stub() 13 | } as unknown as McpServer; 14 | const mockConnection = { 15 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 16 | } as unknown as BotConnection; 17 | const factory = new ToolFactory(mockServer, mockConnection); 18 | const mockBot = {} as Partial; 19 | const getBot = () => mockBot as mineflayer.Bot; 20 | 21 | registerEntityTools(factory, getBot); 22 | 23 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 24 | const findEntityCall = toolCalls.find(call => call.args[0] === 'find-entity'); 25 | 26 | t.truthy(findEntityCall); 27 | t.is(findEntityCall!.args[1], 'Find the nearest entity of a specific type'); 28 | }); 29 | 30 | test('find-entity returns entity when found', async (t) => { 31 | const mockServer = { 32 | tool: sinon.stub() 33 | } as unknown as McpServer; 34 | const mockConnection = { 35 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 36 | } as unknown as BotConnection; 37 | const factory = new ToolFactory(mockServer, mockConnection); 38 | 39 | const mockEntity = { 40 | name: 'zombie', 41 | type: 'mob', 42 | position: new Vec3(5, 64, 8) 43 | }; 44 | const mockBot = { 45 | entity: { 46 | position: new Vec3(0, 64, 0) 47 | }, 48 | nearestEntity: sinon.stub().returns(mockEntity) 49 | } as unknown as mineflayer.Bot; 50 | const getBot = () => mockBot; 51 | 52 | registerEntityTools(factory, getBot); 53 | 54 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 55 | const findEntityCall = toolCalls.find(call => call.args[0] === 'find-entity'); 56 | const executor = findEntityCall!.args[3]; 57 | 58 | const result = await executor({ type: 'zombie', maxDistance: 16 }); 59 | 60 | t.true(result.content[0].text.includes('zombie')); 61 | t.true(result.content[0].text.includes('5')); 62 | t.true(result.content[0].text.includes('64')); 63 | t.true(result.content[0].text.includes('8')); 64 | }); 65 | 66 | test('find-entity returns not found when entity too far', async (t) => { 67 | const mockServer = { 68 | tool: sinon.stub() 69 | } as unknown as McpServer; 70 | const mockConnection = { 71 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 72 | } as unknown as BotConnection; 73 | const factory = new ToolFactory(mockServer, mockConnection); 74 | 75 | const mockEntity = { 76 | name: 'zombie', 77 | type: 'mob', 78 | position: new Vec3(100, 64, 100) 79 | }; 80 | const mockBot = { 81 | entity: { 82 | position: new Vec3(0, 64, 0) 83 | }, 84 | nearestEntity: sinon.stub().returns(mockEntity) 85 | } as unknown as mineflayer.Bot; 86 | const getBot = () => mockBot; 87 | 88 | registerEntityTools(factory, getBot); 89 | 90 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 91 | const findEntityCall = toolCalls.find(call => call.args[0] === 'find-entity'); 92 | const executor = findEntityCall!.args[3]; 93 | 94 | const result = await executor({ type: 'zombie', maxDistance: 16 }); 95 | 96 | t.true(result.content[0].text.includes('No zombie found within 16 blocks')); 97 | }); 98 | 99 | test('find-entity returns not found when no entity exists', async (t) => { 100 | const mockServer = { 101 | tool: sinon.stub() 102 | } as unknown as McpServer; 103 | const mockConnection = { 104 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 105 | } as unknown as BotConnection; 106 | const factory = new ToolFactory(mockServer, mockConnection); 107 | 108 | const mockBot = { 109 | entity: { 110 | position: new Vec3(0, 64, 0) 111 | }, 112 | nearestEntity: sinon.stub().returns(null) 113 | } as unknown as mineflayer.Bot; 114 | const getBot = () => mockBot; 115 | 116 | registerEntityTools(factory, getBot); 117 | 118 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 119 | const findEntityCall = toolCalls.find(call => call.args[0] === 'find-entity'); 120 | const executor = findEntityCall!.args[3]; 121 | 122 | const result = await executor({ type: 'zombie', maxDistance: 16 }); 123 | 124 | t.true(result.content[0].text.includes('No zombie found')); 125 | }); 126 | 127 | test('find-entity handles player type', async (t) => { 128 | const mockServer = { 129 | tool: sinon.stub() 130 | } as unknown as McpServer; 131 | const mockConnection = { 132 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 133 | } as unknown as BotConnection; 134 | const factory = new ToolFactory(mockServer, mockConnection); 135 | 136 | const mockEntity = { 137 | username: 'TestPlayer', 138 | type: 'player', 139 | position: new Vec3(5, 64, 5) 140 | }; 141 | const mockBot = { 142 | entity: { 143 | position: new Vec3(0, 64, 0) 144 | }, 145 | nearestEntity: sinon.stub().returns(mockEntity) 146 | } as unknown as mineflayer.Bot; 147 | const getBot = () => mockBot; 148 | 149 | registerEntityTools(factory, getBot); 150 | 151 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 152 | const findEntityCall = toolCalls.find(call => call.args[0] === 'find-entity'); 153 | const executor = findEntityCall!.args[3]; 154 | 155 | const result = await executor({ type: 'player', maxDistance: 16 }); 156 | 157 | t.true(result.content[0].text.includes('TestPlayer')); 158 | }); 159 | 160 | test('find-entity searches any entity when type not specified', async (t) => { 161 | const mockServer = { 162 | tool: sinon.stub() 163 | } as unknown as McpServer; 164 | const mockConnection = { 165 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 166 | } as unknown as BotConnection; 167 | const factory = new ToolFactory(mockServer, mockConnection); 168 | 169 | const mockEntity = { 170 | name: 'cow', 171 | type: 'mob', 172 | position: new Vec3(5, 64, 5) 173 | }; 174 | const mockBot = { 175 | entity: { 176 | position: new Vec3(0, 64, 0) 177 | }, 178 | nearestEntity: sinon.stub().returns(mockEntity) 179 | } as unknown as mineflayer.Bot; 180 | const getBot = () => mockBot; 181 | 182 | registerEntityTools(factory, getBot); 183 | 184 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 185 | const findEntityCall = toolCalls.find(call => call.args[0] === 'find-entity'); 186 | const executor = findEntityCall!.args[3]; 187 | 188 | const result = await executor({ maxDistance: 16 }); 189 | 190 | t.true(result.content[0].text.includes('cow')); 191 | }); 192 | -------------------------------------------------------------------------------- /src/bot-connection.ts: -------------------------------------------------------------------------------- 1 | import mineflayer from 'mineflayer'; 2 | import pathfinderPkg from 'mineflayer-pathfinder'; 3 | const { pathfinder, Movements } = pathfinderPkg; 4 | import minecraftData from 'minecraft-data'; 5 | 6 | const SUPPORTED_MINECRAFT_VERSION = '1.21.8'; 7 | 8 | type ConnectionState = 'connected' | 'connecting' | 'disconnected'; 9 | 10 | interface BotConfig { 11 | host: string; 12 | port: number; 13 | username: string; 14 | } 15 | 16 | interface ConnectionCallbacks { 17 | onLog: (level: string, message: string) => void; 18 | onChatMessage: (username: string, message: string) => void; 19 | } 20 | 21 | export class BotConnection { 22 | private bot: mineflayer.Bot | null = null; 23 | private state: ConnectionState = 'disconnected'; 24 | private config: BotConfig; 25 | private callbacks: ConnectionCallbacks; 26 | private isReconnecting = false; 27 | private reconnectTimer: ReturnType | null = null; 28 | private readonly reconnectDelayMs: number; 29 | 30 | constructor(config: BotConfig, callbacks: ConnectionCallbacks, reconnectDelayMs = 2000) { 31 | this.config = config; 32 | this.callbacks = callbacks; 33 | this.reconnectDelayMs = reconnectDelayMs; 34 | } 35 | 36 | getBot(): mineflayer.Bot | null { 37 | return this.bot; 38 | } 39 | 40 | getState(): ConnectionState { 41 | return this.state; 42 | } 43 | 44 | getConfig(): BotConfig { 45 | return this.config; 46 | } 47 | 48 | isConnected(): boolean { 49 | return this.state === 'connected'; 50 | } 51 | 52 | connect(): void { 53 | const botOptions = { 54 | host: this.config.host, 55 | port: this.config.port, 56 | username: this.config.username, 57 | plugins: { pathfinder }, 58 | }; 59 | 60 | this.bot = mineflayer.createBot(botOptions); 61 | this.state = 'connecting'; 62 | this.isReconnecting = false; 63 | 64 | this.registerEventHandlers(this.bot); 65 | } 66 | 67 | private registerEventHandlers(bot: mineflayer.Bot): void { 68 | bot.once('spawn', async () => { 69 | this.state = 'connected'; 70 | this.callbacks.onLog('info', 'Bot spawned in world'); 71 | 72 | const mcData = minecraftData(bot.version); 73 | const defaultMove = new Movements(bot, mcData); 74 | bot.pathfinder.setMovements(defaultMove); 75 | 76 | bot.chat('LLM-powered bot ready to receive instructions!'); 77 | this.callbacks.onLog('info', `Bot connected successfully. Username: ${this.config.username}, Server: ${this.config.host}:${this.config.port}`); 78 | }); 79 | 80 | bot.on('chat', (username, message) => { 81 | if (username === bot.username) return; 82 | this.callbacks.onChatMessage(username, message); 83 | }); 84 | 85 | bot.on('kicked', (reason) => { 86 | this.callbacks.onLog('error', `Bot was kicked from server: ${this.formatError(reason)}`); 87 | this.state = 'disconnected'; 88 | bot.quit(); 89 | }); 90 | 91 | bot.on('error', (err) => { 92 | const errorCode = (err as { code?: string }).code || 'Unknown error'; 93 | const errorMsg = err instanceof Error ? err.message : String(err); 94 | 95 | this.callbacks.onLog('error', `Bot error [${errorCode}]: ${errorMsg}`); 96 | 97 | if (errorCode === 'ECONNREFUSED' || errorCode === 'ETIMEDOUT') { 98 | this.state = 'disconnected'; 99 | } 100 | }); 101 | 102 | bot.on('login', () => { 103 | this.callbacks.onLog('info', 'Bot logged in successfully'); 104 | }); 105 | 106 | bot.on('end', (reason) => { 107 | this.callbacks.onLog('info', `Bot disconnected: ${this.formatError(reason)}`); 108 | 109 | if (this.state === 'connected') { 110 | this.state = 'disconnected'; 111 | } 112 | 113 | if (this.bot === bot) { 114 | try { 115 | bot.removeAllListeners(); 116 | this.bot = null; 117 | this.callbacks.onLog('info', 'Bot instance cleaned up after disconnect'); 118 | } catch (err) { 119 | this.callbacks.onLog('warn', `Error cleaning up bot on end event: ${this.formatError(err)}`); 120 | } 121 | } 122 | }); 123 | } 124 | 125 | attemptReconnect(): void { 126 | if (this.isReconnecting || this.state === 'connecting') { 127 | return; 128 | } 129 | 130 | this.isReconnecting = true; 131 | this.state = 'connecting'; 132 | this.callbacks.onLog('info', `Attempting to reconnect to Minecraft server in ${this.reconnectDelayMs}ms...`); 133 | 134 | if (this.reconnectTimer) { 135 | clearTimeout(this.reconnectTimer); 136 | } 137 | 138 | this.reconnectTimer = setTimeout(() => { 139 | if (this.bot) { 140 | try { 141 | this.bot.removeAllListeners(); 142 | this.bot.quit('Reconnecting...'); 143 | this.callbacks.onLog('info', 'Old bot instance cleaned up'); 144 | } catch (err) { 145 | this.callbacks.onLog('warn', `Error while cleaning up old bot: ${this.formatError(err)}`); 146 | } 147 | } 148 | 149 | this.callbacks.onLog('info', 'Creating new bot instance...'); 150 | this.connect(); 151 | }, this.reconnectDelayMs); 152 | } 153 | 154 | async checkConnectionAndReconnect(): Promise<{ connected: boolean; message?: string }> { 155 | const currentState = this.state; 156 | 157 | if (currentState === 'disconnected') { 158 | this.attemptReconnect(); 159 | 160 | const maxWaitTime = this.reconnectDelayMs + 5000; 161 | const pollInterval = 100; 162 | const startTime = Date.now(); 163 | 164 | while (Date.now() - startTime < maxWaitTime) { 165 | if (this.state === 'connected') { 166 | return { connected: true }; 167 | } 168 | await new Promise(resolve => setTimeout(resolve, pollInterval)); 169 | } 170 | 171 | const errorMessage = 172 | `Cannot connect to Minecraft server at ${this.config.host}:${this.config.port}\n\n` + 173 | `Please ensure:\n` + 174 | `1. Minecraft server is running on ${this.config.host}:${this.config.port}\n` + 175 | `2. Server is accessible from this machine\n` + 176 | `3. Server version is compatible (latest supported: ${SUPPORTED_MINECRAFT_VERSION})\n\n` + 177 | `For setup instructions, visit: https://github.com/yuniko-software/minecraft-mcp-server`; 178 | 179 | return { connected: false, message: errorMessage }; 180 | } 181 | 182 | if (currentState === 'connecting') { 183 | return { connected: false, message: 'Bot is connecting to the Minecraft server. Please wait a moment and try again.' }; 184 | } 185 | 186 | return { connected: true }; 187 | } 188 | 189 | cleanup(): void { 190 | if (this.reconnectTimer) { 191 | clearTimeout(this.reconnectTimer); 192 | } 193 | if (this.bot) { 194 | try { 195 | this.bot.quit('Server shutting down'); 196 | } catch (err) { 197 | this.callbacks.onLog('warn', `Error during cleanup: ${this.formatError(err)}`); 198 | } 199 | } 200 | } 201 | 202 | private formatError(error: unknown): string { 203 | if (error instanceof Error) { 204 | return error.message; 205 | } 206 | try { 207 | return JSON.stringify(error); 208 | } catch { 209 | return String(error); 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /tests/chat-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerChatTools } from '../src/tools/chat-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import { MessageStore } from '../src/message-store.js'; 6 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 7 | import type { BotConnection } from '../src/bot-connection.js'; 8 | import type mineflayer from 'mineflayer'; 9 | 10 | test('registerChatTools registers send-chat tool', (t) => { 11 | const mockServer = { 12 | tool: sinon.stub() 13 | } as unknown as McpServer; 14 | const mockConnection = { 15 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 16 | } as unknown as BotConnection; 17 | const factory = new ToolFactory(mockServer, mockConnection); 18 | const mockBot = {} as Partial; 19 | const getBot = () => mockBot as mineflayer.Bot; 20 | const messageStore = new MessageStore(); 21 | 22 | registerChatTools(factory, getBot, messageStore); 23 | 24 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 25 | const sendChatCall = toolCalls.find(call => call.args[0] === 'send-chat'); 26 | 27 | t.truthy(sendChatCall); 28 | t.is(sendChatCall!.args[1], 'Send a chat message in-game'); 29 | }); 30 | 31 | test('registerChatTools registers read-chat tool', (t) => { 32 | const mockServer = { 33 | tool: sinon.stub() 34 | } as unknown as McpServer; 35 | const mockConnection = { 36 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 37 | } as unknown as BotConnection; 38 | const factory = new ToolFactory(mockServer, mockConnection); 39 | const mockBot = {} as Partial; 40 | const getBot = () => mockBot as mineflayer.Bot; 41 | const messageStore = new MessageStore(); 42 | 43 | registerChatTools(factory, getBot, messageStore); 44 | 45 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 46 | const readChatCall = toolCalls.find(call => call.args[0] === 'read-chat'); 47 | 48 | t.truthy(readChatCall); 49 | t.is(readChatCall!.args[1], 'Get recent chat messages from players'); 50 | }); 51 | 52 | test('send-chat calls bot.chat with message', async (t) => { 53 | const mockServer = { 54 | tool: sinon.stub() 55 | } as unknown as McpServer; 56 | const mockConnection = { 57 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 58 | } as unknown as BotConnection; 59 | const factory = new ToolFactory(mockServer, mockConnection); 60 | 61 | const mockBot = { 62 | chat: sinon.stub() 63 | } as Partial; 64 | const getBot = () => mockBot as mineflayer.Bot; 65 | const messageStore = new MessageStore(); 66 | 67 | registerChatTools(factory, getBot, messageStore); 68 | 69 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 70 | const sendChatCall = toolCalls.find(call => call.args[0] === 'send-chat'); 71 | const executor = sendChatCall!.args[3]; 72 | 73 | const result = await executor({ message: 'Hello world' }); 74 | 75 | t.true((mockBot.chat as sinon.SinonStub).calledOnceWith('Hello world')); 76 | t.true(result.content[0].text.includes('Hello world')); 77 | }); 78 | 79 | test('read-chat returns no messages when empty', async (t) => { 80 | const mockServer = { 81 | tool: sinon.stub() 82 | } as unknown as McpServer; 83 | const mockConnection = { 84 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 85 | } as unknown as BotConnection; 86 | const factory = new ToolFactory(mockServer, mockConnection); 87 | 88 | const mockBot = {} as Partial; 89 | const getBot = () => mockBot as mineflayer.Bot; 90 | const messageStore = new MessageStore(); 91 | 92 | registerChatTools(factory, getBot, messageStore); 93 | 94 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 95 | const readChatCall = toolCalls.find(call => call.args[0] === 'read-chat'); 96 | const executor = readChatCall!.args[3]; 97 | 98 | const result = await executor({}); 99 | 100 | t.true(result.content[0].text.includes('No chat messages found')); 101 | }); 102 | 103 | test('read-chat returns formatted messages', async (t) => { 104 | const mockServer = { 105 | tool: sinon.stub() 106 | } as unknown as McpServer; 107 | const mockConnection = { 108 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 109 | } as unknown as BotConnection; 110 | const factory = new ToolFactory(mockServer, mockConnection); 111 | 112 | const mockBot = {} as Partial; 113 | const getBot = () => mockBot as mineflayer.Bot; 114 | const messageStore = new MessageStore(); 115 | 116 | messageStore.addMessage('player1', 'Hello'); 117 | messageStore.addMessage('player2', 'Hi there'); 118 | 119 | registerChatTools(factory, getBot, messageStore); 120 | 121 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 122 | const readChatCall = toolCalls.find(call => call.args[0] === 'read-chat'); 123 | const executor = readChatCall!.args[3]; 124 | 125 | const result = await executor({ count: 10 }); 126 | 127 | t.true(result.content[0].text.includes('player1')); 128 | t.true(result.content[0].text.includes('Hello')); 129 | t.true(result.content[0].text.includes('player2')); 130 | t.true(result.content[0].text.includes('Hi there')); 131 | t.true(result.content[0].text.includes('2 chat message')); 132 | }); 133 | 134 | test('read-chat respects count parameter', async (t) => { 135 | const mockServer = { 136 | tool: sinon.stub() 137 | } as unknown as McpServer; 138 | const mockConnection = { 139 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 140 | } as unknown as BotConnection; 141 | const factory = new ToolFactory(mockServer, mockConnection); 142 | 143 | const mockBot = {} as Partial; 144 | const getBot = () => mockBot as mineflayer.Bot; 145 | const messageStore = new MessageStore(); 146 | 147 | for (let i = 0; i < 20; i++) { 148 | messageStore.addMessage(`player${i}`, `Message ${i}`); 149 | } 150 | 151 | registerChatTools(factory, getBot, messageStore); 152 | 153 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 154 | const readChatCall = toolCalls.find(call => call.args[0] === 'read-chat'); 155 | const executor = readChatCall!.args[3]; 156 | 157 | const result = await executor({ count: 5 }); 158 | 159 | t.true(result.content[0].text.includes('5 chat message')); 160 | }); 161 | 162 | test('read-chat limits count to max messages', async (t) => { 163 | const mockServer = { 164 | tool: sinon.stub() 165 | } as unknown as McpServer; 166 | const mockConnection = { 167 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 168 | } as unknown as BotConnection; 169 | const factory = new ToolFactory(mockServer, mockConnection); 170 | 171 | const mockBot = {} as Partial; 172 | const getBot = () => mockBot as mineflayer.Bot; 173 | const messageStore = new MessageStore(); 174 | 175 | for (let i = 0; i < 10; i++) { 176 | messageStore.addMessage(`player${i}`, `Message ${i}`); 177 | } 178 | 179 | registerChatTools(factory, getBot, messageStore); 180 | 181 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 182 | const readChatCall = toolCalls.find(call => call.args[0] === 'read-chat'); 183 | const executor = readChatCall!.args[3]; 184 | 185 | const result = await executor({ count: 200 }); 186 | 187 | t.true(result.content[0].text.includes('10 chat message')); 188 | }); 189 | -------------------------------------------------------------------------------- /tests/bot-connection.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { BotConnection } from '../src/bot-connection.js'; 4 | 5 | test('constructor initializes with correct state', (t) => { 6 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 7 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 8 | const connection = new BotConnection(config, callbacks); 9 | 10 | t.is(connection.getState(), 'disconnected'); 11 | t.deepEqual(connection.getConfig(), config); 12 | t.is(connection.getBot(), null); 13 | t.false(connection.isConnected()); 14 | }); 15 | 16 | test('constructor accepts custom reconnect delay', (t) => { 17 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 18 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 19 | const customDelay = 5000; 20 | const connection = new BotConnection(config, callbacks, customDelay); 21 | 22 | t.is(connection.getState(), 'disconnected'); 23 | }); 24 | 25 | test('getState returns current state', (t) => { 26 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 27 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 28 | const connection = new BotConnection(config, callbacks); 29 | 30 | t.is(connection.getState(), 'disconnected'); 31 | }); 32 | 33 | test('getConfig returns configuration', (t) => { 34 | const config = { host: 'example.com', port: 30000, username: 'MyBot' }; 35 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 36 | const connection = new BotConnection(config, callbacks); 37 | 38 | const returnedConfig = connection.getConfig(); 39 | t.is(returnedConfig.host, 'example.com'); 40 | t.is(returnedConfig.port, 30000); 41 | t.is(returnedConfig.username, 'MyBot'); 42 | }); 43 | 44 | test('getBot returns null initially', (t) => { 45 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 46 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 47 | const connection = new BotConnection(config, callbacks); 48 | 49 | t.is(connection.getBot(), null); 50 | }); 51 | 52 | test('isConnected returns false when state is disconnected', (t) => { 53 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 54 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 55 | const connection = new BotConnection(config, callbacks); 56 | 57 | t.false(connection.isConnected()); 58 | }); 59 | 60 | test('formatError handles Error objects', (t) => { 61 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 62 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 63 | const connection = new BotConnection(config, callbacks); 64 | 65 | const error = new Error('Test error'); 66 | const formatted = (connection as unknown as { formatError: (error: unknown) => string }).formatError(error); 67 | 68 | t.is(formatted, 'Test error'); 69 | }); 70 | 71 | test('formatError handles plain objects', (t) => { 72 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 73 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 74 | const connection = new BotConnection(config, callbacks); 75 | 76 | const errorObj = { code: 'ECONNREFUSED', message: 'Connection refused' }; 77 | const formatted = (connection as unknown as { formatError: (error: unknown) => string }).formatError(errorObj); 78 | 79 | t.true(formatted.includes('ECONNREFUSED')); 80 | t.true(formatted.includes('Connection refused')); 81 | }); 82 | 83 | test('formatError handles strings', (t) => { 84 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 85 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 86 | const connection = new BotConnection(config, callbacks); 87 | 88 | const formatted = (connection as unknown as { formatError: (error: unknown) => string }).formatError('Simple error'); 89 | 90 | t.is(formatted, '"Simple error"'); 91 | }); 92 | 93 | test('formatError handles non-serializable objects', (t) => { 94 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 95 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 96 | const connection = new BotConnection(config, callbacks); 97 | 98 | const circular: Record = {}; 99 | circular.self = circular; 100 | const formatted = (connection as unknown as { formatError: (error: unknown) => string }).formatError(circular); 101 | 102 | t.is(typeof formatted, 'string'); 103 | }); 104 | 105 | test('checkConnectionAndReconnect returns connected when already connected', async (t) => { 106 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 107 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 108 | const connection = new BotConnection(config, callbacks); 109 | 110 | (connection as unknown as { state: string }).state = 'connected'; 111 | 112 | const result = await connection.checkConnectionAndReconnect(); 113 | 114 | t.true(result.connected); 115 | t.is(result.message, undefined); 116 | }); 117 | 118 | test('checkConnectionAndReconnect returns message when connecting', async (t) => { 119 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 120 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 121 | const connection = new BotConnection(config, callbacks); 122 | 123 | (connection as unknown as { state: string }).state = 'connecting'; 124 | 125 | const result = await connection.checkConnectionAndReconnect(); 126 | 127 | t.false(result.connected); 128 | t.true(result.message!.includes('connecting')); 129 | }); 130 | 131 | test('checkConnectionAndReconnect includes setup instructions on failure', async (t) => { 132 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 133 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 134 | const connection = new BotConnection(config, callbacks, 100); 135 | 136 | (connection as unknown as { state: string }).state = 'disconnected'; 137 | 138 | // Stub attemptReconnect to prevent actual connection attempt 139 | const attemptReconnectStub = sinon.stub(connection as unknown as { attemptReconnect: () => void }, 'attemptReconnect').callsFake(() => { 140 | (connection as unknown as { state: string }).state = 'connecting'; 141 | }); 142 | 143 | const result = await connection.checkConnectionAndReconnect(); 144 | 145 | t.true(attemptReconnectStub.calledOnce); 146 | t.false(result.connected); 147 | t.true(result.message!.includes('Cannot connect')); 148 | t.true(result.message!.includes('localhost:25565')); 149 | t.true(result.message!.includes('github.com')); 150 | 151 | attemptReconnectStub.restore(); 152 | }); 153 | 154 | test('cleanup clears reconnect timer', (t) => { 155 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 156 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 157 | const connection = new BotConnection(config, callbacks); 158 | 159 | (connection as unknown as { reconnectTimer: ReturnType }).reconnectTimer = setTimeout(() => {}, 10000); 160 | 161 | t.notThrows(() => { 162 | connection.cleanup(); 163 | }); 164 | }); 165 | 166 | test('cleanup does not throw when no bot exists', (t) => { 167 | const config = { host: 'localhost', port: 25565, username: 'TestBot' }; 168 | const callbacks = { onLog: sinon.stub(), onChatMessage: sinon.stub() }; 169 | const connection = new BotConnection(config, callbacks); 170 | 171 | t.notThrows(() => { 172 | connection.cleanup(); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /tests/tool-factory.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { ToolFactory } from '../src/tool-factory.js'; 4 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 5 | import type { BotConnection } from '../src/bot-connection.js'; 6 | 7 | test('createResponse returns proper MCP response format', (t) => { 8 | const mockServer = {} as McpServer; 9 | const mockConnection = {} as BotConnection; 10 | const factory = new ToolFactory(mockServer, mockConnection); 11 | 12 | const response = factory.createResponse('Test message'); 13 | 14 | t.deepEqual(response, { 15 | content: [{ type: 'text', text: 'Test message' }] 16 | }); 17 | }); 18 | 19 | test('createResponse handles empty string', (t) => { 20 | const mockServer = {} as McpServer; 21 | const mockConnection = {} as BotConnection; 22 | const factory = new ToolFactory(mockServer, mockConnection); 23 | 24 | const response = factory.createResponse(''); 25 | 26 | t.deepEqual(response, { 27 | content: [{ type: 'text', text: '' }] 28 | }); 29 | }); 30 | 31 | test('createErrorResponse with Error object', (t) => { 32 | const mockServer = {} as McpServer; 33 | const mockConnection = {} as BotConnection; 34 | const factory = new ToolFactory(mockServer, mockConnection); 35 | 36 | const error = new Error('Connection timeout'); 37 | const response = factory.createErrorResponse(error); 38 | 39 | t.deepEqual(response, { 40 | content: [{ type: 'text', text: 'Failed: Connection timeout' }], 41 | isError: true 42 | }); 43 | }); 44 | 45 | test('createErrorResponse with string', (t) => { 46 | const mockServer = {} as McpServer; 47 | const mockConnection = {} as BotConnection; 48 | const factory = new ToolFactory(mockServer, mockConnection); 49 | 50 | const response = factory.createErrorResponse('Invalid argument'); 51 | 52 | t.deepEqual(response, { 53 | content: [{ type: 'text', text: 'Failed: Invalid argument' }], 54 | isError: true 55 | }); 56 | }); 57 | 58 | test('createErrorResponse includes isError flag', (t) => { 59 | const mockServer = {} as McpServer; 60 | const mockConnection = {} as BotConnection; 61 | const factory = new ToolFactory(mockServer, mockConnection); 62 | 63 | const response = factory.createErrorResponse('Error occurred'); 64 | 65 | t.true(response.isError === true); 66 | }); 67 | 68 | test('registerTool calls server.tool with correct parameters', async (t) => { 69 | const mockServer = { 70 | tool: sinon.stub() 71 | } as unknown as McpServer; 72 | const mockConnection = { 73 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 74 | } as unknown as BotConnection; 75 | 76 | const factory = new ToolFactory(mockServer, mockConnection); 77 | const schema = { type: 'object', properties: {} }; 78 | const executor = sinon.stub().resolves({ content: [{ type: 'text', text: 'Success' }] }); 79 | 80 | factory.registerTool('test_tool', 'A test tool', schema, executor); 81 | 82 | t.true((mockServer.tool as sinon.SinonStub).calledOnce); 83 | t.is((mockServer.tool as sinon.SinonStub).firstCall.args[0], 'test_tool'); 84 | t.is((mockServer.tool as sinon.SinonStub).firstCall.args[1], 'A test tool'); 85 | t.is((mockServer.tool as sinon.SinonStub).firstCall.args[2], schema); 86 | }); 87 | 88 | test('registerTool executor checks connection before executing', async (t) => { 89 | const mockServer = { 90 | tool: sinon.stub() 91 | } as unknown as McpServer; 92 | const mockConnection = { 93 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 94 | } as unknown as BotConnection; 95 | 96 | const factory = new ToolFactory(mockServer, mockConnection); 97 | const executor = sinon.stub().resolves({ content: [{ type: 'text', text: 'Success' }] }); 98 | 99 | factory.registerTool('test_tool', 'A test tool', {}, executor); 100 | 101 | const registeredExecutor = (mockServer.tool as sinon.SinonStub).firstCall.args[3]; 102 | await registeredExecutor({ arg: 'value' }); 103 | 104 | t.true((mockConnection.checkConnectionAndReconnect as sinon.SinonStub).calledOnce); 105 | }); 106 | 107 | test('registerTool executor returns error when not connected', async (t) => { 108 | const mockServer = { 109 | tool: sinon.stub() 110 | } as unknown as McpServer; 111 | const mockConnection = { 112 | checkConnectionAndReconnect: sinon.stub().resolves({ 113 | connected: false, 114 | message: 'Bot is not connected' 115 | }) 116 | } as unknown as BotConnection; 117 | 118 | const factory = new ToolFactory(mockServer, mockConnection); 119 | const executor = sinon.stub().resolves({ content: [{ type: 'text', text: 'Success' }] }); 120 | 121 | factory.registerTool('test_tool', 'A test tool', {}, executor); 122 | 123 | const registeredExecutor = (mockServer.tool as sinon.SinonStub).firstCall.args[3]; 124 | const response = await registeredExecutor({ arg: 'value' }); 125 | 126 | t.deepEqual(response, { 127 | content: [{ type: 'text', text: 'Bot is not connected' }], 128 | isError: true 129 | }); 130 | t.true((executor as sinon.SinonStub).notCalled); 131 | }); 132 | 133 | test('registerTool executor calls executor when connected', async (t) => { 134 | const mockServer = { 135 | tool: sinon.stub() 136 | } as unknown as McpServer; 137 | const mockConnection = { 138 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 139 | } as unknown as BotConnection; 140 | 141 | const factory = new ToolFactory(mockServer, mockConnection); 142 | const executor = sinon.stub().resolves({ content: [{ type: 'text', text: 'Success' }] }); 143 | 144 | factory.registerTool('test_tool', 'A test tool', {}, executor); 145 | 146 | const registeredExecutor = (mockServer.tool as sinon.SinonStub).firstCall.args[3]; 147 | const args = { arg: 'value' }; 148 | await registeredExecutor(args); 149 | 150 | t.true((executor as sinon.SinonStub).calledOnceWith(args)); 151 | }); 152 | 153 | test('registerTool executor returns executor result when successful', async (t) => { 154 | const mockServer = { 155 | tool: sinon.stub() 156 | } as unknown as McpServer; 157 | const mockConnection = { 158 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 159 | } as unknown as BotConnection; 160 | 161 | const factory = new ToolFactory(mockServer, mockConnection); 162 | const expectedResponse = { content: [{ type: 'text', text: 'Tool executed' }] }; 163 | const executor = sinon.stub().resolves(expectedResponse); 164 | 165 | factory.registerTool('test_tool', 'A test tool', {}, executor); 166 | 167 | const registeredExecutor = (mockServer.tool as sinon.SinonStub).firstCall.args[3]; 168 | const response = await registeredExecutor({ arg: 'value' }); 169 | 170 | t.deepEqual(response, expectedResponse); 171 | }); 172 | 173 | test('registerTool executor catches and returns error response on exception', async (t) => { 174 | const mockServer = { 175 | tool: sinon.stub() 176 | } as unknown as McpServer; 177 | const mockConnection = { 178 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 179 | } as unknown as BotConnection; 180 | 181 | const factory = new ToolFactory(mockServer, mockConnection); 182 | const error = new Error('Execution failed'); 183 | const executor = sinon.stub().rejects(error); 184 | 185 | factory.registerTool('test_tool', 'A test tool', {}, executor); 186 | 187 | const registeredExecutor = (mockServer.tool as sinon.SinonStub).firstCall.args[3]; 188 | const response = await registeredExecutor({ arg: 'value' }); 189 | 190 | t.deepEqual(response, { 191 | content: [{ type: 'text', text: 'Failed: Execution failed' }], 192 | isError: true 193 | }); 194 | }); 195 | -------------------------------------------------------------------------------- /tests/block-tools.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sinon from 'sinon'; 3 | import { registerBlockTools } from '../src/tools/block-tools.js'; 4 | import { ToolFactory } from '../src/tool-factory.js'; 5 | import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 6 | import type { BotConnection } from '../src/bot-connection.js'; 7 | import type mineflayer from 'mineflayer'; 8 | import { Vec3 } from 'vec3'; 9 | 10 | test('registerBlockTools registers place-block tool', (t) => { 11 | const mockServer = { 12 | tool: sinon.stub() 13 | } as unknown as McpServer; 14 | const mockConnection = { 15 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 16 | } as unknown as BotConnection; 17 | const factory = new ToolFactory(mockServer, mockConnection); 18 | const mockBot = {} as Partial; 19 | const getBot = () => mockBot as mineflayer.Bot; 20 | 21 | registerBlockTools(factory, getBot); 22 | 23 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 24 | const placeBlockCall = toolCalls.find(call => call.args[0] === 'place-block'); 25 | 26 | t.truthy(placeBlockCall); 27 | t.is(placeBlockCall!.args[1], 'Place a block at the specified position'); 28 | }); 29 | 30 | test('registerBlockTools registers dig-block tool', (t) => { 31 | const mockServer = { 32 | tool: sinon.stub() 33 | } as unknown as McpServer; 34 | const mockConnection = { 35 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 36 | } as unknown as BotConnection; 37 | const factory = new ToolFactory(mockServer, mockConnection); 38 | const mockBot = {} as Partial; 39 | const getBot = () => mockBot as mineflayer.Bot; 40 | 41 | registerBlockTools(factory, getBot); 42 | 43 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 44 | const digBlockCall = toolCalls.find(call => call.args[0] === 'dig-block'); 45 | 46 | t.truthy(digBlockCall); 47 | t.is(digBlockCall!.args[1], 'Dig a block at the specified position'); 48 | }); 49 | 50 | test('registerBlockTools registers get-block-info tool', (t) => { 51 | const mockServer = { 52 | tool: sinon.stub() 53 | } as unknown as McpServer; 54 | const mockConnection = { 55 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 56 | } as unknown as BotConnection; 57 | const factory = new ToolFactory(mockServer, mockConnection); 58 | const mockBot = {} as Partial; 59 | const getBot = () => mockBot as mineflayer.Bot; 60 | 61 | registerBlockTools(factory, getBot); 62 | 63 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 64 | const getBlockInfoCall = toolCalls.find(call => call.args[0] === 'get-block-info'); 65 | 66 | t.truthy(getBlockInfoCall); 67 | t.is(getBlockInfoCall!.args[1], 'Get information about a block at the specified position'); 68 | }); 69 | 70 | test('registerBlockTools registers find-block tool', (t) => { 71 | const mockServer = { 72 | tool: sinon.stub() 73 | } as unknown as McpServer; 74 | const mockConnection = { 75 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 76 | } as unknown as BotConnection; 77 | const factory = new ToolFactory(mockServer, mockConnection); 78 | const mockBot = {} as Partial; 79 | const getBot = () => mockBot as mineflayer.Bot; 80 | 81 | registerBlockTools(factory, getBot); 82 | 83 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 84 | const findBlockCall = toolCalls.find(call => call.args[0] === 'find-block'); 85 | 86 | t.truthy(findBlockCall); 87 | t.is(findBlockCall!.args[1], 'Find the nearest block of a specific type'); 88 | }); 89 | 90 | test('get-block-info returns block information', async (t) => { 91 | const mockServer = { 92 | tool: sinon.stub() 93 | } as unknown as McpServer; 94 | const mockConnection = { 95 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 96 | } as unknown as BotConnection; 97 | const factory = new ToolFactory(mockServer, mockConnection); 98 | 99 | const mockBlock = { 100 | name: 'stone', 101 | type: 1, 102 | position: new Vec3(10, 64, 20) 103 | }; 104 | const mockBot = { 105 | blockAt: sinon.stub().returns(mockBlock) 106 | } as Partial; 107 | const getBot = () => mockBot as mineflayer.Bot; 108 | 109 | registerBlockTools(factory, getBot); 110 | 111 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 112 | const getBlockInfoCall = toolCalls.find(call => call.args[0] === 'get-block-info'); 113 | const executor = getBlockInfoCall!.args[3]; 114 | 115 | const result = await executor({ x: 10, y: 64, z: 20 }); 116 | 117 | t.true(result.content[0].text.includes('stone')); 118 | t.true(result.content[0].text.includes('10')); 119 | t.true(result.content[0].text.includes('64')); 120 | t.true(result.content[0].text.includes('20')); 121 | }); 122 | 123 | test('get-block-info handles missing block', async (t) => { 124 | const mockServer = { 125 | tool: sinon.stub() 126 | } as unknown as McpServer; 127 | const mockConnection = { 128 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 129 | } as unknown as BotConnection; 130 | const factory = new ToolFactory(mockServer, mockConnection); 131 | 132 | const mockBot = { 133 | blockAt: sinon.stub().returns(null) 134 | } as Partial; 135 | const getBot = () => mockBot as mineflayer.Bot; 136 | 137 | registerBlockTools(factory, getBot); 138 | 139 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 140 | const getBlockInfoCall = toolCalls.find(call => call.args[0] === 'get-block-info'); 141 | const executor = getBlockInfoCall!.args[3]; 142 | 143 | const result = await executor({ x: 10, y: 64, z: 20 }); 144 | 145 | t.true(result.content[0].text.includes('No block information found')); 146 | }); 147 | 148 | test('dig-block handles air blocks', async (t) => { 149 | const mockServer = { 150 | tool: sinon.stub() 151 | } as unknown as McpServer; 152 | const mockConnection = { 153 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 154 | } as unknown as BotConnection; 155 | const factory = new ToolFactory(mockServer, mockConnection); 156 | 157 | const mockBlock = { 158 | name: 'air' 159 | }; 160 | const mockBot = { 161 | blockAt: sinon.stub().returns(mockBlock) 162 | } as Partial; 163 | const getBot = () => mockBot as mineflayer.Bot; 164 | 165 | registerBlockTools(factory, getBot); 166 | 167 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 168 | const digBlockCall = toolCalls.find(call => call.args[0] === 'dig-block'); 169 | const executor = digBlockCall!.args[3]; 170 | 171 | const result = await executor({ x: 10, y: 64, z: 20 }); 172 | 173 | t.true(result.content[0].text.includes('No block found')); 174 | }); 175 | 176 | test('find-block returns not found when block not found', async (t) => { 177 | const mockServer = { 178 | tool: sinon.stub() 179 | } as unknown as McpServer; 180 | const mockConnection = { 181 | checkConnectionAndReconnect: sinon.stub().resolves({ connected: true }) 182 | } as unknown as BotConnection; 183 | const factory = new ToolFactory(mockServer, mockConnection); 184 | 185 | const mockBot = { 186 | version: '1.21', 187 | findBlock: sinon.stub().returns(null) 188 | } as Partial; 189 | const getBot = () => mockBot as mineflayer.Bot; 190 | 191 | registerBlockTools(factory, getBot); 192 | 193 | const toolCalls = (mockServer.tool as sinon.SinonStub).getCalls(); 194 | const findBlockCall = toolCalls.find(call => call.args[0] === 'find-block'); 195 | const executor = findBlockCall!.args[3]; 196 | 197 | const result = await executor({ blockType: 'diamond_ore', maxDistance: 16 }); 198 | 199 | t.true(result.content[0].text.includes('No diamond_ore found')); 200 | }); 201 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [2025] [Yuniko Software] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------