actors-mcp-server

Official
  • src
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { createBot } from "mineflayer"; import type { Bot } from "mineflayer"; import { pathfinder, goals, Movements } from "mineflayer-pathfinder"; import type { Pathfinder } from "mineflayer-pathfinder"; import { Vec3 } from "vec3"; import { MinecraftToolHandler } from "./handlers/tools.js"; import { MINECRAFT_TOOLS } from "./tools/index.js"; import * as schemas from "./schemas.js"; import { cliSchema } from "./cli.js"; import type { MinecraftBot } from "./types/minecraft.js"; import { MinecraftResourceHandler } from "./handlers/resources.js"; import type { ResourceHandler } from "./handlers/resources.js"; import type { ResourceResponse } from "./handlers/resources.js"; const MINECRAFT_RESOURCES = [ { name: "players", uri: "minecraft://players", description: "List of players currently on the server, including their usernames and connection info", mimeType: "application/json", }, { name: "position", uri: "minecraft://position", description: "Current position of the bot in the world (x, y, z coordinates)", mimeType: "application/json", }, { name: "blocks/nearby", uri: "minecraft://blocks/nearby", description: "List of blocks in the bot's vicinity, including their positions and types", mimeType: "application/json", }, { name: "entities/nearby", uri: "minecraft://entities/nearby", description: "List of entities (players, mobs, items) near the bot, including their positions and types", mimeType: "application/json", }, { name: "inventory", uri: "minecraft://inventory", description: "Current contents of the bot's inventory, including item names, counts, and slots", mimeType: "application/json", }, { name: "health", uri: "minecraft://health", description: "Bot's current health, food, saturation, and armor status", mimeType: "application/json", }, { name: "weather", uri: "minecraft://weather", description: "Current weather conditions in the game (clear, raining, thundering)", mimeType: "application/json", }, ]; interface ExtendedBot extends Bot { pathfinder: Pathfinder & { setMovements(movements: Movements): void; goto(goal: goals.Goal): Promise<void>; }; } export class MinecraftServer { private server: Server; private bot: ExtendedBot | null = null; private toolHandler!: MinecraftToolHandler; private resourceHandler!: MinecraftResourceHandler; private connectionParams: z.infer<typeof cliSchema>; private isConnected: boolean = false; private reconnectAttempts: number = 0; private readonly maxReconnectAttempts: number = 3; private readonly reconnectDelay: number = 5000; // 5 seconds constructor(connectionParams: z.infer<typeof cliSchema>) { this.connectionParams = connectionParams; this.server = new Server( { name: "mineflayer-mcp-server", version: "0.1.0", }, { capabilities: { tools: { enabled: true, }, resources: { enabled: true, }, }, } ); this.setupHandlers(); } private sendJsonRpcNotification(method: string, params: any) { this.server .notification({ method, params: JSON.parse(JSON.stringify(params)), }) .catch((error) => { console.error("Failed to send notification:", error); }); } private async connectBot(): Promise<void> { if (this.bot) { this.bot.end(); this.bot = null; } const bot = createBot({ host: this.connectionParams.host, port: this.connectionParams.port, username: this.connectionParams.username, hideErrors: false, }) as ExtendedBot; bot.loadPlugin(pathfinder); this.bot = bot; // Create a wrapper that implements MinecraftBot interface const wrapper: MinecraftBot = { chat: (message: string) => bot.chat(message), disconnect: () => bot.end(), getPosition: () => { const pos = bot.entity?.position; return pos ? { x: pos.x, y: pos.y, z: pos.z } : null; }, getHealth: () => bot.health, getInventory: () => bot.inventory.items().map((item) => ({ name: item.name, count: item.count, slot: item.slot, })), getPlayers: () => Object.values(bot.players).map((player) => ({ username: player.username, uuid: player.uuid, ping: player.ping, })), navigateRelative: async ( dx: number, dy: number, dz: number, progressCallback?: (progress: number) => void ) => { const pos = bot.entity.position; const yaw = bot.entity.yaw; const sin = Math.sin(yaw); const cos = Math.cos(yaw); const worldDx = dx * cos - dz * sin; const worldDz = dx * sin + dz * cos; const goal = new goals.GoalNear( pos.x + worldDx, pos.y + dy, pos.z + worldDz, 1 ); const startPos = bot.entity.position; const targetPos = new Vec3( pos.x + worldDx, pos.y + dy, pos.z + worldDz ); const totalDistance = startPos.distanceTo(targetPos); // Set up progress monitoring const progressToken = Date.now().toString(); const checkProgress = () => { if (!bot) return; const currentPos = bot.entity.position; const remainingDistance = currentPos.distanceTo(targetPos); const progress = Math.min( 100, ((totalDistance - remainingDistance) / totalDistance) * 100 ); if (progressCallback) { progressCallback(progress); } this.sendJsonRpcNotification("tool/progress", { token: progressToken, progress, status: progress < 100 ? "in_progress" : "complete", message: `Navigation progress: ${Math.round(progress)}%`, }); }; const progressInterval = setInterval(checkProgress, 500); try { await bot.pathfinder.goto(goal); } finally { clearInterval(progressInterval); // Send final progress if (progressCallback) { progressCallback(100); } this.sendJsonRpcNotification("tool/progress", { token: progressToken, progress: 100, status: "complete", message: "Navigation complete", }); } }, digBlockRelative: async (dx: number, dy: number, dz: number) => { const pos = bot.entity.position; const yaw = bot.entity.yaw; const sin = Math.sin(yaw); const cos = Math.cos(yaw); const worldDx = dx * cos - dz * sin; const worldDz = dx * sin + dz * cos; const block = bot.blockAt( new Vec3( Math.floor(pos.x + worldDx), Math.floor(pos.y + dy), Math.floor(pos.z + worldDz) ) ); if (!block) throw new Error("No block at relative position"); await bot.dig(block); }, digAreaRelative: async (start, end, progressCallback) => { const pos = bot.entity.position; const yaw = bot.entity.yaw; const sin = Math.sin(yaw); const cos = Math.cos(yaw); const transformPoint = (dx: number, dy: number, dz: number) => ({ x: Math.floor(pos.x + dx * cos - dz * sin), y: Math.floor(pos.y + dy), z: Math.floor(pos.z + dx * sin + dz * cos), }); const absStart = transformPoint(start.dx, start.dy, start.dz); const absEnd = transformPoint(end.dx, end.dy, end.dz); const minX = Math.min(absStart.x, absEnd.x); const maxX = Math.max(absStart.x, absEnd.x); const minY = Math.min(absStart.y, absEnd.y); const maxY = Math.max(absStart.y, absEnd.y); const minZ = Math.min(absStart.z, absEnd.z); const maxZ = Math.max(absStart.z, absEnd.z); const totalBlocks = (maxX - minX + 1) * (maxY - minY + 1) * (maxZ - minZ + 1); let blocksDug = 0; for (let y = maxY; y >= minY; y--) { for (let x = minX; x <= maxX; x++) { for (let z = minZ; z <= maxZ; z++) { const block = bot.blockAt(new Vec3(x, y, z)); if (block && block.name !== "air") { await bot.dig(block); blocksDug++; if (progressCallback) { progressCallback( (blocksDug / totalBlocks) * 100, blocksDug, totalBlocks ); } } } } } }, getBlocksNearby: () => { const pos = bot.entity.position; const radius = 4; const blocks = []; for (let x = -radius; x <= radius; x++) { for (let y = -radius; y <= radius; y++) { for (let z = -radius; z <= radius; z++) { const block = bot.blockAt( new Vec3( Math.floor(pos.x + x), Math.floor(pos.y + y), Math.floor(pos.z + z) ) ); if (block && block.name !== "air") { blocks.push({ name: block.name, position: { x: Math.floor(pos.x + x), y: Math.floor(pos.y + y), z: Math.floor(pos.z + z), }, }); } } } } return blocks; }, getEntitiesNearby: () => { return Object.values(bot.entities) .filter((e) => e !== bot.entity && e.position) .map((e) => ({ name: e.name || "unknown", type: e.type, position: { x: e.position.x, y: e.position.y, z: e.position.z, }, velocity: e.velocity, health: e.health, })); }, getWeather: () => ({ isRaining: bot.isRaining, rainState: bot.isRaining ? "raining" : "clear", thunderState: bot.thunderState, }), } as MinecraftBot; this.toolHandler = new MinecraftToolHandler(wrapper); this.resourceHandler = new MinecraftResourceHandler(wrapper); return new Promise((resolve, reject) => { if (!this.bot) return reject(new Error("Bot not initialized")); this.bot.once("spawn", () => { this.isConnected = true; this.reconnectAttempts = 0; resolve(); }); this.bot.on("end", async () => { this.isConnected = false; try { await this.server.notification({ method: "server/status", params: { type: "connection", status: "disconnected", host: this.connectionParams.host, port: this.connectionParams.port, }, }); if (this.reconnectAttempts < this.maxReconnectAttempts) { this.reconnectAttempts++; await new Promise((resolve) => setTimeout(resolve, this.reconnectDelay) ); await this.connectBot(); } } catch (error) { console.error("Failed to handle disconnection:", error); } }); this.bot.on("error", async (error) => { try { await this.server.notification({ method: "server/status", params: { type: "error", error: error instanceof Error ? error.message : String(error), }, }); } catch (notificationError) { console.error( "Failed to send error notification:", notificationError ); } }); }); } private setupHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: MINECRAFT_TOOLS, })); this.server.setRequestHandler( ReadResourceRequestSchema, async (request) => { try { if (!this.bot || !this.isConnected) { throw new Error("Bot is not connected"); } const { uri } = request.params; let result: ResourceResponse; switch (uri) { case "minecraft://players": result = await this.resourceHandler.handleGetPlayers(uri); break; case "minecraft://position": result = await this.resourceHandler.handleGetPosition(uri); break; case "minecraft://blocks/nearby": result = await this.resourceHandler.handleGetBlocksNearby(uri); break; case "minecraft://entities/nearby": result = await this.resourceHandler.handleGetEntitiesNearby(uri); break; case "minecraft://inventory": result = await this.resourceHandler.handleGetInventory(uri); break; case "minecraft://health": result = await this.resourceHandler.handleGetHealth(uri); break; case "minecraft://weather": result = await this.resourceHandler.handleGetWeather(uri); break; default: throw new Error(`Resource not found: ${uri}`); } return { contents: result.contents.map((content) => ({ uri: content.uri, mimeType: content.mimeType || "application/json", text: typeof content.text === "string" ? content.text : JSON.stringify(content.text), })), }; } catch (error) { throw { code: -32603, message: error instanceof Error ? error.message : String(error), }; } } ); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params.arguments) { throw new Error("Arguments are required"); } if (!this.bot || !this.isConnected) { throw new Error("Bot is not connected"); } let result; switch (request.params.name) { case "chat": { const args = schemas.ChatSchema.parse(request.params.arguments); result = await this.toolHandler.handleChat(args.message); break; } case "navigate_relative": { const args = schemas.NavigateRelativeSchema.parse( request.params.arguments ); result = await this.toolHandler.handleNavigateRelative( args.dx, args.dy, args.dz ); break; } case "dig_block_relative": { const args = schemas.DigBlockRelativeSchema.parse( request.params.arguments ); result = await this.toolHandler.handleDigBlockRelative( args.dx, args.dy, args.dz ); break; } case "dig_area_relative": { const args = schemas.DigAreaRelativeSchema.parse( request.params.arguments ); result = await this.toolHandler.handleDigAreaRelative( args.start, args.end ); break; } default: throw { code: -32601, message: `Unknown tool: ${request.params.name}`, }; } return { content: result?.content || [{ type: "text", text: "Success" }], _meta: result?._meta, }; } catch (error) { if (error instanceof z.ZodError) { throw { code: -32602, message: "Invalid params", data: { errors: error.errors.map((e) => ({ path: e.path.join("."), message: e.message, })), }, }; } throw { code: -32603, message: error instanceof Error ? error.message : String(error), }; } }); } async start(): Promise<void> { try { // Start MCP server first const transport = new StdioServerTransport(); await this.server.connect(transport); // Send startup status await this.server.notification({ method: "server/status", params: { type: "startup", status: "running", transport: "stdio", }, }); // Then connect bot await this.connectBot(); // Keep process alive and handle termination process.stdin.resume(); process.on("SIGINT", () => { this.bot?.end(); process.exit(0); }); process.on("SIGTERM", () => { this.bot?.end(); process.exit(0); }); } catch (error) { throw { code: -32000, message: "Server startup failed", data: { error: error instanceof Error ? error.message : String(error), }, }; } } }