feat: initial Minecraft Bedrock AI Bridge MCP server
MCP server that bridges Minecraft Bedrock Edition's WebSocket API to Claude Code CLI. Exposes 6 tools: minecraft_command, minecraft_chat, minecraft_build, minecraft_get_events, minecraft_get_status, minecraft_subscribe. Supports both SSE and Streamable HTTP MCP transports. Privacy-first: no disk persistence, no telemetry, memory-only event buffer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --production 2>/dev/null || npm install --production
|
||||
|
||||
COPY src/ ./src/
|
||||
|
||||
EXPOSE 3001 3002
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
services:
|
||||
mc-ai-bridge:
|
||||
build: .
|
||||
container_name: mc-ai-bridge
|
||||
ports:
|
||||
- "3001:3001" # Minecraft WebSocket
|
||||
- "3002:3002" # MCP SSE transport
|
||||
environment:
|
||||
- WS_PORT=3001
|
||||
- MCP_PORT=3002
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
1552
package-lock.json
generated
Normal file
1552
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mc-ai-bridge",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server bridging Minecraft Bedrock Edition to Claude Code via WebSocket",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"express": "^4.21.2",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
224
src/bedrock-ws.js
Normal file
224
src/bedrock-ws.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createSubscribeMessage, createCommandMessage, sanitize, log, logError } from './utils.js';
|
||||
import { EventStore } from './event-store.js';
|
||||
import { CommandQueue } from './command-queue.js';
|
||||
|
||||
const TAG = 'BedrockWS';
|
||||
|
||||
/**
|
||||
* WebSocket server that accepts a connection from Minecraft Bedrock Edition.
|
||||
* Only one Minecraft client is supported at a time.
|
||||
*/
|
||||
export class BedrockWebSocket {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.port - WebSocket listen port (default 3001)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.port = opts.port ?? 3001;
|
||||
this.events = new EventStore(100);
|
||||
this.commandQueue = new CommandQueue();
|
||||
|
||||
/** @type {import('ws').WebSocket | null} */
|
||||
this._ws = null;
|
||||
this._wss = null;
|
||||
this._connectedAt = null;
|
||||
this._playerName = null;
|
||||
this._subscriptions = new Set();
|
||||
}
|
||||
|
||||
/** Start the WebSocket server */
|
||||
start() {
|
||||
this._wss = new WebSocketServer({ port: this.port });
|
||||
|
||||
this._wss.on('listening', () => {
|
||||
log(TAG, `WebSocket server listening on port ${this.port}`);
|
||||
log(TAG, `In Minecraft, type: /connect ws://<your-ip>:${this.port}`);
|
||||
});
|
||||
|
||||
this._wss.on('connection', (ws) => {
|
||||
// Only allow one connection at a time
|
||||
if (this._ws) {
|
||||
log(TAG, 'Rejecting new connection - already have an active client');
|
||||
ws.close(1000, 'Only one Minecraft client supported');
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws = ws;
|
||||
this._connectedAt = new Date();
|
||||
log(TAG, 'Minecraft client connected!');
|
||||
|
||||
// Wire up command queue to send over this socket
|
||||
this.commandQueue.setSendFunction((id, message) => {
|
||||
if (this._ws && this._ws.readyState === 1) {
|
||||
this._ws.send(message);
|
||||
} else {
|
||||
throw new Error('WebSocket not connected');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-subscribe to key events
|
||||
this._autoSubscribe();
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const data = JSON.parse(raw.toString());
|
||||
this._handleMessage(data);
|
||||
} catch (err) {
|
||||
logError(TAG, 'Failed to parse message:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', (code, reason) => {
|
||||
log(TAG, `Minecraft disconnected (code: ${code}, reason: ${reason || 'none'})`);
|
||||
this._ws = null;
|
||||
this._connectedAt = null;
|
||||
this._playerName = null;
|
||||
this._subscriptions.clear();
|
||||
this.commandQueue.setSendFunction(null);
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
logError(TAG, 'WebSocket error:', err.message);
|
||||
});
|
||||
});
|
||||
|
||||
this._wss.on('error', (err) => {
|
||||
logError(TAG, 'Server error:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Subscribe to default event types */
|
||||
_autoSubscribe() {
|
||||
const defaultEvents = ['PlayerMessage'];
|
||||
for (const eventName of defaultEvents) {
|
||||
this.subscribe(eventName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a Bedrock event type.
|
||||
* @param {string} eventName
|
||||
*/
|
||||
subscribe(eventName) {
|
||||
if (!this._ws || this._ws.readyState !== 1) {
|
||||
log(TAG, `Cannot subscribe to ${eventName} - not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._subscriptions.has(eventName)) {
|
||||
log(TAG, `Already subscribed to ${eventName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._ws.send(createSubscribeMessage(eventName));
|
||||
this._subscriptions.add(eventName);
|
||||
log(TAG, `Subscribed to ${eventName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an incoming WebSocket message from Bedrock.
|
||||
* @param {object} data - Parsed JSON message
|
||||
*/
|
||||
_handleMessage(data) {
|
||||
const purpose = data?.header?.messagePurpose;
|
||||
|
||||
if (purpose === 'commandResponse') {
|
||||
// Response to a command we sent
|
||||
const requestId = data.header.requestId;
|
||||
this.commandQueue.handleResponse(requestId, data.body);
|
||||
return;
|
||||
}
|
||||
|
||||
if (purpose === 'event') {
|
||||
const eventName = data.header.eventName;
|
||||
const body = data.body || {};
|
||||
|
||||
// Filter bot's own messages to prevent echo loops
|
||||
if (eventName === 'PlayerMessage') {
|
||||
const sender = body.sender || '';
|
||||
const message = sanitize(body.message || '');
|
||||
const type = body.type || 'chat';
|
||||
|
||||
// Skip messages from external sources (commands, say, tell from server)
|
||||
if (type !== 'chat' || sender === 'External' || sender === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track player name from first chat message
|
||||
if (!this._playerName && sender) {
|
||||
this._playerName = sender;
|
||||
log(TAG, `Player identified: ${this._playerName}`);
|
||||
}
|
||||
|
||||
this.events.push(eventName, {
|
||||
sender,
|
||||
message,
|
||||
type,
|
||||
});
|
||||
|
||||
log(TAG, `[Chat] <${sender}> ${message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Store all other events
|
||||
this.events.push(eventName, body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to Minecraft.
|
||||
* @param {string} commandLine - e.g. "give @p diamond 64"
|
||||
* @returns {Promise<object>} Command response
|
||||
*/
|
||||
async sendCommand(commandLine) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
const { id, message } = createCommandMessage(commandLine);
|
||||
return this.commandQueue.enqueue(id, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of commands (for building).
|
||||
* @param {string[]} commandLines
|
||||
* @returns {Promise<object>} Batch result
|
||||
*/
|
||||
async sendBatch(commandLines) {
|
||||
if (!this.isConnected()) {
|
||||
throw new Error('Minecraft is not connected');
|
||||
}
|
||||
const commands = commandLines.map((line) => createCommandMessage(line));
|
||||
return this.commandQueue.enqueueBatch(commands);
|
||||
}
|
||||
|
||||
/** @returns {boolean} Whether a Minecraft client is connected */
|
||||
isConnected() {
|
||||
return this._ws !== null && this._ws.readyState === 1;
|
||||
}
|
||||
|
||||
/** @returns {object} Status information */
|
||||
getStatus() {
|
||||
return {
|
||||
connected: this.isConnected(),
|
||||
playerName: this._playerName,
|
||||
connectedAt: this._connectedAt?.toISOString() || null,
|
||||
subscriptions: [...this._subscriptions],
|
||||
eventCount: this.events.size,
|
||||
...this.commandQueue.getStatus(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shut down the server */
|
||||
stop() {
|
||||
this.commandQueue.destroy();
|
||||
if (this._ws) {
|
||||
this._ws.close();
|
||||
}
|
||||
if (this._wss) {
|
||||
this._wss.close();
|
||||
}
|
||||
log(TAG, 'Server stopped');
|
||||
}
|
||||
}
|
||||
181
src/command-queue.js
Normal file
181
src/command-queue.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { log, logError } from './utils.js';
|
||||
|
||||
const TAG = 'CommandQueue';
|
||||
|
||||
/**
|
||||
* Rate-limited command dispatcher for Minecraft Bedrock.
|
||||
* Bedrock has a hard limit of ~100 in-flight commands.
|
||||
* We cap at 80 and throttle at 50ms between commands.
|
||||
*/
|
||||
export class CommandQueue {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {number} opts.maxInFlight - Max concurrent commands (default 80)
|
||||
* @param {number} opts.throttleMs - Delay between commands in ms (default 50)
|
||||
* @param {number} opts.batchSize - Commands per batch for build mode (default 20)
|
||||
* @param {number} opts.batchDelayMs - Delay between batches in ms (default 200)
|
||||
*/
|
||||
constructor(opts = {}) {
|
||||
this.maxInFlight = opts.maxInFlight ?? 80;
|
||||
this.throttleMs = opts.throttleMs ?? 50;
|
||||
this.batchSize = opts.batchSize ?? 20;
|
||||
this.batchDelayMs = opts.batchDelayMs ?? 200;
|
||||
|
||||
/** @type {Map<string, {resolve: Function, reject: Function, timer: NodeJS.Timeout}>} */
|
||||
this._pending = new Map();
|
||||
this._queue = [];
|
||||
this._processing = false;
|
||||
this._sendFn = null;
|
||||
this._totalSent = 0;
|
||||
this._totalCompleted = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the function used to actually send a command over WebSocket.
|
||||
* @param {(id: string, message: string) => void} fn
|
||||
*/
|
||||
setSendFunction(fn) {
|
||||
this._sendFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a command response comes back from Bedrock.
|
||||
* @param {string} requestId
|
||||
* @param {object} response
|
||||
*/
|
||||
handleResponse(requestId, response) {
|
||||
const entry = this._pending.get(requestId);
|
||||
if (entry) {
|
||||
clearTimeout(entry.timer);
|
||||
this._pending.delete(requestId);
|
||||
this._totalCompleted++;
|
||||
entry.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a single command for dispatch.
|
||||
* @param {string} id - Request UUID
|
||||
* @param {string} message - Serialized WS message
|
||||
* @param {number} timeoutMs - Per-command timeout (default 10s)
|
||||
* @returns {Promise<object>} Resolves with Bedrock response
|
||||
*/
|
||||
enqueue(id, message, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this._queue.push({ id, message, resolve, reject, timeoutMs });
|
||||
this._processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue a batch of commands (for building).
|
||||
* Sends in groups of batchSize with batchDelayMs between groups.
|
||||
* @param {Array<{id: string, message: string}>} commands
|
||||
* @returns {Promise<{total: number, succeeded: number, failed: number, results: Array}>}
|
||||
*/
|
||||
async enqueueBatch(commands) {
|
||||
const results = [];
|
||||
let succeeded = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (let i = 0; i < commands.length; i += this.batchSize) {
|
||||
const batch = commands.slice(i, i + this.batchSize);
|
||||
|
||||
const batchResults = await Promise.allSettled(
|
||||
batch.map((cmd) => this.enqueue(cmd.id, cmd.message))
|
||||
);
|
||||
|
||||
for (const result of batchResults) {
|
||||
if (result.status === 'fulfilled') {
|
||||
succeeded++;
|
||||
results.push(result.value);
|
||||
} else {
|
||||
failed++;
|
||||
results.push({ error: result.reason?.message || 'unknown error' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delay between batches (except after the last one)
|
||||
if (i + this.batchSize < commands.length) {
|
||||
await this._delay(this.batchDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
return { total: commands.length, succeeded, failed, results };
|
||||
}
|
||||
|
||||
/** Process queued commands respecting rate limits */
|
||||
async _processQueue() {
|
||||
if (this._processing) return;
|
||||
this._processing = true;
|
||||
|
||||
while (this._queue.length > 0) {
|
||||
// Wait if at capacity
|
||||
if (this._pending.size >= this.maxInFlight) {
|
||||
await this._delay(this.throttleMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const item = this._queue.shift();
|
||||
if (!item) break;
|
||||
|
||||
if (!this._sendFn) {
|
||||
item.reject(new Error('No WebSocket connection'));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timer = setTimeout(() => {
|
||||
const entry = this._pending.get(item.id);
|
||||
if (entry) {
|
||||
this._pending.delete(item.id);
|
||||
entry.reject(new Error('Command timed out'));
|
||||
}
|
||||
}, item.timeoutMs);
|
||||
|
||||
this._pending.set(item.id, {
|
||||
resolve: item.resolve,
|
||||
reject: item.reject,
|
||||
timer,
|
||||
});
|
||||
|
||||
try {
|
||||
this._sendFn(item.id, item.message);
|
||||
this._totalSent++;
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
this._pending.delete(item.id);
|
||||
item.reject(err);
|
||||
}
|
||||
|
||||
// Throttle between sends
|
||||
await this._delay(this.throttleMs);
|
||||
}
|
||||
|
||||
this._processing = false;
|
||||
}
|
||||
|
||||
/** @returns {{queueSize: number, inFlight: number, totalSent: number, totalCompleted: number}} */
|
||||
getStatus() {
|
||||
return {
|
||||
queueSize: this._queue.length,
|
||||
inFlight: this._pending.size,
|
||||
totalSent: this._totalSent,
|
||||
totalCompleted: this._totalCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
_delay(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
/** Clean up all pending timeouts */
|
||||
destroy() {
|
||||
for (const entry of this._pending.values()) {
|
||||
clearTimeout(entry.timer);
|
||||
entry.reject(new Error('Queue destroyed'));
|
||||
}
|
||||
this._pending.clear();
|
||||
this._queue = [];
|
||||
}
|
||||
}
|
||||
61
src/event-store.js
Normal file
61
src/event-store.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* In-memory ring buffer for Minecraft game events.
|
||||
* Privacy-first: no disk persistence, lost on restart.
|
||||
*/
|
||||
export class EventStore {
|
||||
/** @param {number} capacity - Maximum events to retain */
|
||||
constructor(capacity = 100) {
|
||||
this._capacity = capacity;
|
||||
/** @type {Array<{type: string, timestamp: string, data: object}>} */
|
||||
this._events = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a new event into the ring buffer.
|
||||
* @param {string} type - Event type (e.g. "PlayerMessage")
|
||||
* @param {object} data - Event payload
|
||||
*/
|
||||
push(type, data) {
|
||||
this._events.push({
|
||||
type,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
});
|
||||
|
||||
// Trim to capacity
|
||||
if (this._events.length > this._capacity) {
|
||||
this._events = this._events.slice(-this._capacity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent events.
|
||||
* @param {number} count - Number of events to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getRecent(count = 20) {
|
||||
return this._events.slice(-count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent events filtered by type.
|
||||
* @param {string} type - Event type to filter by
|
||||
* @param {number} count - Max number to return
|
||||
* @returns {Array}
|
||||
*/
|
||||
getByType(type, count = 20) {
|
||||
return this._events
|
||||
.filter((e) => e.type === type)
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
/** @returns {number} Total events currently stored */
|
||||
get size() {
|
||||
return this._events.length;
|
||||
}
|
||||
|
||||
/** Clear all events */
|
||||
clear() {
|
||||
this._events = [];
|
||||
}
|
||||
}
|
||||
36
src/index.js
Normal file
36
src/index.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { BedrockWebSocket } from './bedrock-ws.js';
|
||||
import { startMcpServer } from './mcp-server.js';
|
||||
import { log } from './utils.js';
|
||||
|
||||
const TAG = 'Main';
|
||||
|
||||
const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10);
|
||||
const MCP_PORT = parseInt(process.env.MCP_PORT || '3002', 10);
|
||||
|
||||
log(TAG, 'Minecraft Bedrock AI Bridge starting...');
|
||||
|
||||
// Start the Bedrock WebSocket server
|
||||
const bedrock = new BedrockWebSocket({ port: WS_PORT });
|
||||
bedrock.start();
|
||||
|
||||
// Start the MCP server (Streamable HTTP transport)
|
||||
startMcpServer(bedrock, MCP_PORT);
|
||||
|
||||
log(TAG, '');
|
||||
log(TAG, '=== READY ===');
|
||||
log(TAG, `Minecraft WebSocket : ws://0.0.0.0:${WS_PORT}`);
|
||||
log(TAG, `MCP HTTP endpoint : http://0.0.0.0:${MCP_PORT}/mcp`);
|
||||
log(TAG, `Health check : http://0.0.0.0:${MCP_PORT}/health`);
|
||||
log(TAG, '');
|
||||
log(TAG, 'In Minecraft, type: /connect ws://<your-ip>:' + WS_PORT);
|
||||
log(TAG, '');
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(signal) {
|
||||
log(TAG, `${signal} received, shutting down...`);
|
||||
bedrock.stop();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
409
src/mcp-server.js
Normal file
409
src/mcp-server.js
Normal file
@@ -0,0 +1,409 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import express from 'express';
|
||||
import { z } from 'zod';
|
||||
import { log, logError } from './utils.js';
|
||||
|
||||
const TAG = 'MCP';
|
||||
|
||||
// Available Bedrock event types for subscription
|
||||
const BEDROCK_EVENTS = [
|
||||
'PlayerMessage',
|
||||
'BlockChanged',
|
||||
'PlayerTransform',
|
||||
'ItemUsed',
|
||||
'ItemAcquired',
|
||||
'ItemCrafted',
|
||||
'MobKilled',
|
||||
'MobInteracted',
|
||||
'PlayerTravelled',
|
||||
'PlayerDied',
|
||||
'BossKilled',
|
||||
];
|
||||
|
||||
/**
|
||||
* Create and configure the MCP server with all Minecraft tools.
|
||||
* @param {import('./bedrock-ws.js').BedrockWebSocket} bedrock - The Bedrock WS bridge
|
||||
* @param {number} port - HTTP port for MCP transport (default 3002)
|
||||
*/
|
||||
export function startMcpServer(bedrock, port = 3002) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Track active transports by session
|
||||
const transports = {};
|
||||
|
||||
// Create a fresh McpServer for each session, wired to the same bedrock instance
|
||||
function createServer() {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: 'minecraft-bridge',
|
||||
version: '1.0.0',
|
||||
},
|
||||
{
|
||||
capabilities: { logging: {} },
|
||||
}
|
||||
);
|
||||
|
||||
// ── Tool: minecraft_command ──────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'minecraft_command',
|
||||
{
|
||||
title: 'Minecraft Command',
|
||||
description:
|
||||
'Execute any slash command in Minecraft Bedrock. Examples: "give @p diamond 64", "tp @p 100 64 200", "time set night", "weather thunder". Do NOT include the leading slash.',
|
||||
inputSchema: z.object({
|
||||
command: z
|
||||
.string()
|
||||
.describe(
|
||||
'The command to execute (without leading slash). E.g. "give @p diamond 64"'
|
||||
),
|
||||
}),
|
||||
},
|
||||
async ({ command }) => {
|
||||
try {
|
||||
const response = await bedrock.sendCommand(command);
|
||||
const statusMessage = response?.statusMessage || 'Command executed';
|
||||
const statusCode = response?.statusCode ?? -1;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `[${statusCode === 0 ? 'OK' : 'ERROR'}] ${statusMessage}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Tool: minecraft_chat ────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'minecraft_chat',
|
||||
{
|
||||
title: 'Minecraft Chat',
|
||||
description:
|
||||
'Send a chat message visible in Minecraft. Uses /say for broadcast or /tell for a specific player.',
|
||||
inputSchema: z.object({
|
||||
message: z.string().describe('The message to send'),
|
||||
player: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Target player name for /tell. Omit to broadcast with /say.'
|
||||
),
|
||||
}),
|
||||
},
|
||||
async ({ message, player }) => {
|
||||
try {
|
||||
const cmd = player
|
||||
? `tell ${player} ${message}`
|
||||
: `say ${message}`;
|
||||
const response = await bedrock.sendCommand(cmd);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Message sent${player ? ` to ${player}` : ''}: "${message}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Tool: minecraft_build ───────────────────────────────────────────
|
||||
server.registerTool(
|
||||
'minecraft_build',
|
||||
{
|
||||
title: 'Minecraft Build',
|
||||
description:
|
||||
'Execute a batch of build commands (setblock, fill, clone) with rate limiting. Max 200 commands per call. Commands should NOT have a leading slash.',
|
||||
inputSchema: z.object({
|
||||
commands: z
|
||||
.array(z.string())
|
||||
.max(200)
|
||||
.describe(
|
||||
'Array of build commands. E.g. ["setblock 10 64 10 stone", "fill 10 64 10 20 64 20 glass"]'
|
||||
),
|
||||
}),
|
||||
},
|
||||
async ({ commands }) => {
|
||||
// Validate: only allow build commands
|
||||
const allowed = ['setblock', 'fill', 'clone', 'structure'];
|
||||
const invalid = commands.filter((cmd) => {
|
||||
const base = cmd.replace(/^\//, '').split(' ')[0].toLowerCase();
|
||||
return !allowed.includes(base);
|
||||
});
|
||||
|
||||
if (invalid.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error: Only setblock/fill/clone/structure commands allowed. Invalid: ${invalid.slice(0, 3).join(', ')}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bedrock.sendBatch(commands);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Build complete: ${result.succeeded}/${result.total} commands succeeded, ${result.failed} failed`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Tool: minecraft_get_events ──────────────────────────────────────
|
||||
server.registerTool(
|
||||
'minecraft_get_events',
|
||||
{
|
||||
title: 'Minecraft Get Events',
|
||||
description:
|
||||
'Get recent game events (chat messages, block changes, etc.) from the in-memory event buffer.',
|
||||
inputSchema: z.object({
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Number of events to return (default 20, max 100)'),
|
||||
type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Filter by event type. E.g. "PlayerMessage", "BlockChanged"'
|
||||
),
|
||||
}),
|
||||
},
|
||||
async ({ count, type }) => {
|
||||
const n = count ?? 20;
|
||||
const events = type
|
||||
? bedrock.events.getByType(type, n)
|
||||
: bedrock.events.getRecent(n);
|
||||
|
||||
if (events.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No events recorded yet. Make sure Minecraft is connected and events are subscribed.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formatted = events
|
||||
.map(
|
||||
(e) =>
|
||||
`[${e.timestamp}] ${e.type}: ${JSON.stringify(e.data)}`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${events.length} event(s):\n${formatted}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Tool: minecraft_get_status ──────────────────────────────────────
|
||||
server.registerTool(
|
||||
'minecraft_get_status',
|
||||
{
|
||||
title: 'Minecraft Status',
|
||||
description:
|
||||
'Check if Minecraft is connected and get status info (player name, uptime, queue, event count).',
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async () => {
|
||||
const status = bedrock.getStatus();
|
||||
const lines = [
|
||||
`Connected: ${status.connected ? 'YES' : 'NO'}`,
|
||||
`Player: ${status.playerName || 'unknown'}`,
|
||||
`Connected since: ${status.connectedAt || 'N/A'}`,
|
||||
`Subscriptions: ${status.subscriptions.join(', ') || 'none'}`,
|
||||
`Events stored: ${status.eventCount}`,
|
||||
`Commands sent: ${status.totalSent}`,
|
||||
`Commands completed: ${status.totalCompleted}`,
|
||||
`Queue size: ${status.queueSize}`,
|
||||
`In-flight: ${status.inFlight}`,
|
||||
];
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: lines.join('\n') }],
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Tool: minecraft_subscribe ───────────────────────────────────────
|
||||
server.registerTool(
|
||||
'minecraft_subscribe',
|
||||
{
|
||||
title: 'Minecraft Subscribe',
|
||||
description: `Subscribe to additional Bedrock event types. Available: ${BEDROCK_EVENTS.join(', ')}`,
|
||||
inputSchema: z.object({
|
||||
event: z
|
||||
.string()
|
||||
.describe(
|
||||
`Event type to subscribe to. One of: ${BEDROCK_EVENTS.join(', ')}`
|
||||
),
|
||||
}),
|
||||
},
|
||||
async ({ event }) => {
|
||||
if (!BEDROCK_EVENTS.includes(event)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Unknown event "${event}". Available: ${BEDROCK_EVENTS.join(', ')}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ok = bedrock.subscribe(event);
|
||||
if (ok) {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `Subscribed to ${event} events` },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Failed to subscribe to ${event}. Is Minecraft connected?`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// ── SSE Transport endpoint ──────────────────────────────────────────
|
||||
// SSE is the most widely supported MCP transport for Claude Code
|
||||
app.get('/sse', async (req, res) => {
|
||||
log(TAG, 'SSE client connected');
|
||||
const transport = new SSEServerTransport('/messages', res);
|
||||
const sessionId = transport.sessionId;
|
||||
transports[sessionId] = transport;
|
||||
|
||||
transport.onclose = () => {
|
||||
delete transports[sessionId];
|
||||
log(TAG, `SSE session closed: ${sessionId}`);
|
||||
};
|
||||
|
||||
const server = createServer();
|
||||
await server.connect(transport);
|
||||
log(TAG, `SSE session started: ${sessionId}`);
|
||||
});
|
||||
|
||||
app.post('/messages', async (req, res) => {
|
||||
const sessionId = req.query.sessionId;
|
||||
const transport = transports[sessionId];
|
||||
if (transport) {
|
||||
await transport.handlePostMessage(req, res, req.body);
|
||||
} else {
|
||||
res.status(400).send('Invalid or missing session');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Streamable HTTP Transport (modern alternative) ─────────────────
|
||||
app.post('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res, req.body);
|
||||
return;
|
||||
}
|
||||
|
||||
// New session
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
transports[id] = transport;
|
||||
log(TAG, `Streamable HTTP session created: ${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
delete transports[transport.sessionId];
|
||||
log(TAG, `Streamable HTTP session closed: ${transport.sessionId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const server = createServer();
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
|
||||
app.get('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res);
|
||||
} else {
|
||||
res.status(400).send('Invalid or missing session');
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/mcp', async (req, res) => {
|
||||
const sessionId = req.headers['mcp-session-id'];
|
||||
if (sessionId && transports[sessionId]) {
|
||||
await transports[sessionId].handleRequest(req, res);
|
||||
} else {
|
||||
res.status(400).send('Invalid or missing session');
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
minecraft: bedrock.isConnected(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
log(TAG, `MCP SSE endpoint : http://0.0.0.0:${port}/sse`);
|
||||
log(TAG, `MCP HTTP endpoint : http://0.0.0.0:${port}/mcp`);
|
||||
});
|
||||
}
|
||||
80
src/utils.js
Normal file
80
src/utils.js
Normal file
@@ -0,0 +1,80 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket subscribe message envelope.
|
||||
* @param {string} eventName - e.g. "PlayerMessage", "BlockChanged"
|
||||
* @returns {string} JSON string ready to send over WS
|
||||
*/
|
||||
export function createSubscribeMessage(eventName) {
|
||||
return JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId: randomUUID(),
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'subscribe',
|
||||
},
|
||||
body: {
|
||||
eventName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Bedrock WebSocket command message envelope.
|
||||
* @param {string} commandLine - e.g. "give @p diamond 64" (no leading slash)
|
||||
* @returns {{ id: string, message: string }} request ID and JSON string
|
||||
*/
|
||||
export function createCommandMessage(commandLine) {
|
||||
// Strip leading slash if present
|
||||
const cmd = commandLine.startsWith('/') ? commandLine.slice(1) : commandLine;
|
||||
const requestId = randomUUID();
|
||||
|
||||
const message = JSON.stringify({
|
||||
header: {
|
||||
version: 1,
|
||||
requestId,
|
||||
messageType: 'commandRequest',
|
||||
messagePurpose: 'commandRequest',
|
||||
},
|
||||
body: {
|
||||
version: 1,
|
||||
commandLine: cmd,
|
||||
origin: {
|
||||
type: 'player',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { id: requestId, message };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Minecraft formatting codes (section sign + character).
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitize(text) {
|
||||
if (!text) return '';
|
||||
// Remove section sign formatting codes like §a, §l, §r etc.
|
||||
return text.replace(/\u00A7[0-9a-fk-or]/gi, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamped log helper.
|
||||
* @param {string} tag - Module tag
|
||||
* @param {...any} args - Log arguments
|
||||
*/
|
||||
export function log(tag, ...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.log(`[${ts}] [${tag}]`, ...args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamped error log helper.
|
||||
* @param {string} tag - Module tag
|
||||
* @param {...any} args - Log arguments
|
||||
*/
|
||||
export function logError(tag, ...args) {
|
||||
const ts = new Date().toISOString();
|
||||
console.error(`[${ts}] [${tag}]`, ...args);
|
||||
}
|
||||
Reference in New Issue
Block a user