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