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:
2026-03-07 16:24:05 +00:00
commit 146c75b243
11 changed files with 2594 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.env
*.log
.DS_Store

14
Dockerfile Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}