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
+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')));
});