Initial minecraft-builder-mcp prototype and Gothic hall build

This commit is contained in:
Emil
2026-09-12 23:22:20 +03:00
commit 6682668c16
103 changed files with 10064 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
MCB_BACKEND_URL=http://127.0.0.1:8765
# Different tokens generated by the Paper plugin; replace locally, never commit.
MCB_TOKEN=
MCB_AGENT_TOKEN=
# Required only for externally launched stdio MCP. Chat sets these per session.
MCB_PLAYER_ID=
MCB_PROJECT_ID=default
# Optional ACP choices. Defaults: pinned adapter, configured model/login.
# MCB_MODEL=
# MCB_ACP_COMMAND=
# MCB_ACP_ARGS=[]
# MCB_ACP_AUTH_METHOD=
# MCB_STATE_DIR=.state/chat
# Dedicated Codex auth/config home; defaults to .state/chat/codex-home.
# MCB_CODEX_HOME=
# Optional explicitly opted-in API auth, never inherited from generic OpenAI env vars.
# MCB_OPENAI_API_KEY=
# MCB_CODEX_API_KEY=
+88
View File
@@ -0,0 +1,88 @@
# Bridge
Локальные MCP-инструменты и игровой ACP-клиент для `minecraft-builder-mcp`.
Требуется Node.js 22.22.3+ и работающий Paper-плагин проекта. Установка воспроизводима по `package-lock.json`:
```bash
npm ci --ignore-scripts
npm test
```
Прямые зависимости закреплены: `codex-acp` 1.11.0, ACP SDK 1.4.0, MCP SDK 1.30.0, Zod 4.6.2, TypeScript 7.0.2. Закреплённая транзитивная версия Codex — 0.153.4. `npm ci` не выбирает свежие версии.
## Запуск
Секреты берутся из окружения, а не из аргументов командной строки. Плагин создаёт отдельные административный и агентский токены. Не коммитьте их. Пример конфигурации переменных — `.env.example`; сам bridge не загружает `.env` автоматически.
- `npm run doctor` проверяет `/health` Paper при наличии `MCB_TOKEN`, создаёт отдельную минимальную конфигурацию Codex и выводит JSON с настройками, ограничениями и точной командой входа.
- `npm run login` вручную запускает вход через device code в отдельный Codex home; `npm run login -- status` проверяет этот вход.
- `npm run mcp` запускает MCP по stdio для внешнего агента. Нужны `MCB_AGENT_TOKEN`, `MCB_PLAYER_ID`, `MCB_PROJECT_ID`.
- `npm run chat` запускает опрос `/ai` и ACP. Нужны `MCB_TOKEN` и `MCB_AGENT_TOKEN`.
- `node dist/rpc.js project_context` — прямой диагностический RPC с агентской областью доступа.
Для локального сервера из этого репозитория удобнее запускать из корня `python3 scripts/bridge.py doctor`, `python3 scripts/bridge.py login`, `python3 scripts/bridge.py status` и затем `python3 scripts/bridge.py chat`. Обёртка использует общую папку `.runtime/bridge-state` и сама читает приватные токены Paper. `status` эквивалентен `login status` или `login --status`; эти команды только проверяют вход и не начинают авторизацию. `doctor`, `login` и `status` можно запускать ещё до создания конфигурации Paper. Не смешивайте вход через эту обёртку с обычным `npm run chat` без соответствующего `MCB_STATE_DIR`: у них разные папки состояния по умолчанию.
`MCB_BACKEND_URL` по умолчанию `http://127.0.0.1:8765`. Разрешён только HTTP на loopback; для удалённого сервера нужен локальный SSH-туннель. UUID игрока/проекта поступают из доверенной конфигурации или ответа Paper, а сервер повторно проверяет владельца и область. В прототипе сервер поддерживает одного настроенного владельца.
Игровой мост запускает установленный локальный `codex-acp`, без `npx @latest`. Он использует отдельный Codex home в `.state/chat/codex-home`; вход и настройки обычного `~/.codex` автоматически не копируются. Один раз запустите `npm run login` из той же рабочей директории и с тем же `MCB_STATE_DIR`, что и `chat`. Помощник использует закреплённый Codex CLI с `login --device-auth`; вход начинается только при явном запуске этой команды. Мост сам не открывает браузер и не обращается к модели во время сборки/обычных тестов. Для API-ключа задайте `MCB_OPENAI_API_KEY` или `MCB_CODEX_API_KEY` и `MCB_ACP_AUTH_METHOD=api-key`; общий `OPENAI_API_KEY` из родительского окружения не наследуется.
Необязательные настройки:
- `MCB_MODEL`: ID модели; применяется через объявленный ACP model selector. Без значения выбирает адаптер.
- `MCB_ACP_COMMAND`: путь к альтернативному ACP-агенту; без него используется текущий Node и закреплённый `codex-acp`.
- `MCB_ACP_ARGS`: JSON-массив аргументов, без shell-интерпретации.
- `MCB_STATE_DIR`: папка состояния, по умолчанию `.state/chat` относительно рабочей директории.
- `MCB_CODEX_HOME`: явный путь к отдельному Codex home для входа. Если там существует отличающийся `config.toml`, мост откажется запускаться и сохранит файл; используйте отдельную пустую папку, а не обычный профиль Codex.
Дочерний процесс получает только разрешённые переменные окружения, отдельные HOME/XDG/CODEX_HOME и минимальный конфиг. Настройки запрашивают `read-only`, `on-request`, проверку пользователем, запрет сети внутри командного sandbox и отключение shell, приложений, браузера, computer use, hooks, plugins и дополнительных агентов. Источники и параметры приведены в `src/security.ts`. Проверка закреплённого CLI подтвердила отключённый `shell_tool` и перечисленные интеграции; `unified_exec` этот CLI оставляет включённым даже при явном отключении, что отражено в doctor.
Есть ограничение самого `codex-acp` 1.11.0: его режим `read-only` посылает на каждый ход sandbox `workspace-write` с выключенной сетью, а не буквальный read-only. Поэтому папка конкретной сессии и временные пути могут оставаться доступными для записи. Код не заявляет полной изоляции ОС, и ещё не проверен на настоящем ходе модели. Процессы Bridge/ACP/MCP также остаются доверенными локальными программами; права Paper проверяются отдельно сервером.
Bridge отклоняет дополнительные запросы разрешений с сообщением в игре. Интерактивное подтверждение разрешений через Minecraft пока не реализовано. ACP capabilities для файлов и терминала не объявляются. Административный токен Paper не передаётся дочернему агенту; MCP получает отдельный ограниченный агентский токен. Конфигурацию нужно проверять через doctor после смены версий или при наличии системных политик Codex. Для динамически передаваемого Minecraft MCP override `default_tools_approval_mode` не устанавливается: закреплённый адаптер передаёт только command/args/env и заменяет соответствующую таблицу конфигурации. Поэтому поведение разрешений MCP остаётся проверкой первого настоящего хода после входа пользователя; сборка и initialize не подтверждают, что строительство через модель уже работает.
## MCP
Инструменты: `project_context`, `region_inspect`, `build_prepare`, `build_apply`, `operation_status`, `operation_cancel`, `operation_undo_prepare`, `part_get`, `part_define`, `camera_list`, `camera_capture`, `asset_list`, `schematic_export`, `schematic_import_prepare`.
Рецепт первой версии:
```json
{
"version": 1,
"operations": [
{"type":"box","min":{"x":0,"y":64,"z":0},"max":{"x":4,"y":70,"z":4},"block":"minecraft:stone_bricks","hollow":true},
{"type":"line","from":{"x":0,"y":64,"z":0},"to":{"x":8,"y":64,"z":8},"block":"minecraft:stone"},
{"type":"cylinder","center":{"x":12,"y":64,"z":12},"radius":3,"height":8,"block":"minecraft:stone_bricks","hollow":true},
{"type":"repeat","count":3,"offset":{"x":6,"y":0,"z":0},"operations":[{"type":"box","min":{"x":0,"y":64,"z":20},"max":{"x":1,"y":67,"z":21},"block":"minecraft:oak_log[axis=y]"}]}
]
}
```
Необязательный `part_id` в `build_prepare` ограничивает запись точной маской зарегистрированной части; расширение задаётся отдельной частью.
Инструменты отсылают данные на Paper для финальной проверки и вычисления. Мост не хранит блоки и не пишет мир. MCP принимает один уровень `repeat`; глубоко вложенные повторения, произвольный код, арки и общие трансформации пока не объявлены. Поддерживаемые состояния блоков и лимиты берутся из `project_context`.
`build_prepare` возвращает `plan_id` и `plan_hash`. Для `build_apply` нужно передать их вместе со стабильным `idempotency_key`. При таймауте запись могла уже начаться: сначала проверьте `operation_status` и используйте тот же ключ. Мост не повторяет запись автоматически.
Локальная библиотека `.schem` поддерживает до 64 файлов, Sponge v2, плотные области до 4096 блоков и повороты 0/90/180/270°. Файлы вручную помещаются в папку `schematics` внутри данных Paper-плагина. `asset_list` возвращает метаданные без выдуманных превью; `schematic_export` сохраняет регион и возвращает ID; `schematic_import_prepare` создаёт обычный проверяемый план, который затем применяется через `build_apply`. Пути, сущности, block entities и неподдерживаемые блоки не принимаются.
`camera_capture` возвращает `pending` и `captureId`; запрос с `capture_id` читает результат. Только `completed` с настоящим изображением превращается в MCP `ImageContent`. Если камеры нет или снимок не готов, изображение не выдумывается. Обычный текстовый ответ ограничен 64 KiB; чтение слишком большой области завершается ошибкой с просьбой уменьшить область. HTTP ограничен по времени, входному объёму и размеру потокового ответа.
## Диалоги и остановка
Один активный ход на проект, до восьми сообщений в очереди. Разные проекты могут обрабатываться независимо. Сессии и папки разделены по проекту и UUID игрока. Ответы отправляются только инициатору через `chat_reply`; общий игровой чат, мысли модели, tool-аргументы и stderr адаптера не транслируются.
Каждый запрос включает переданные сервером позицию игрока, направление взгляда и целевой блок, если они доступны. Вывод сообщений ограничен до четырёх сообщений в секунду для одного игрока.
ID ACP-сессии и последняя компактная сводка сохраняются атомарной заменой `session.json` с правами `0600`. После перезапуска мост пробует `session/load`; при отказе начинает новый диалог со сводкой и явно сообщает об этом. Повтор истории при загрузке не выводится в чат. Незавершённый предыдущий ход помечается отдельно; уже начатые операции необходимо сверить на Paper. Сводка хранит последнее задание и итог, она не заменяет `project_context`.
`/ai stop` должен одновременно установить серверный флаг остановки записи и доставить мосту событие `type:"cancel"`. ACP отменяет генерацию; уже изменённые блоки остаются в журнале. Прерванный агент, не отвечающий на cancel пять секунд, завершается. При каждом запуске мост создаёт `client_id` и передаёт его в `chat_poll`. Смена ID позволяет Paper остановить активные записи и завершить оставшиеся арендованные запросы с уведомлением пользователя; такие задания автоматически не повторяются. Очередь входящих сообщений Paper пока хранится в памяти. После `/ai stop` сервер удерживает запись на паузе до нового задания владельца или `/ai resume`.
## Проверки
`npm test` выполняет HTTP-тесты и настоящий stdio MCP handshake, а также использует отдельный mock ACP-процесс для проверки сессий, резюме, скрытия истории, отказа разрешений и отмены. Проверки очередей подтверждают сериализацию внутри проекта и независимость других проектов. Реальный закреплённый `codex-acp` также прошёл бесплатный `initialize`: ACP v1, `loadSession: true`, методы входа `api-key` и `chat-gpt` до ограничения окружения. Повторная проверка в отдельном home также успешна; при `NO_BROWSER=1` адаптер объявляет только `api-key`, а ChatGPT-вход выполняется отдельным helper `login`. Это не доказывает вход ChatGPT, качество модели или поведение Minecraft: для этого требуется запуск всей системы на настоящем сервере/клиенте.
Опциональный `node test/live-paper.mjs` запускается только против отдельного настоящего тестового Paper: проверяет полый куб, повторное применение с тем же ключом, экспорт `.schem`, библиотеку ассетов, отмену, импорт в тот же anchor, вторую отмену до 27 блоков воздуха, границы и честный ответ недоступной камеры. Он оставляет экспортированный тестовый asset в локальной библиотеке. Это изменяющая мир проверка, она не входит в обычный `npm test`.
Исходные API сверены с [ACP SDK](https://github.com/agentclientprotocol/typescript-sdk), [codex-acp](https://github.com/agentclientprotocol/codex-acp), [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk) [справочником конфигурации Codex](https://learn.chatgpt.com/docs/config-file/config-reference) и [документацией MCP Codex](https://learn.chatgpt.com/docs/extend/mcp?surface=cli).
+1957
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "minecraft-builder-mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Local MCP tools and ACP chat bridge for minecraft-builder-mcp",
"engines": {
"node": ">=22.22.3"
},
"bin": {
"minecraft-builder-mcp": "dist/mcp.js",
"minecraft-builder-chat": "dist/chat.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "npm run build && node --test test/*.test.mjs",
"mcp": "node dist/mcp.js",
"chat": "node dist/chat.js",
"doctor": "node dist/doctor.js",
"login": "node dist/login.js"
},
"dependencies": {
"@agentclientprotocol/codex-acp": "1.11.0",
"@agentclientprotocol/sdk": "1.4.0",
"@modelcontextprotocol/sdk": "1.30.0",
"zod": "4.6.2"
},
"devDependencies": {
"@types/node": "22.20.2",
"typescript": "7.0.2"
}
}
+187
View File
@@ -0,0 +1,187 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { createHash } from 'node:crypto';
import { readFile, mkdir, writeFile, rename } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { join, resolve } from 'node:path';
import { Readable, Writable } from 'node:stream';
import { fileURLToPath } from 'node:url';
import { client, ndJsonStream, PROTOCOL_VERSION, type ClientConnection, type McpServer, type SessionNotification } from '@agentclientprotocol/sdk';
import { agentEnvironment, codexPaths, prepareCodexHome } from './security.js';
export { agentEnvironment } from './security.js';
const require = createRequire(import.meta.url);
export interface ChatMessage { id: string; playerId: string; projectId: string; text: string; type?: 'prompt' | 'cancel' | 'reset'; playerPosition?: { x: number; y: number; z: number; yaw?: number; pitch?: number }; worldId?: string; lookTarget?: { x: number; y: number; z: number } }
export type Reply = (text: string, done: boolean, error?: boolean) => Promise<void>;
export interface AgentSession { prompt(text: string, reply: Reply): Promise<void>; cancel(): Promise<void>; close(): void }
export interface AcpOptions {
backendUrl: string; agentToken: string; stateDir: string; codexHome?: string;
command?: string; args?: string[]; model?: string; authMethod?: string;
timeoutMs?: number; startupTimeoutMs?: number;
env?: NodeJS.ProcessEnv;
}
interface SavedSession { sessionId: string; summary: string; interrupted: boolean }
const INSTRUCTIONS = `You are the Minecraft builder for the current authorized project. Respond in the player's language using short game-chat messages. Use only minecraft-builder-mcp to inspect and edit the world. Begin each new task with project_context; read relevant world data before designing. Only implemented server capabilities may be used. Prepare compact geometry, inspect statistics, apply using stable idempotency keys, and poll operation_status. Preserve manual edits: conflict requires localized redesign or the user's decision, never blindly overwrite fresh snapshots. A cancelled or failed operation may have partial writes. Camera requests are asynchronous; poll capture_id and inspect actual image. If unavailable, explicitly say visually unverified. Never use console commands, shell, files, or other MCP servers to modify Minecraft. Do not claim any action completed without server evidence.`;
export class CodexSession implements AgentSession {
private child?: ChildProcessWithoutNullStreams;
private connection?: ClientConnection;
private sessionId?: string;
private currentReply?: Reply;
private active = false;
private cancelled = false;
private buffer = '';
private finalText = '';
private lastSent = 0;
private sentChars = 0;
private firstPrompt = true;
private summary = '';
private reportReset = false;
private reportInterrupted = false;
private readonly dir: string;
private readonly stateFile: string;
constructor(private readonly message: Pick<ChatMessage, 'projectId' | 'playerId'>, private readonly options: AcpOptions) {
const key = createHash('sha256').update(`${message.projectId}\0${message.playerId}`).digest('hex').slice(0,32);
this.dir = join(resolve(options.stateDir), key); this.stateFile = join(this.dir, 'session.json');
}
private async start(): Promise<void> {
if (this.connection && !this.connection.signal.aborted) return;
await mkdir(this.dir, { recursive: true, mode: 0o700 });
const paths = codexPaths(this.options.stateDir, this.options.codexHome);
await prepareCodexHome(paths);
let saved: SavedSession | undefined;
try {
const raw = JSON.parse(await readFile(this.stateFile, 'utf8')) as SavedSession;
if (typeof raw.sessionId === 'string' && typeof raw.summary === 'string') saved = raw;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.reportReset = true;
}
this.summary = saved?.summary.slice(0, 5000) ?? '';
this.reportInterrupted = saved?.interrupted ?? false;
const executable = this.options.command ?? process.execPath;
const args = this.options.args ?? (this.options.command ? [] : [require.resolve('@agentclientprotocol/codex-acp')]);
const child = spawn(executable, args, { cwd: this.dir, stdio: ['pipe','pipe','pipe'], env: agentEnvironment(this.options.env ?? process.env, paths), shell: false, detached: process.platform !== 'win32' });
this.child = child;
// Adapter stderr may contain prompts or secrets. Drain it without logging raw content.
child.stderr.resume();
const app = client({ name: 'minecraft-builder-mcp-chat' });
app.onRequest('session/request_permission', async () => {
await this.currentReply?.('Codex запросил дополнительное разрешение. Оно отклонено: подтверждение через игровой чат пока не реализовано.', false, true);
return { outcome: { outcome: 'cancelled' } };
});
app.onNotification('session/update', ({ params }) => this.onUpdate(params));
this.connection = app.connect(ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout) as unknown as ReadableStream<Uint8Array>));
const connection = this.connection;
child.once('error', () => connection.close(new Error('Cannot start the ACP process. Check MCB_ACP_COMMAND.')));
child.once('exit', () => connection.close(new Error('ACP process exited. Check Codex authentication and installation.')));
const setupTimeout = setTimeout(() => connection.close(new Error('ACP startup timed out.')), this.options.startupTimeoutMs ?? 30_000);
try {
const init = await connection.agent.request('initialize', { protocolVersion: PROTOCOL_VERSION, clientInfo: { name:'minecraft-builder-mcp',version:'0.1.0' }, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } });
if (init.protocolVersion !== PROTOCOL_VERSION) throw new Error('ACP protocol version is incompatible.');
if (this.options.authMethod) {
if (!init.authMethods?.some(method => method.id === this.options.authMethod)) throw new Error('MCB_ACP_AUTH_METHOD was not advertised by the agent.');
await connection.agent.request('authenticate', { methodId: this.options.authMethod });
}
const mcpServers: McpServer[] = [{ name: 'minecraft-builder-mcp', command: process.execPath,
args: [fileURLToPath(new URL('./mcp.js', import.meta.url))], env: [
{ name: 'MCB_BACKEND_URL', value: this.options.backendUrl },
{ name: 'MCB_AGENT_TOKEN', value: this.options.agentToken },
{ name: 'MCB_PLAYER_ID', value: this.message.playerId },
{ name: 'MCB_PROJECT_ID', value: this.message.projectId },
] }];
let configOptions;
if (saved && init.agentCapabilities?.loadSession) {
try {
const loaded = await connection.agent.request('session/load', { sessionId: saved.sessionId, cwd: this.dir, mcpServers });
this.sessionId = saved.sessionId; configOptions = loaded.configOptions; this.firstPrompt = false;
} catch { this.reportReset = true; }
}
if (!this.sessionId) {
const created = await connection.agent.request('session/new', { cwd: this.dir, mcpServers });
this.sessionId = created.sessionId; configOptions = created.configOptions; this.firstPrompt = true;
if (saved) this.reportReset = true;
}
if (this.options.model) {
const config = configOptions?.find(option => option.category === 'model' || option.id === 'model');
if (!config) throw new Error('Agent did not advertise a model selector; unset MCB_MODEL or use a compatible adapter.');
await connection.agent.request('session/set_config_option', { sessionId: this.sessionId, configId: config.id, value: this.options.model });
}
await this.save(false);
} catch (error) { this.close(); throw error; }
finally { clearTimeout(setupTimeout); }
}
private async onUpdate(params: SessionNotification): Promise<void> {
// History replay from session/load is suppressed; another player's chat never receives it.
if (!this.active || params.sessionId !== this.sessionId || !this.currentReply) return;
const update = params.update;
if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
this.finalText = (this.finalText + update.content.text).slice(0, 8000);
this.buffer += update.content.text;
if (this.buffer.length >= 180 || Date.now() - this.lastSent >= 1500) await this.flush(false);
}
// Deliberately do not forward thought chunks, tool arguments, or raw terminal output.
}
private async flush(done: boolean): Promise<void> {
const remaining = Math.max(0, 8000 - this.sentChars);
const clean = this.buffer.replace(/[\u0000-\u001f\u007f§]/g, ' ').trim().slice(0, remaining);
this.buffer = '';
if (clean) {
for (let offset = 0; offset < clean.length; offset += 240) await this.currentReply?.(clean.slice(offset, offset + 240), false);
this.sentChars += clean.length;
}
this.lastSent = Date.now();
if (done) await this.currentReply?.(this.cancelled ? 'Остановлено. Уже изменённые блоки остаются в истории.' : this.sentChars ? '' : 'Ход Codex завершён без текстового ответа; состояние мира доступно через /ai status.', true);
}
async prompt(text: string, reply: Reply): Promise<void> {
if (this.active) throw new Error('This ACP session already has an active turn.');
this.currentReply = reply; this.cancelled = false;
await this.start();
if (this.cancelled) { await reply('Запрос остановлен до отправки Codex.', true); this.currentReply = undefined; return; }
if (this.reportReset) { await reply('Начат новый диалог Codex с сохранённой краткой сводкой проекта.', false); this.reportReset = false; }
if (this.reportInterrupted) { await reply('Предыдущий ход был прерван перезапуском. Проверю состояние операций перед новым строительством.', false); this.reportInterrupted = false; }
this.active = true; this.buffer = ''; this.finalText = ''; this.sentChars = 0;
const prefix = this.firstPrompt ? `${INSTRUCTIONS}\n${this.summary ? `Previous compact summary (historical, verify world): ${this.summary}\n` : ''}\nPlayer request:\n` : '';
await this.save(true);
const timeout = setTimeout(() => { void this.cancel().catch(() => this.close()); }, this.options.timeoutMs ?? 15 * 60_000);
try {
const result = await this.connection!.agent.request('session/prompt', { sessionId: this.sessionId!, prompt: [{ type: 'text', text: `${prefix}${text}` }] });
this.firstPrompt = false;
this.cancelled ||= result.stopReason === 'cancelled';
this.summary = `Last player request: ${text.slice(0,2000)}\nLast agent response: ${this.finalText.slice(0,3000)}`;
await this.save(false);
await this.flush(true);
} finally { clearTimeout(timeout); this.active = false; this.currentReply = undefined; }
}
async cancel(): Promise<void> {
this.cancelled = true;
if (this.sessionId && this.connection && !this.connection.signal.aborted) {
await this.connection.agent.notify('session/cancel', { sessionId: this.sessionId });
// A stuck adapter must not keep a project queue blocked forever.
const target = this.connection;
setTimeout(() => { if (this.active && this.connection === target) this.close(); }, 5000).unref();
}
}
private async save(interrupted: boolean): Promise<void> {
const temp = `${this.stateFile}.tmp`;
await writeFile(temp, JSON.stringify({ sessionId: this.sessionId, summary: this.summary, interrupted }), { mode: 0o600 });
await rename(temp, this.stateFile);
}
close(): void {
this.connection?.close(); this.connection = undefined;
const child = this.child;
if (child) {
terminateAgentTree(child, 'SIGTERM');
setTimeout(() => terminateAgentTree(child, 'SIGKILL'), 3000).unref();
}
this.child = undefined; this.sessionId = undefined;
}
}
function terminateAgentTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
try {
// The adapter launches Codex and MCP children. On Unix all are in our own process group.
if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal);
else if (child.exitCode === null) child.kill(signal);
} catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') child.kill(signal); }
}
+90
View File
@@ -0,0 +1,90 @@
import { randomUUID } from 'node:crypto';
export class BackendError extends Error {
constructor(public readonly code: string, message: string, public readonly details?: unknown) {
super(message); this.name = 'BackendError';
}
}
export interface Scope { player_id: string; project_id: string }
export interface BackendOptions {
url: string; token: string; scope?: Scope; timeoutMs?: number; maxResponseBytes?: number; maxRequestBytes?: number;
}
export interface RpcBackend { call(method: string, params?: Record<string, unknown>): Promise<unknown> }
export function validateBackendUrl(raw: string): URL {
const url = new URL(raw);
if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.protocol !== 'http:') {
throw new Error('MCB_BACKEND_URL must use HTTP on loopback. Use an SSH tunnel for remote Paper.');
}
if (url.username || url.password || url.search || url.hash || !['/', ''].includes(url.pathname)) {
throw new Error('MCB_BACKEND_URL must contain only a loopback origin.');
}
return url;
}
export class BackendClient implements RpcBackend {
readonly url: URL;
constructor(private readonly options: BackendOptions) {
this.url = validateBackendUrl(options.url);
if (!options.token || /[\r\n]/.test(options.token)) throw new Error('Backend token is required.');
}
async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
// Scope always comes from the launcher, never from model-controlled arguments.
const body = JSON.stringify({ method, params: { ...params, ...this.options.scope }, requestId: randomUUID() });
if (Buffer.byteLength(body) > (this.options.maxRequestBytes ?? 1_048_576)) {
throw new BackendError('request_too_large', 'Request exceeds the bridge byte limit.');
}
return this.request('/v1/rpc', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${this.options.token}` }, body }, true, method === 'camera_capture' ? 12_582_912 : undefined, method === 'camera_capture' ? 60_000 : undefined);
}
async health(): Promise<unknown> { return this.request('/health', { method: 'GET' }, false); }
private async request(path: string, init: RequestInit, envelope = true, responseLimit?: number, requestTimeout?: number): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? requestTimeout ?? 15_000);
try {
const response = await fetch(new URL(path, this.url), { ...init, signal: controller.signal, redirect: 'error' });
const limit = this.options.maxResponseBytes ?? responseLimit ?? 2_097_152;
if (Number(response.headers.get('content-length')) > limit) {
await response.body?.cancel();
throw new BackendError('response_too_large', 'Backend response exceeds the bridge byte limit.');
}
const reader = response.body?.getReader();
if (!reader) throw new BackendError('invalid_response', 'Backend returned an empty response.');
let bytes = 0; const chunks: Uint8Array[] = [];
for (;;) {
const { value, done } = await reader.read(); if (done) break;
bytes += value.byteLength;
if (bytes > limit) { await reader.cancel(); throw new BackendError('response_too_large', 'Backend response exceeds the bridge byte limit.'); }
chunks.push(value);
}
let data: unknown;
try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
catch { throw new BackendError('invalid_response', 'Backend did not return valid JSON.'); }
if (!envelope) {
if (!response.ok) throw new BackendError(`http_${response.status}`, `Backend HTTP ${response.status}.`);
return data;
}
if (!data || typeof data !== 'object' || !('ok' in data)) throw new BackendError('invalid_response', 'Backend response has no RPC envelope.');
if (data.ok === true && 'result' in data && response.ok) return data.result;
if (data.ok === false && 'error' in data && data.error && typeof data.error === 'object') {
const error = data.error as Record<string, unknown>;
throw new BackendError(typeof error.code === 'string' ? error.code : 'backend_error',
this.redact(typeof error.message === 'string' ? error.message : 'Backend rejected the request.'),
error.details ? JSON.parse(this.redact(JSON.stringify(error.details))) : undefined);
}
throw new BackendError('invalid_response', 'Backend returned an invalid RPC envelope.');
} catch (error) {
if (error instanceof BackendError) throw error;
if (controller.signal.aborted) throw new BackendError('timeout', 'Backend request timed out. A write may have started; query operation status before retrying with the same idempotency key.');
throw new BackendError('unavailable', 'Cannot reach the Paper backend. Check its process and local port.');
} finally { clearTimeout(timer); }
}
private redact(value: string): string { return value.split(this.options.token).join('[REDACTED]'); }
}
export function backendFromEnv(env = process.env, admin = false): BackendClient {
const token = admin ? env.MCB_TOKEN : env.MCB_AGENT_TOKEN;
if (!token) throw new Error(`${admin ? 'MCB_TOKEN' : 'MCB_AGENT_TOKEN'} is required; tokens are separate for chat and tools.`);
if (!admin && (!env.MCB_PLAYER_ID || !env.MCB_PROJECT_ID)) throw new Error('MCB_PLAYER_ID and MCB_PROJECT_ID are required for MCP tools.');
return new BackendClient({ url: env.MCB_BACKEND_URL ?? 'http://127.0.0.1:8765', token,
scope: admin ? undefined : { player_id: env.MCB_PLAYER_ID!, project_id: env.MCB_PROJECT_ID! } });
}
+110
View File
@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto';
import { setTimeout as pause } from 'node:timers/promises';
import { type AgentSession, type ChatMessage, type Reply } from './acp.js';
import { BackendError, type RpcBackend } from './backend.js';
export interface ChatRunnerOptions { maxQueue?: number; maxSessions?: number; onError?: (code: string) => void }
export class ChatRunner {
private readonly queues = new Map<string, ChatMessage[]>();
private readonly running = new Set<string>();
private readonly sessions = new Map<string, AgentSession>();
private readonly active = new Map<string, { message: ChatMessage; session: AgentSession }>();
private readonly seen = new Set<string>();
private stopping = false;
private readonly clientId = randomUUID();
private readonly delivery = new Map<string, Promise<void>>();
private readonly replyTime = new Map<string, number>();
constructor(private readonly backend: RpcBackend, private readonly factory: (message: ChatMessage) => AgentSession, private readonly options: ChatRunnerOptions = {}) {}
async poll(): Promise<void> {
const response = await this.backend.call('chat_poll', { client_id: this.clientId });
if (!response || typeof response !== 'object' || !('messages' in response) || !Array.isArray(response.messages)) throw new Error('Invalid chat_poll response.');
for (const raw of response.messages) {
if (!isChatMessage(raw)) { this.options.onError?.('invalid_chat_message'); continue; }
if (this.seen.has(raw.id)) continue;
this.seen.add(raw.id);
if (this.seen.size > 10_000) this.seen.delete(this.seen.values().next().value!);
if (raw.type === 'cancel') { await this.cancel(raw); continue; }
if (raw.type === 'reset') {
await this.reply(raw)('Сброс диалога пока не поддерживается. После перезапуска мост попытается возобновить сессию.', true, true); continue;
}
const queue = this.queues.get(raw.projectId) ?? [];
if (queue.length >= (this.options.maxQueue ?? 8)) { await this.reply(raw)('Очередь проекта заполнена. Повтори запрос после завершения текущего.', true, true); continue; }
queue.push(raw); this.queues.set(raw.projectId, queue);
if (!this.running.has(raw.projectId)) void this.drain(raw.projectId);
}
}
private reply(message: ChatMessage): Reply {
return async (text, done, error) => {
const channel = `${message.projectId}\0${message.playerId}`;
const next = (this.delivery.get(channel) ?? Promise.resolve()).catch(() => {}).then(async () => {
const delay = (this.replyTime.get(channel) ?? 0) + 250 - Date.now();
if (text && delay > 0) await pause(delay);
await this.backend.call('chat_reply', { id: message.id, playerId: message.playerId, text, done, ...(error ? { error: true } : {}) });
this.replyTime.set(channel, Date.now());
});
this.delivery.set(channel, next);
try { await next; } finally { if (this.delivery.get(channel) === next) this.delivery.delete(channel); }
};
}
private async drain(projectId: string): Promise<void> {
this.running.add(projectId);
try {
for (;;) {
if (this.stopping) break;
const message = this.queues.get(projectId)?.shift(); if (!message) break;
const key = `${projectId}\0${message.playerId}`;
let session = this.sessions.get(key);
try {
if (!session) {
if (this.sessions.size >= (this.options.maxSessions ?? 8)) throw new Error('Session capacity reached.');
session = this.factory(message); this.sessions.set(key, session);
}
this.active.set(projectId, { message, session });
await this.reply(message)('Codex обрабатывает запрос. Остановка: /ai stop.', false);
await session.prompt(promptWithContext(message), this.reply(message));
} catch (error) {
session?.close(); this.sessions.delete(key);
const code = error instanceof BackendError ? error.code : 'acp_error';
this.options.onError?.(code);
await this.reply(message)(`Запрос не завершён (${code}). Проверь вход Codex и настройки ACP; изменения мира проверь через /ai status.`, true, true).catch(() => this.options.onError?.('chat_reply_failed'));
} finally { this.active.delete(projectId); }
}
} finally { this.running.delete(projectId); if (!(this.queues.get(projectId)?.length)) this.queues.delete(projectId); }
}
private async cancel(message: ChatMessage): Promise<void> {
const current = this.active.get(message.projectId);
if (current && current.message.playerId === message.playerId) await current.session.cancel().catch(() => current.session.close());
const queue = this.queues.get(message.projectId) ?? [];
const cancelled = queue.filter(item => item.playerId === message.playerId);
this.queues.set(message.projectId, queue.filter(item => item.playerId !== message.playerId));
for (const item of cancelled) await this.reply(item)('Запрос удалён из очереди.', true);
// Paper's /ai stop independently cancels any world operation. ACP cancellation alone is insufficient.
await this.reply(message)('Остановка Codex запрошена; остановку записи выполняет сервер.', true);
}
close(): void {
this.stopping = true;
for (const session of this.sessions.values()) session.close();
}
}
function isChatMessage(value: unknown): value is ChatMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Record<string, unknown>;
return ['id','playerId','projectId'].every(key => typeof item[key] === 'string' && (item[key] as string).length > 0 && (item[key] as string).length <= 200)
&& typeof item.text === 'string' && item.text.length <= 8000 && (item.type === undefined || ['prompt','cancel','reset'].includes(String(item.type)));
}
export function promptWithContext(message: ChatMessage): string {
const context: Record<string, unknown> = {};
function position(raw: unknown): Record<string, number> | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const source = raw as Record<string, unknown>;
if (!['x','y','z'].every(key => typeof source[key] === 'number' && Number.isFinite(source[key]))) return undefined;
const result: Record<string, number> = {};
for (const key of ['x','y','z','yaw','pitch']) if (typeof source[key] === 'number' && Number.isFinite(source[key])) result[key] = source[key];
return result;
}
if (position(message.playerPosition)) context.playerPosition = position(message.playerPosition);
if (position(message.lookTarget)) context.lookTarget = position(message.lookTarget);
if (typeof message.worldId === 'string' && message.worldId.length <= 128) context.worldId = message.worldId;
return Object.keys(context).length ? `Server-observed player context at request time (verify current project with project_context): ${JSON.stringify(context)}\nPlayer request:\n${message.text}` : message.text;
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
import { resolve } from 'node:path';
import { setTimeout as pause } from 'node:timers/promises';
import { backendFromEnv } from './backend.js';
import { CodexSession, type AcpOptions } from './acp.js';
import { ChatRunner } from './chat-runner.js';
let runner: ChatRunner | undefined;
try {
if (!process.env.MCB_AGENT_TOKEN) throw new Error('MCB_AGENT_TOKEN is required for the agent MCP connection.');
let args: string[] | undefined;
if (process.env.MCB_ACP_ARGS) {
const parsed: unknown = JSON.parse(process.env.MCB_ACP_ARGS);
if (!Array.isArray(parsed) || !parsed.every(value => typeof value === 'string')) throw new Error('MCB_ACP_ARGS must be a JSON array of strings.');
args = parsed;
}
const options: AcpOptions = {
backendUrl: process.env.MCB_BACKEND_URL ?? 'http://127.0.0.1:8765',
agentToken: process.env.MCB_AGENT_TOKEN,
stateDir: resolve(process.env.MCB_STATE_DIR ?? '.state/chat'),
command: process.env.MCB_ACP_COMMAND, args,
codexHome: process.env.MCB_CODEX_HOME, model: process.env.MCB_MODEL, authMethod: process.env.MCB_ACP_AUTH_METHOD,
};
const backend = backendFromEnv(process.env, true);
runner = new ChatRunner(backend, message => new CodexSession(message, options), { onError: code => console.error(`minecraft-builder-mcp: ${code}`) });
let stopping = false;
const stop = () => { stopping = true; runner?.close(); };
process.once('SIGINT', stop); process.once('SIGTERM', stop);
console.error('minecraft-builder-mcp chat bridge started (local Paper, ACP).');
let failures = 0;
while (!stopping) {
try { await runner.poll(); failures = 0; }
catch { failures++; if (failures === 1 || failures % 30 === 0) console.error('Paper chat polling failed; check local backend and admin token.'); }
if (!stopping) await pause(Math.min(10_000, 750 * Math.max(1, failures)));
}
} catch (error) { console.error(error instanceof Error ? error.message : 'Chat bridge failed.'); process.exitCode = 1; }
finally { runner?.close(); }
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env node
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { backendFromEnv } from './backend.js';
import { codexPaths, prepareCodexHome, securityReport } from './security.js';
const paths = codexPaths(resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), process.env.MCB_CODEX_HOME);
try {
await prepareCodexHome(paths);
let backend: unknown;
try { backend = await backendFromEnv(process.env, true).health(); }
catch (error) { backend = { status: 'unavailable', message: error instanceof Error ? error.message : 'Health check failed.' }; }
console.log(JSON.stringify({ bridge: '0.1.0', node: process.version, backend, codex: { ...securityReport(paths), login: { command: process.execPath, args: [fileURLToPath(new URL('./login.js', import.meta.url))], env: { MCB_STATE_DIR: resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), MCB_CODEX_HOME: paths.codexHome }, method: 'device-auth (starts only when the user runs this command)' } } }, null, 2));
} catch (error) { console.error(error instanceof Error ? error.message : 'Doctor failed.'); process.exitCode = 1; }
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { agentEnvironment, codexPaths, prepareCodexHome } from './security.js';
try {
const args = process.argv.slice(2);
if (args.length > 1 || (args[0] !== undefined && args[0] !== 'status')) throw new Error('Usage: npm run login [-- status]');
const paths = codexPaths(resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), process.env.MCB_CODEX_HOME);
await prepareCodexHome(paths);
const require = createRequire(import.meta.url);
const child = spawn(process.execPath, [require.resolve('@openai/codex/bin/codex.js'), 'login', ...(args[0] === 'status' ? ['status'] : ['--device-auth'])], { stdio: 'inherit', env: agentEnvironment(process.env, paths), shell: false });
child.once('error', () => { console.error('Unable to launch the pinned Codex login command.'); process.exitCode = 1; });
child.once('exit', code => { process.exitCode = code ?? 1; });
} catch (error) { console.error(error instanceof Error ? error.message : 'Codex login helper failed.'); process.exitCode = 1; }
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env node
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { backendFromEnv } from './backend.js';
import { createMcpServer } from './tools.js';
try {
const server = createMcpServer(backendFromEnv());
await server.connect(new StdioServerTransport());
const shutdown = () => { void server.close().finally(() => process.exit(0)); };
process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown);
} catch (error) { console.error(error instanceof Error ? error.message : 'MCP startup failed.'); process.exitCode = 1; }
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env node
import { backendFromEnv } from './backend.js';
try {
const [method, raw = '{}'] = process.argv.slice(2);
if (!method || !/^[a-z_]+$/.test(method)) throw new Error('Usage: node dist/rpc.js method \'{"params":"values"}\'');
const params: unknown = JSON.parse(raw);
if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error('RPC params must be a JSON object.');
console.log(JSON.stringify(await backendFromEnv().call(method, params as Record<string, unknown>), null, 2));
} catch (error) { console.error(error instanceof Error ? error.message : 'RPC failed.'); process.exitCode = 1; }
+76
View File
@@ -0,0 +1,76 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { delimiter, dirname, join, resolve } from 'node:path';
// These are supported by the pinned Codex 0.153.4 runtime and official configuration reference.
export const CODEX_CONFIG = {
sandbox_mode: 'read-only', approval_policy: 'on-request', approvals_reviewer: 'user',
cli_auth_credentials_store: 'file', allow_login_shell: false, web_search: 'disabled',
sandbox_workspace_write: { network_access: false, writable_roots: [], exclude_slash_tmp: true, exclude_tmpdir_env_var: true },
shell_environment_policy: { inherit: 'none' },
features: {
shell_tool: false, unified_exec: false, shell_snapshot: false,
apps: false, hooks: false, multi_agent: false, plugins: false, remote_plugin: false,
browser_use: false, browser_use_external: false, browser_use_full_cdp_access: false,
computer_use: false, image_generation: false, code_mode: false, code_mode_host: false,
skill_mcp_dependency_install: false,
},
};
export const CODEX_CONFIG_TOML = `# Managed by minecraft-builder-mcp; use a dedicated CODEX_HOME.\nsandbox_mode = "read-only"\napproval_policy = "on-request"\napprovals_reviewer = "user"\ncli_auth_credentials_store = "file"\nallow_login_shell = false\nweb_search = "disabled"\n\n[sandbox_workspace_write]\nnetwork_access = false\nwritable_roots = []\nexclude_slash_tmp = true\nexclude_tmpdir_env_var = true\n\n[shell_environment_policy]\ninherit = "none"\n\n[features]\n${Object.entries(CODEX_CONFIG.features).map(([key,value]) => `${key} = ${value}`).join('\n')}\n`;
export interface CodexPaths { home: string; codexHome: string }
export function codexPaths(stateDir: string, codexHome?: string): CodexPaths {
const state = resolve(stateDir);
return { home: join(state, 'home'), codexHome: resolve(codexHome ?? join(state, 'codex-home')) };
}
export async function prepareCodexHome(paths: CodexPaths): Promise<void> {
for (const directory of [paths.home, paths.codexHome, join(paths.home,'.config'), join(paths.home,'.cache'), join(paths.home,'tmp')]) await mkdir(directory,{recursive:true,mode:0o700});
const configPath = join(paths.codexHome, 'config.toml');
let existing: string | undefined;
try { existing = await readFile(configPath, 'utf8'); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
if (existing !== undefined && existing !== CODEX_CONFIG_TOML) {
throw new Error('MCB_CODEX_HOME contains a different config.toml. Choose a dedicated empty Codex home or the bridge-managed home; existing settings will not be overwritten.');
}
if (existing === undefined) {
try { await writeFile(configPath, CODEX_CONFIG_TOML, { flag: 'wx', mode: 0o600 }); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || await readFile(configPath,'utf8') !== CODEX_CONFIG_TOML) throw error;
}
}
}
/** Explicit allowlist; arbitrary user cloud credentials, global Codex settings and bridge tokens stay outside the child. */
export function agentEnvironment(source: NodeJS.ProcessEnv, paths: CodexPaths): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const key of ['LANG','LC_ALL','LC_CTYPE','TZ','SystemRoot','WINDIR','PATHEXT']) if (source[key] !== undefined) result[key] = source[key];
result.PATH = [dirname(process.execPath), ...(source.PATH ? [source.PATH] : process.platform === 'win32' ? [] : ['/usr/bin','/bin'])].join(delimiter);
result.HOME = paths.home; result.USERPROFILE = paths.home;
result.XDG_CONFIG_HOME = join(paths.home,'.config'); result.XDG_CACHE_HOME = join(paths.home,'.cache');
result.APPDATA = join(paths.home,'.config'); result.LOCALAPPDATA = join(paths.home,'.cache');
result.TMPDIR = join(paths.home,'tmp'); result.TEMP = result.TMPDIR; result.TMP = result.TMPDIR;
result.CODEX_HOME = paths.codexHome;
result.CODEX_CONFIG = JSON.stringify(CODEX_CONFIG);
result.INITIAL_AGENT_MODE = 'read-only'; result.NO_BROWSER = '1';
if (source.MCB_OPENAI_API_KEY) result.OPENAI_API_KEY = source.MCB_OPENAI_API_KEY;
if (source.MCB_CODEX_API_KEY) result.CODEX_API_KEY = source.MCB_CODEX_API_KEY;
return result;
}
export function securityReport(paths: CodexPaths) {
return {
codexHome: paths.codexHome, osHome: paths.home, globalUserConfigurationInherited: false,
authentication: 'Dedicated login or explicitly supplied MCB_OPENAI_API_KEY/MCB_CODEX_API_KEY; never copied automatically',
configuredSandbox: 'read-only', adapterMode: 'read-only', adapterVersion: '1.11.0',
adapterTurnSandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user',
sandboxedCommandNetwork: false, shellToolRequested: false, unifiedExecRequested: false,
pinnedCliFeatureProbe: { codexVersion: '0.153.4', platform: 'linux', shell_tool: false, unified_exec: true },
inheritedMcpServers: false, acpFilesystem: false, acpTerminal: false,
runtimeVerified: false,
limitations: [
'Pinned Codex reports unified_exec enabled even when disabled explicitly; shell_tool is disabled. A real model tool-availability check has not run.',
'codex-acp 1.11.0 overrides turn sandbox to workspace-write even in its read-only mode; the session directory and temporary paths may remain writable.',
'Sandboxed-command network restrictions do not sandbox the bridge, ACP adapter or MCP server processes; these remain trusted local programs.',
'No real model turn or end-to-end sandbox probe has run; configuration and initialization alone do not prove OS-level isolation.',
'Administrator-managed Codex settings and repository-local configuration can also apply; use the dedicated project/state directory and inspect doctor output.',
],
};
}
+64
View File
@@ -0,0 +1,64 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { BackendError, type RpcBackend } from './backend.js';
const id = z.string().min(1).max(200);
const position = z.object({ x: z.number().int().min(-30_000_000).max(30_000_000), y: z.number().int().min(-4096).max(4096), z: z.number().int().min(-30_000_000).max(30_000_000) }).strict();
const block = z.string().regex(/^minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?$/).max(200);
// Deliberate small declarative language: the Paper compiler enforces all resource limits again.
const shape = z.discriminatedUnion('type', [
z.object({ type: z.literal('box'), min: position, max: position, block, hollow: z.boolean().optional() }).strict(),
z.object({ type: z.literal('line'), from: position, to: position, block }).strict(),
z.object({ type: z.literal('cylinder'), center: position, radius: z.number().int().min(0).max(128), height: z.number().int().min(1).max(256), block, hollow: z.boolean().optional() }).strict(),
]);
const operation = z.union([shape, z.object({ type: z.literal('repeat'), count: z.number().int().min(1).max(128), offset: position, operations: z.array(shape).min(1).max(256) }).strict()]);
const recipe = z.object({ version: z.literal(1), operations: z.array(operation).min(1).max(256) }).strict();
const pose = z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite(), yaw: z.number().min(-360).max(360), pitch: z.number().min(-90).max(90), fov: z.number().int().min(30).max(110).optional(), width: z.number().int().min(320).max(1920).optional(), height: z.number().int().min(180).max(1080).optional() }).strict();
export function toolResult(result: unknown): CallToolResult {
let metadata = result;
const content: CallToolResult['content'] = [];
if (result && typeof result === 'object' && 'imageBase64' in result) {
const { imageBase64, mimeType, ...rest } = result as Record<string, unknown>;
if (rest.status !== 'completed' || typeof imageBase64 !== 'string' || imageBase64.length > 12_000_000 || !['image/png', 'image/jpeg'].includes(String(mimeType)) || !/^[A-Za-z0-9+/]*={0,2}$/.test(imageBase64)) {
throw new BackendError('invalid_image', 'Camera returned an invalid or oversized capture.');
}
metadata = rest;
content.push({ type: 'image', data: imageBase64, mimeType: String(mimeType) });
}
const text = JSON.stringify(metadata ?? null);
if (Buffer.byteLength(text) > 65_536) throw new BackendError('context_limit', 'Result is too large for model context. Request a smaller region or summary.');
content.unshift({ type: 'text', text });
return { content };
}
export function createMcpServer(backend: RpcBackend): McpServer {
const server = new McpServer({ name: 'minecraft-builder-mcp', version: '0.1.0' }, { instructions:
'Build only through these tools in the server-authorized project. Start with project_context and region_inspect. Prepare a compact recipe, inspect its summary, then apply with the returned plan ID/hash and a stable unique idempotency key. Conflicts preserve manual edits: never re-read and blindly overwrite them. Query operation_status until terminal; cancellation can leave partial edits. Camera unavailable means visually unverified. Never claim success from preparation alone.' });
function register(name: string, description: string, inputSchema: z.ZodRawShape, readOnly: boolean, idempotent = false) {
server.registerTool(name, { description, inputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, idempotentHint: idempotent, openWorldHint: false } }, async (args) => {
try { return toolResult(await backend.call(name, args as Record<string, unknown>)); }
catch (error) {
const failure = error instanceof BackendError ? { code: error.code, message: error.message, details: error.details } : { code: 'bridge_error', message: 'The bridge could not complete this request.' };
const text = JSON.stringify(failure);
return { isError: true, content: [{ type: 'text', text: Buffer.byteLength(text) <= 32_768 ? text : JSON.stringify({ code: failure.code, message: failure.message, details: 'Details exceed the context budget; inspect a smaller area.' }) }] };
}
});
}
register('project_context', 'Get authorized world, project, area, capabilities, supported blocks, and operation summaries. No full-world dump.', {}, true, true);
register('region_inspect', 'Inspect a bounded inclusive region. Prefer summary; blocks detail is only for a small local area. Both min and max are required; inspect a small section of the project area.', { min: position, max: position, detail: z.enum(['summary', 'blocks']).default('summary') }, true, true);
register('build_prepare', 'Prepare immutable geometry without changing the world. Use supported recipe operations from project_context. Returns plan_id, plan_hash and compact statistics.', { recipe, part_id: id.optional(), dependencies: z.array(position).max(512).optional() }, false);
register('build_apply', 'Apply a prepared plan with compare-before-write protection. Reuse the SAME idempotency_key after uncertain transport outcome; query project_context to recover an unknown operation ID, then operation_status first.', { plan_id: id, plan_hash: id, idempotency_key: id }, false, true);
register('operation_status', 'Get exact state, changed count, conflicts and completion. A terminal cancelled/conflict state can include partial writes.', { operation_id: id }, true, true);
register('operation_cancel', 'Request cancellation before the next server batch. Already applied changes remain journaled.', { operation_id: id }, false, true);
register('operation_undo_prepare', 'Prepare checked undo of a recorded operation. Manual edits after construction become conflicts. Apply returned undo plan with build_apply.', { operation_id: id }, false);
register('part_get', 'Get named part metadata and protected status, without expanding every block.', { part_id: id }, true, true);
register('part_define', 'Register an exact part mask from a completed operation. Server rejects unsupported membership or overlap.', { name: z.string().min(1).max(64), operation_id: id }, false);
register('camera_list', 'List saved camera poses and whether a camera client is connected.', {}, true, true);
register('camera_capture', 'Request a real capture by saved camera_id or pose. A pending response returns captureId; poll using capture_id. Only completed results contain an image. Camera unavailable is not a successful visual check.', { camera_id: id.optional(), pose: pose.optional(), after_operation_id: id.optional(), capture_id: id.optional() }, true);
register('asset_list', 'List up to 64 local schematic assets with dimensions and metadata, optionally filtered by query. No network library or generated thumbnails. Place .schem files manually in the plugin data/schematics directory; asset IDs never accept arbitrary paths.', { query: z.string().max(64).optional() }, true, true);
register('schematic_export', 'Export a dense inclusive region of at most 4096 supported blocks as a local Sponge v2 .schem asset. Optional origin is the clipboard anchor. Returns an asset ID and metadata; files remain in the plugin data/schematics directory.', { name: z.string().min(1).max(64).regex(/^[^\u0000-\u001f\u007f]+$/), min: position, max: position, origin: position.optional() }, false);
register('schematic_import_prepare', 'Prepare a checked import of a local .schem asset at target, optionally rotating 0/90/180/270 degrees. Rejects entities, block entities and unsupported blocks. Returns a normal plan; inspect it and use build_apply to edit the world.', { asset_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), target: position, rotation: z.union([z.literal(0),z.literal(90),z.literal(180),z.literal(270)]).default(0) }, false);
return server;
}
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { setTimeout as pause } from 'node:timers/promises';
import { CodexSession, agentEnvironment } from '../dist/acp.js';
async function fixture(t){
const stateDir=await mkdtemp(join(tmpdir(),'mcb-acp-'));t.after(()=>rm(stateDir,{recursive:true,force:true}));
const log=join(stateDir,'protocol.jsonl');
const options={backendUrl:'http://127.0.0.1:8765',agentToken:'agent-secret',stateDir,command:process.execPath,args:[resolve('test/fixtures/mock-acp.mjs'),log],env:{...process.env,MCB_TOKEN:'ADMIN SECRET'}};
const records=async()=> (await readFile(log,'utf8').catch(()=>'' )).trim().split('\n').filter(Boolean).map(JSON.parse);
return {options,records};
}
test('ACP initializes, injects scoped MCP, preserves sessions, suppresses private replay and thoughts',async t=>{
const {options,records}=await fixture(t);const messages=[];
let session=new CodexSession({projectId:'project',playerId:'player'},options);t.after(()=>session.close());
await session.prompt('Build a tower',async(text,done)=>messages.push({text,done}));
await session.prompt('Make it higher',async(text,done)=>messages.push({text,done}));
assert.ok(messages.some(item=>item.text.includes('Built turn 2')));
assert.ok(!messages.some(item=>item.text.includes('SECRET THOUGHT')));
session.close();session=new CodexSession({projectId:'project',playerId:'player'},options);
await session.prompt('Resume',async(text,done)=>messages.push({text,done}));
assert.ok(!messages.some(item=>item.text.includes('PRIVATE HISTORY')));
const all=await records();assert.equal(all.filter(item=>item.method==='session/new').length,1);assert.equal(all.filter(item=>item.method==='session/load').length,1);
const mcp=all.find(item=>item.method==='session/new').params.mcpServers[0];
assert.ok(mcp.env.some(item=>item.name==='MCB_PLAYER_ID'&&item.value==='player'));
assert.ok(!JSON.stringify(all).includes('ADMIN SECRET'));
});
test('permission requests fail closed and explain in chat',async t=>{
const {options,records}=await fixture(t);const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('request-permission',async text=>messages.push(text));
assert.ok(messages.some(text=>text.includes('отклонено')));
assert.equal((await records()).find(item=>item.id==='approval').result.outcome.outcome,'cancelled');
});
test('ACP cancel completes active prompt without ending the whole daemon',async t=>{
const {options,records}=await fixture(t);const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const work=session.prompt('wait-for-cancel',async text=>messages.push(text));
for(let i=0;i<100;i++){await pause(10);if((await records()).some(item=>item.method==='session/prompt'))break;}
await session.cancel();await work;
assert.ok(messages.some(text=>text.includes('Остановлено')));
});
test('child environment allowlists secrets and forces dedicated Codex configuration',()=>{
const env=agentEnvironment({PATH:'/bin',HOME:'/home/test',MCB_TOKEN:'admin',MCB_AGENT_TOKEN:'agent',AWS_SECRET_ACCESS_KEY:'private',OPENAI_API_KEY:'inherited',MCB_OPENAI_API_KEY:'opt-in',CODEX_CONFIG:'{"sandbox_mode":"danger-full-access"}'},{home:'/isolated/home',codexHome:'/isolated/codex'});
assert.equal(env.HOME,'/isolated/home');assert.equal(env.CODEX_HOME,'/isolated/codex');assert.equal(env.AWS_SECRET_ACCESS_KEY,undefined);assert.equal(env.MCB_TOKEN,undefined);assert.equal(env.OPENAI_API_KEY,'opt-in');assert.equal(JSON.parse(env.CODEX_CONFIG).features.shell_tool,false);assert.equal(env.INITIAL_AGENT_MODE,'read-only');
});
test('cancel during startup does not send a prompt after initialization completes',async t=>{
const {options,records}=await fixture(t);const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const work=session.prompt('must-not-send',async()=>{});await session.cancel();await work;
assert.ok(!(await records()).some(item=>item.method==='session/prompt'));
});
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { BackendClient, backendFromEnv } from '../dist/backend.js';
async function fixture(t, handler) {
const server = createServer(handler); server.listen(0, '127.0.0.1'); await once(server, 'listening');
t.after(() => { server.closeAllConnections(); server.close(); });
return `http://127.0.0.1:${server.address().port}`;
}
test('transport injects trusted actor, authenticates and uses stable envelope', async t => {
let request;
const url = await fixture(t, async (req,res) => {
assert.equal(req.headers.authorization,'Bearer private-token');
let body = ''; for await (const chunk of req) body += chunk;
request = JSON.parse(body); res.end(JSON.stringify({ok:true,result:{project:'p'}}));
});
const backend = new BackendClient({ url, token:'private-token', scope:{player_id:'owner',project_id:'p'} });
assert.deepEqual(await backend.call('project_context',{player_id:'forged',project_id:'elsewhere'}),{project:'p'});
assert.equal(request.params.player_id,'owner'); assert.equal(request.params.project_id,'p'); assert.ok(request.requestId);
});
test('backend error details redact the token', async t => {
const url = await fixture(t, (_req,res) => {res.statusCode=400;res.end(JSON.stringify({ok:false,error:{code:'conflict',message:'private-token is hidden',details:{token:'private-token'}}}));});
const backend = new BackendClient({url,token:'private-token'});
await assert.rejects(backend.call('build_apply'),error => error.code==='conflict' && !error.message.includes('private-token') && error.details.token==='[REDACTED]');
});
test('response is bounded even with streaming and no content-length', async t => {
const url = await fixture(t, (_req,res) => { res.write('x'.repeat(300)); res.end('x'.repeat(300)); });
await assert.rejects(new BackendClient({url,token:'token',maxResponseBytes:100}).call('project_context'),error=>error.code==='response_too_large');
});
test('write timeout is not automatically retried', async t => {
let calls=0; const url = await fixture(t, () => { calls++; });
await assert.rejects(new BackendClient({url,token:'token',timeoutMs:30}).call('build_apply'),error=>error.code==='timeout');
assert.equal(calls,1);
});
test('token cannot follow a redirect, invalid JSON cannot masquerade as result', async t => {
const url = await fixture(t, (_req,res) => {res.writeHead(302,{location:'http://127.0.0.1:1'});res.end();});
await assert.rejects(new BackendClient({url,token:'token'}).call('project_context'),error=>error.code==='unavailable');
});
test('remote endpoints and missing or conflated auth are rejected', () => {
assert.throws(()=>new BackendClient({url:'http://example.com',token:'token'}),/loopback/);
assert.throws(()=>backendFromEnv({MCB_TOKEN:'admin'}),/MCB_AGENT_TOKEN/);
assert.throws(()=>backendFromEnv({MCB_AGENT_TOKEN:'agent'}),/MCB_PLAYER_ID/);
});
+37
View File
@@ -0,0 +1,37 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { setTimeout as pause } from 'node:timers/promises';
import { ChatRunner, promptWithContext } from '../dist/chat-runner.js';
const message=(id,projectId='p',playerId='u',type='prompt')=>({id,projectId,playerId,text:id,type});
async function eventually(predicate){for(let i=0;i<400;i++){if(predicate())return;await pause(5);}assert.fail('Condition timed out');}
test('projects run concurrently while each project stays ordered, replies remain private',async()=>{
const polls=[[message('one'),message('two'),message('other','q','v')]];const replies=[];const started=[];const release=new Map();
const backend={async call(method,params){if(method==='chat_poll'){assert.match(params.client_id,/^[0-9a-f-]{36}$/);return {messages:polls.shift()??[]};}replies.push(params);return {};}};
const runner=new ChatRunner(backend,()=>({async prompt(text,reply){started.push(text);await new Promise(done=>release.set(text,done));await reply(`answer ${text}`,true);},async cancel(){},close(){}}));
try{
await runner.poll();await eventually(()=>started.length===2);
assert.deepEqual(started,['one','other']);release.get('one')();await eventually(()=>started.includes('two'));
release.get('two')();release.get('other')();await eventually(()=>replies.filter(item=>item.done).length===3);
assert.equal(replies.find(item=>item.text==='answer other').playerId,'v');
assert.equal(replies.find(item=>item.text==='answer two').playerId,'u');
}finally{runner.close();}
});
test('stop reaches active session during prompt and drops only that player queued messages',async()=>{
const polls=[[message('one'),message('two')],[message('stop','p','u','cancel')]];const replies=[];let release;let cancelled=0;
const backend={async call(method,params){if(method==='chat_poll'){assert.match(params.client_id,/^[0-9a-f-]{36}$/);return {messages:polls.shift()??[]};}replies.push(params);return {};}};
const runner=new ChatRunner(backend,()=>({async prompt(text,reply){await new Promise(done=>release=done);await reply('stopped',true);},async cancel(){cancelled++;release();},close(){}}));
try{await runner.poll();await eventually(()=>release);await runner.poll();assert.equal(cancelled,1);assert.ok(replies.some(item=>item.id==='two'&&item.done));}finally{runner.close();}
});
test('replayed polling message ID does not start duplicate agent turn',async()=>{
let calls=0;const request=message('one');
const backend={async call(method){return method==='chat_poll'?{messages:[request]}:{};}};
const runner=new ChatRunner(backend,()=>({async prompt(){calls++;},async cancel(){},close(){}}));
try{await runner.poll();await pause(1);await runner.poll();await pause(1);assert.equal(calls,1);}finally{runner.close();}
});
test('player position, gaze and target are forwarded for each request without arbitrary payload fields',()=>{
const first=promptWithContext({...message('build here'),playerPosition:{x:1,y:65,z:3,yaw:90,pitch:10,secret:'hidden'},lookTarget:{x:5,y:64,z:8},worldId:'world'});
assert.ok(first.includes('"x":1'));assert.ok(first.includes('"yaw":90'));assert.ok(first.includes('"lookTarget"'));assert.ok(!first.includes('hidden'));
const next=promptWithContext({...message('build here'),playerPosition:{x:9,y:65,z:3}});assert.ok(next.includes('"x":9'));
});
+24
View File
@@ -0,0 +1,24 @@
import { createInterface } from 'node:readline';
import { appendFileSync } from 'node:fs';
const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');
let promptId;let cancelled=false;let turn=0;
for await (const line of createInterface({input:process.stdin})) {
const msg=JSON.parse(line);
if(process.argv[2]) appendFileSync(process.argv[2],JSON.stringify(msg)+'\n');
if(msg.method==='initialize') send({id:msg.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}});
else if(msg.method==='session/new') send({id:msg.id,result:{sessionId:'session-one'}});
else if(msg.method==='session/load') {
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'PRIVATE HISTORY'}}}});
send({id:msg.id,result:{}});
}
else if(msg.method==='session/prompt') {
turn++;promptId=msg.id;
const content=msg.params.prompt[0].text;
if(content.includes('wait-for-cancel')) continue;
if(content.includes('request-permission')) {send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'dangerous',title:'Permission',kind:'execute'},options:[{optionId:'yes',name:'Allow',kind:'allow_once'}]}});continue;}
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_thought_chunk',content:{type:'text',text:'SECRET THOUGHT'}}}});
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:`Built turn ${turn}.`}}}});
send({id:msg.id,result:{stopReason:'end_turn'}});
} else if(msg.method==='session/cancel') {cancelled=true;send({id:promptId,result:{stopReason:'cancelled'}});}
else if(msg.id==='approval') {send({id:promptId,result:{stopReason:'end_turn'}});}
}
+19
View File
@@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join,resolve} from 'node:path';
import {spawnSync} from 'node:child_process';
test('launcher doctor and login status share a dedicated home without requiring Paper or starting login',async t=>{
const state=await mkdtemp(join(tmpdir(),'mcb-launcher-'));t.after(()=>rm(state,{recursive:true,force:true}));
const env={...process.env,MCB_STATE_DIR:state,MCB_CODEX_HOME:join(state,'codex-home'),MCB_OPENAI_API_KEY:'',MCB_CODEX_API_KEY:''};
const run=args=>spawnSync('python3',[resolve('../scripts/bridge.py'),...args,'--config',join(state,'absent-config.yml')],{env,encoding:'utf8',timeout:20_000});
const doctor=run(['doctor']);assert.equal(doctor.status,0,doctor.stderr);
const data=JSON.parse(doctor.stdout);assert.equal(data.codex.codexHome,env.MCB_CODEX_HOME);
assert.equal(data.codex.login.env.MCB_STATE_DIR,state);
for(const args of [['status'],['login','status'],['login','--status']]){
const status=run(args);assert.equal(status.status,1,status.stderr);assert.match(status.stdout+status.stderr,/not logged in/i);
assert.ok(!(status.stdout+status.stderr).includes('device code'));
}
});
+57
View File
@@ -0,0 +1,57 @@
// Opt-in real MCP image check. The configured online spectator is teleported.
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { setTimeout as pause } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
for (const key of ['MCB_AGENT_TOKEN', 'MCB_PLAYER_ID', 'MCB_PROJECT_ID', 'MCB_CAPTURE_POSE']) {
assert.ok(process.env[key], `${key} is required`);
}
const pose = JSON.parse(process.env.MCB_CAPTURE_POSE);
const transport = new StdioClientTransport({ command: process.execPath, args: [resolve('dist/mcp.js')],
env: Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined && key !== 'MCB_TOKEN')), stderr: 'pipe' });
const client = new Client({ name: 'minecraft-builder-live-camera', version: '0.1.0' });
const startedAt = Date.now();
async function capture(args) {
const reply = await client.callTool({ name: 'camera_capture', arguments: args });
assert.notEqual(reply.isError, true, 'Camera MCP returned an error');
const metadata = JSON.parse(reply.content.find(item => item.type === 'text').text);
assert.equal('imageBase64' in metadata, false, 'Image must be a separate MCP content block');
return { reply, metadata };
}
try {
await client.connect(transport);
const args = { pose };
if (process.env.MCB_AFTER_OPERATION_ID) args.after_operation_id = process.env.MCB_AFTER_OPERATION_ID;
let current = await capture(args);
const id = current.metadata.captureId;
for (let attempt = 0; current.metadata.status === 'pending' && attempt < 150; attempt++) {
await pause(200);
current = await capture({ capture_id: id });
}
const { reply, metadata } = current;
assert.equal(metadata.status, 'completed', 'A real completed capture is required');
assert.equal(metadata.captureId, id);
assert.ok(Date.parse(metadata.capturedAt) >= startedAt - 1000, 'Frame must be newly captured');
const images = reply.content.filter(item => item.type === 'image');
assert.equal(images.length, 1);
assert.equal(images[0].mimeType, 'image/png');
const png = Buffer.from(images[0].data, 'base64');
assert.deepEqual(png.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
assert.equal(png.readUInt32BE(16), metadata.width);
assert.equal(png.readUInt32BE(20), metadata.height);
const directory = resolve(process.env.MCB_CAMERA_OUTPUT_DIR ?? '../.runtime/camera-test');
await mkdir(directory, { recursive: true });
const stem = `mcp-${id}`;
const report = { ...metadata, transport: 'MCP stdio ImageContent', imageBytes: png.length,
imageSha256: createHash('sha256').update(png).digest('hex') };
await writeFile(resolve(directory, `${stem}.png`), png, { mode: 0o600, flag: 'wx' });
await writeFile(resolve(directory, `${stem}.json`), JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
console.log(JSON.stringify({ status: 'passed', transport: report.transport, width: metadata.width,
height: metadata.height, imageBytes: png.length, imagePath: resolve(directory, `${stem}.png`) }));
} finally {
await client.close();
}
+86
View File
@@ -0,0 +1,86 @@
// Opt-in integration driver. It mutates and restores a tiny all-air area on a real development Paper server.
// Credentials arrive only through the environment; this driver never prints them.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { setTimeout as pause } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
for (const name of ['MCB_AGENT_TOKEN','MCB_PLAYER_ID','MCB_PROJECT_ID']) if (!process.env[name]) throw new Error(`${name} is required`);
const transport = new StdioClientTransport({ command: process.execPath, args: [resolve('dist/mcp.js')], env: Object.fromEntries(Object.entries(process.env).filter(([key,value]) => value !== undefined && key !== 'MCB_TOKEN')), stderr: 'pipe' });
const client = new Client({ name: 'minecraft-builder-live-smoke', version: '0.1.0' });
async function tool(name, args = {}, allowError = false) {
for (let attempt = 0; attempt < 100; attempt++) {
const result = await client.callTool({ name, arguments: args });
const data = JSON.parse(result.content.find(item => item.type === 'text').text);
if (result.isError && data.code === 'busy') { await pause(50); continue; }
if (result.isError && !allowError) throw new Error(`${name}: ${data.code}: ${data.message}`);
return { data, isError: result.isError === true };
}
throw new Error(`${name}: backend remained busy`);
}
async function finished(operation_id) {
for (let attempt=0; attempt<400; attempt++) {
const {data} = await tool('operation_status',{operation_id});
if (['applied','conflict','failed','cancelled','recovery_required'].includes(data.status)) return data;
await pause(50);
}
throw new Error(`Operation did not finish within 20 seconds: ${operation_id}`);
}
const min = {x:Number(process.env.MCB_TEST_X ?? 0), y:Number(process.env.MCB_TEST_Y ?? 96), z:Number(process.env.MCB_TEST_Z ?? 0)};
const max = {x:min.x+2,y:min.y+2,z:min.z+2};
let operation;
try {
await client.connect(transport);
const {data:context} = await tool('project_context');
console.log(JSON.stringify({step:'context',project_id:context.project_id,world_id:context.world_id,capabilities:context.capabilities}));
const {data:before} = await tool('region_inspect',{min,max,detail:'blocks'});
assert.equal(before.blocks.length,27); assert.ok(before.blocks.every(block=>block.state==='minecraft:air'),'Test area must be entirely air; choose MCB_TEST_X/Y/Z in the configured project region.');
const {data:plan} = await tool('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max,block:'minecraft:stone_bricks',hollow:true}]}});
assert.equal(plan.changed_blocks,26);
const apply = {plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:`live-smoke-${randomUUID()}`};
const {data:started} = await tool('build_apply',apply); operation=started.operation_id;
const done = await finished(operation); assert.equal(done.status,'applied'); assert.equal(done.written,26);
const {data:replayed} = await tool('build_apply',apply); assert.equal(replayed.operation_id,operation);
const {data:after} = await tool('region_inspect',{min,max,detail:'blocks'});
assert.equal(after.blocks.filter(block=>block.state==='minecraft:stone_bricks').length,26);
assert.equal(after.blocks.filter(block=>block.state==='minecraft:air').length,1);
console.log(JSON.stringify({step:'build-and-idempotency',operation_id:operation,status:done.status,written:done.written}));
const {data:exported}=await tool('schematic_export',{name:'Live MCP hollow box',min,max,origin:min});
assert.match(exported.assetId,/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/);
const {data:library}=await tool('asset_list',{query:'Live MCP hollow box'});
assert.ok(library.assets.some(asset=>asset.assetId===exported.assetId),'Exported schematic must be listed by asset ID');
console.log(JSON.stringify({step:'schematic-export',asset_id:exported.assetId,width:exported.width,height:exported.height,length:exported.length}));
const {data:undo} = await tool('operation_undo_prepare',{operation_id:operation});
const {data:undoStarted} = await tool('build_apply',{plan_id:undo.plan_id,plan_hash:undo.plan_hash,idempotency_key:`live-undo-${randomUUID()}`});
const undoDone=await finished(undoStarted.operation_id); assert.equal(undoDone.status,'applied');
const {data:restored}=await tool('region_inspect',{min,max,detail:'blocks'});assert.ok(restored.blocks.every(block=>block.state==='minecraft:air'));
console.log(JSON.stringify({step:'checked-undo',operation_id:undoStarted.operation_id,status:undoDone.status,restored_air_blocks:27}));
const {data:importPlan}=await tool('schematic_import_prepare',{asset_id:exported.assetId,target:min,rotation:0});
assert.equal(importPlan.changed_blocks,26);
const {data:importStarted}=await tool('build_apply',{plan_id:importPlan.plan_id,plan_hash:importPlan.plan_hash,idempotency_key:`live-import-${randomUUID()}`});
operation=importStarted.operation_id;
const imported=await finished(operation);assert.equal(imported.status,'applied');assert.equal(imported.written,26);
const {data:importedBlocks}=await tool('region_inspect',{min,max,detail:'blocks'});
assert.equal(importedBlocks.blocks.length,27);
assert.equal(importedBlocks.blocks.filter(block=>block.state==='minecraft:stone_bricks').length,26);
assert.equal(importedBlocks.blocks.filter(block=>block.state==='minecraft:air').length,1);
const {data:importUndo}=await tool('operation_undo_prepare',{operation_id:operation});
const {data:importUndoStarted}=await tool('build_apply',{plan_id:importUndo.plan_id,plan_hash:importUndo.plan_hash,idempotency_key:`live-import-undo-${randomUUID()}`});
const importUndoDone=await finished(importUndoStarted.operation_id);assert.equal(importUndoDone.status,'applied');
const {data:importRestored}=await tool('region_inspect',{min,max,detail:'blocks'});
assert.equal(importRestored.blocks.length,27);assert.ok(importRestored.blocks.every(block=>block.state==='minecraft:air'));
console.log(JSON.stringify({step:'schematic-roundtrip-and-undo',asset_id:exported.assetId,import_operation_id:operation,undo_operation_id:importUndoStarted.operation_id,written:imported.written,restored_air_blocks:27}));
operation=undefined;
const cameras = await tool('camera_list');
if(!cameras.data.configured){const unavailable=await tool('camera_capture',{pose:{x:min.x,y:min.y,z:min.z,yaw:0,pitch:0}},true);assert.equal(unavailable.isError,true);assert.equal(unavailable.data.code,'camera_unavailable');console.log(JSON.stringify({step:'camera',status:'honestly-unavailable'}));}
const bounds=context.region;
const outside={x:bounds.max.x+1,y:bounds.min.y,z:bounds.min.z};
const denied=await tool('region_inspect',{min:outside,max:outside},true);assert.equal(denied.isError,true);assert.equal(denied.data.code,'out_of_bounds');
console.log(JSON.stringify({step:'scope-boundary',status:'rejected',code:denied.data.code}));
console.log('LIVE MCP SMOKE PASSED');
} catch(error) {
if(operation) console.error(`Inspect operation ${operation}; test failure can leave blocks. No blind cleanup was attempted.`);
throw error;
} finally { await client.close(); }
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import { resolve } from 'node:path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { toolResult } from '../dist/tools.js';
test('real stdio MCP lists tools, validates recipes, calls backend and returns conflicts', async t => {
const seen=[];
const backend=createServer(async(req,res)=>{
let body='';for await(const chunk of req) body+=chunk;
const rpc=JSON.parse(body);seen.push(rpc);
res.setHeader('content-type','application/json');
res.end(JSON.stringify(rpc.method==='build_apply'?{ok:false,error:{code:'conflict',message:'Manual block changed.'}}:{ok:true,result:{project_id:'project',capabilities:['box']}}));
});backend.listen(0,'127.0.0.1');await once(backend,'listening');
const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:{MCB_BACKEND_URL:`http://127.0.0.1:${backend.address().port}`,MCB_AGENT_TOKEN:'agent',MCB_PROJECT_ID:'project',MCB_PLAYER_ID:'player'},stderr:'pipe'});
const client=new Client({name:'test',version:'1'});
t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();});
await client.connect(transport);
const list=await client.listTools();assert.equal(list.tools.length,14);
assert.ok(list.tools.some(tool=>tool.name==='schematic_import_prepare'));assert.ok(!list.tools.some(tool=>tool.name==='chat_poll'));
const context=await client.callTool({name:'project_context',arguments:{player_id:'forged'}});
assert.equal(JSON.parse(context.content[0].text).project_id,'project');
assert.equal(seen[0].params.player_id,'player');
const invalid=await client.callTool({name:'build_prepare',arguments:{recipe:{version:1,operations:[{type:'execute',code:'bad'}]}}});
assert.equal(invalid.isError,true);assert.equal(seen.length,1);
const prepared=await client.callTool({name:'build_prepare',arguments:{recipe:{version:1,operations:[{type:'box',min:{x:0,y:64,z:0},max:{x:2,y:66,z:2},block:'minecraft:stone'}]}}});
assert.ok(!prepared.isError);assert.equal(seen[1].method,'build_prepare');
const conflict=await client.callTool({name:'build_apply',arguments:{plan_id:'plan',plan_hash:'hash',idempotency_key:'key'}});
assert.equal(conflict.isError,true);assert.equal(JSON.parse(conflict.content[0].text).code,'conflict');
const beforeAssets=seen.length;
const invalidPath=await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'../../secret',target:{x:0,y:64,z:0}}});assert.equal(invalidPath.isError,true);
const invalidRotation=await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:45}});assert.equal(invalidRotation.isError,true);assert.equal(seen.length,beforeAssets);
await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:90}});assert.equal(seen.at(-1).params.rotation,90);
await client.callTool({name:'asset_list',arguments:{query:'tower'}});assert.equal(seen.at(-1).params.query,'tower');
});
test('camera image is a native MCP image rather than a text context dump',()=>{
const result=toolResult({status:'completed',captureId:'c1',imageBase64:'YWJj',mimeType:'image/png'});
assert.equal(result.content[1].type,'image');assert.ok(!result.content[0].text.includes('YWJj'));
assert.throws(()=>toolResult({status:'pending',imageBase64:'YWJj',mimeType:'image/png'}),/invalid/);
assert.throws(()=>toolResult({blocks:'a'.repeat(70000)}),/too large/);
});
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {codexPaths,prepareCodexHome,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js';
test('dedicated home gets known config and never overwrites existing user configuration',async t=>{
const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true}));
const paths=codexPaths(root);await prepareCodexHome(paths);
assert.equal(await readFile(join(paths.codexHome,'config.toml'),'utf8'),CODEX_CONFIG_TOML);
await prepareCodexHome(paths);
await writeFile(join(paths.codexHome,'config.toml'),'sandbox_mode = "danger-full-access"\n');
await assert.rejects(prepareCodexHome(paths),/will not be overwritten/);
assert.equal(await readFile(join(paths.codexHome,'config.toml'),'utf8'),'sandbox_mode = "danger-full-access"\n');
});
test('doctor security report describes upstream workspace-write limitation honestly',()=>{
const report=securityReport(codexPaths('/state'));
assert.equal(report.configuredSandbox,'read-only');assert.equal(report.adapterTurnSandbox,'workspace-write');assert.equal(report.runtimeVerified,false);
assert.equal(report.pinnedCliFeatureProbe.unified_exec,true);
assert.ok(report.limitations.some(text=>text.includes('temporary paths')));
});
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext",
"types": ["node"], "strict": true, "noUncheckedIndexedAccess": true, "outDir": "dist", "rootDir": "src",
"declaration": true, "sourceMap": true, "skipLibCheck": true
},
"include": ["src/**/*.ts"]
}