Expand material support and checkpoint the completed building toolkit

Expose the runtime block/item registry through compact material search and
single-material descriptions. Preserve private block-entity data through
checked edits and durable undo without sending payloads to model context.

Include the completed terrain tools, isolated world/map plugin, station lift,
ACP streaming and guidance fixes, local camera auto-connect, construction
scripts, and their public documentation, references and verification records.
Active station decoration and private runtime data remain outside this commit.

Validation: 145 Maven tests, 47 Bridge tests, successful camera Gradle build,
and isolated Paper verification of all 1,196 block defaults plus 5,392
independent property cases for placement, same-material edits and restoration.
This commit is contained in:
Emil
2026-09-13 16:59:13 +03:00
parent 0290559abf
commit 89c53d7058
179 changed files with 37719 additions and 162 deletions
+129 -1
View File
@@ -1,10 +1,12 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
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';
import { longReply, overlongWord } from './fixtures/streamed-replies.mjs';
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');
@@ -12,6 +14,34 @@ async function fixture(t){
const records=async()=> (await readFile(log,'utf8').catch(()=>'' )).trim().split('\n').filter(Boolean).map(JSON.parse);
return {options,records};
}
test('updated terrain and material guidance reaches resumed ACP sessions once without resetting history',async t=>{
const {options,records}=await fixture(t);
let session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('first request',async()=>{});session.close();
const key=createHash('sha256').update('p\0u').digest('hex').slice(0,32);
const file=join(options.stateDir,key,'session.json');
const saved=JSON.parse(await readFile(file,'utf8'));saved.instructionsHash='older-prompt';
await writeFile(file,JSON.stringify(saved));
session=new CodexSession({projectId:'p',playerId:'u'},options);
await session.prompt('after update',async()=>{});
await session.prompt('next request',async()=>{});session.close();
session=new CodexSession({projectId:'p',playerId:'u'},options);
await session.prompt('same guidance after restart',async()=>{});
const calls=await records();const prompts=calls.filter(v=>v.method==='session/prompt').map(v=>v.params.prompt[0].text);
assert.ok(prompts[1].includes('shacraft-natural-v1'));
assert.ok(prompts[1].includes('Avoid large rectangular plateaus'));
assert.ok(prompts[1].includes('NOT as terrain_preview parameters'));
for(const prompt of [prompts[0],prompts[1]]) {
assert.ok(prompt.includes('project_context contains only a catalog summary'));
assert.ok(prompt.includes('material_search'));
assert.ok(prompt.includes('material_describe for 1–3 chosen materials'));
assert.ok(prompt.includes('do not enumerate the registry'));
assert.ok(prompt.includes('item-only materials can be discovered but cannot be placed'));
}
assert.equal(prompts[2],'next request');assert.equal(prompts[3],'same guidance after restart');
assert.equal(calls.filter(v=>v.method==='session/new').length,1);
assert.equal(calls.filter(v=>v.method==='session/load').length,2);
});
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());
@@ -34,6 +64,38 @@ test('permission requests fail closed and explain in chat',async t=>{
assert.ok(messages.some(text=>text.includes('отклонено')));
assert.equal((await records()).find(item=>item.id==='approval').result.outcome.outcome,'cancelled');
});
test('MCP startup failure before session creation stops the prompt and redacts diagnostics',async t=>{
const {options,records}=await fixture(t);options.args.push('mcp-fail-new');const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await assert.rejects(session.prompt('Must not run',async text=>messages.push(text)),error=>error.code==='mcp_unavailable');
assert.ok(!(await records()).some(item=>item.method==='session/prompt'));
assert.deepEqual(messages,['Minecraft MCP не запустился: инструменты строительства недоступны. Запрос остановлен; проверь настройки и процесс моста.']);
assert.ok(!messages.join(' ').includes('agent-secret'));
});
test('MCP startup failure during load stops the new turn while historical failures stay private',async t=>{
const {options,records}=await fixture(t);const messages=[];
let session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('First turn',async()=>{});session.close();options.args.push('mcp-fail-load');
session=new CodexSession({projectId:'p',playerId:'u'},options);
await assert.rejects(session.prompt('Must not run',async text=>messages.push(text)),error=>error.code==='mcp_unavailable');
assert.equal((await records()).filter(item=>item.method==='session/prompt').length,1);
assert.ok(messages.some(text=>text.includes('Minecraft MCP не запустился')));
assert.ok(!messages.join(' ').includes('PRIVATE'));
});
test('MCP startup failures from another session do not block the selected session',async t=>{
const {options}=await fixture(t);options.args.push('mcp-fail-other-session');const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await session.prompt('Continue selected session',async text=>messages.push(text));
assert.ok(messages.some(text=>text.includes('Built turn 1')));
assert.ok(!messages.some(text=>text.includes('MCP не запустился')));
});
test('an asynchronous MCP startup failure interrupts an active turn with a safe explanation',async t=>{
const {options}=await fixture(t);const messages=[];
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
await assert.rejects(session.prompt('mcp-fail-active',async(text,done)=>messages.push({text,done})),error=>error.code==='mcp_unavailable');
assert.ok(messages.some(item=>item.text.includes('Minecraft MCP не запустился')));
assert.ok(!messages.some(item=>item.done || item.text.includes('agent-secret')));
});
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());
@@ -52,3 +114,69 @@ test('cancel during startup does not send a prompt after initialization complete
const work=session.prompt('must-not-send',async()=>{});await session.cancel();await work;
assert.ok(!(await records()).some(item=>item.method==='session/prompt'));
});
test('delayed token streams keep words intact and flush the next turn independently',async t=>{
const {options}=await fixture(t);
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const first=[];
await session.prompt('stream-greeting',async(text,done)=>first.push({text,done}));
const visible=items=>items.filter(item=>item.text).map(item=>item.text).join(' ').replace(/\s+/g,' ').trim();
assert.equal(visible(first),'Привет! Что построим?','ACP token boundaries must not become game-chat word boundaries');
assert.equal(first.at(-1).done,true);
assert.equal(first.filter(item=>item.done).length,1);
await pause(1600);
const second=[];
await session.prompt('stream-follow-up',async(text,done)=>second.push({text,done}));
assert.equal(visible(second),'Понятно. Проверяю освещение','a new prompt resets buffering and completion flushes an unfinished sentence');
assert.equal(second.at(-1).done,true);
assert.equal(second.filter(item=>item.done).length,1);
});
test('slow chat delivery preserves streamed text order and completes after the final reply',async t=>{
const {options}=await fixture(t);
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const messages=[];let inFlight=0;let maxInFlight=0;let calls=0;
await session.prompt('stream-slow-delivery',async(text,done)=>{
const call=++calls;
maxInFlight=Math.max(maxInFlight,++inFlight);
// A slower first HTTP reply must not let later chunks or the done marker overtake it.
await pause(call===1 ? 100 : 3);
messages.push({text,done});
inFlight--;
});
assert.equal(inFlight,0,'prompt completion must await all chat replies');
assert.equal(maxInFlight,1,'chat replies must be serialized');
assert.equal(messages.at(-1).done,true);
assert.equal(messages.filter(item=>item.done).length,1);
const visible=messages.filter(item=>item.text).map(item=>item.text);
assert.ok(visible.length>1,'long replies need multiple Minecraft chat messages');
assert.ok(visible.every(text=>text.length<=240),'chat messages must respect the UTF-16 length bound');
assert.ok(visible.every(text=>text.isWellFormed()),'chunking must not split a surrogate pair');
assert.equal(visible.join(' ').replace(/\s+/g,' ').trim(),longReply,'visible text must retain every word exactly once and in order');
});
test('a word longer than one chat message is bounded without splitting emoji',async t=>{
const {options}=await fixture(t);
const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close());
const messages=[];
await session.prompt('stream-overlong-word',async(text,done)=>messages.push({text,done}));
const visible=messages.filter(item=>item.text).map(item=>item.text);
assert.ok(visible.length>1);
assert.ok(visible.every(text=>text.length<=240 && text.isWellFormed()));
assert.equal(visible.join(''),overlongWord,'an unavoidable long-word split must preserve all Unicode text');
assert.equal(messages.at(-1).done,true);
});
test('only correlated Minecraft tools receive one-use approval in the active session',async t=>{
for(const mode of ['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush','foreign','unknown','wrong-session','uncorrelated','completed','no-meta','persistent']) {
await t.test(mode,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('mcp-approval-'+mode,async text=>messages.push(text));
const response=(await records()).find(item=>item.id==='approval').result.outcome;
assert.deepEqual(response,['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush'].includes(mode)?{outcome:'selected',optionId:'allow_once'}:{outcome:'cancelled'});
assert.equal(messages.some(text=>text.includes('отклонено')),!['valid','material-search','material-describe','terrain-preview','terrain-prepare','terrain-brush'].includes(mode));
});
}
});
+32 -1
View File
@@ -1,21 +1,52 @@
import { createInterface } from 'node:readline';
import { appendFileSync } from 'node:fs';
import { setTimeout as pause } from 'node:timers/promises';
import { greetingChunks, followUpChunks, longReply, overlongWord, tokenChunks } from './streamed-replies.mjs';
const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');
const startupFailure = (sessionId = 'session-one') => send({method:'session/update',params:{sessionId,update:{sessionUpdate:'tool_call',toolCallId:'mcp_startup.minecraft-builder-mcp',title:'mcp__minecraft-builder-mcp__startup',kind:'other',status:'failed',content:[{type:'content',content:{type:'text',text:'PRIVATE STARTUP DETAIL agent-secret'}}]}}});
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/new') {
if(process.argv[3]==='mcp-fail-new') startupFailure();
if(process.argv[3]==='mcp-fail-other-session') startupFailure('unrelated-session');
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({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'tool_call',toolCallId:'old-mcp-tool-call',title:'mcp__minecraft-builder-mcp__project_context',kind:'other',status:'failed',content:[{type:'content',content:{type:'text',text:'PRIVATE HISTORY FAILED TOOL'}}]}}});
if(process.argv[3]==='mcp-fail-load') startupFailure();
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('mcp-fail-active')) {startupFailure();continue;}
if(content.includes('wait-for-cancel')) continue;
if(content.includes('mcp-approval-')) {
const mode=content.match(/mcp-approval-([a-z-]+)/)?.[1];
const server=mode==='foreign'?'other-server':'minecraft-builder-mcp';
const tool=mode==='unknown'?'run_shell':mode==='material-search'?'material_search':mode==='material-describe'?'material_describe':mode==='terrain-preview'?'terrain_preview':mode==='terrain-prepare'?'terrain_prepare':mode==='terrain-brush'?'terrain_brush_prepare':'build_prepare';
const sid=mode==='wrong-session'?'other-session':'session-one';
if(mode!=='uncorrelated') send({method:'session/update',params:{sessionId:sid,update:{sessionUpdate:'tool_call',toolCallId:'mc-call',kind:'execute',title:`mcp.${server}.${tool}`,status:'in_progress',rawInput:{server,tool,arguments:{}},_meta:{is_mcp_tool_call:true}}}});
if(mode==='completed') send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'tool_call_update',toolCallId:'mc-call',status:'completed'}}});
send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'mc-call',kind:'execute',status:'pending'},...(mode==='no-meta'?{}:{_meta:{is_mcp_tool_approval:true}}),options:[{optionId:mode==='persistent'?'allow_always':'allow_once',name:'Allow',kind:mode==='persistent'?'allow_always':'allow_once'}]}});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;}
if(content.includes('stream-greeting') || content.includes('stream-follow-up') || content.includes('stream-slow-delivery') || content.includes('stream-overlong-word')) {
const greeting = content.includes('stream-greeting');
const chunks = greeting ? greetingChunks : content.includes('stream-follow-up') ? followUpChunks : tokenChunks(content.includes('stream-overlong-word') ? overlongWord : longReply);
// Real models can think for seconds before emitting their first incomplete token.
if(greeting) await pause(1600);
for(const text of chunks) {
send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text}}}});
await pause(greeting ? 12 : 1);
}
send({id:msg.id,result:{stopReason:'end_turn'}});
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'}});
+17
View File
@@ -0,0 +1,17 @@
export const greetingChunks = ['Пр', 'ивет! ', 'Что пост', 'роим?'];
export const followUpChunks = ['По', 'нятно. ', 'Проверяю ', 'осве', 'щение'];
export const longReply = 'Осматриваю зал. ' + Array.from({ length: 28 }, (_, i) =>
`Арка${i + 1} цела, фонарь${i + 1} 🏮 установлен ровно`
).join(', ') + '. Проверка завершена';
export const overlongWord = 'А'.repeat(239) + '🏮'.repeat(150) + 'конец';
export function tokenChunks(text) {
const sizes = [2, 3, 7, 4, 11];
const chunks = [];
for (let offset = 0, i = 0; offset < text.length; i++) {
const end = Math.min(text.length, offset + sizes[i % sizes.length]);
chunks.push(text.slice(offset, end));
offset = end;
}
return chunks;
}
+61
View File
@@ -0,0 +1,61 @@
// Opt-in isolated Paper test: relative edits, native preview, halo conflicts, full restoration.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { writeFile } from 'node:fs/promises';
import { setTimeout as pause } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client=new Client({name:'brush-live-test',version:'1'});
const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:Object.fromEntries(Object.entries(process.env).filter(([k,v])=>v!==undefined&&k!=='MCB_TOKEN')),stderr:'pipe'});
async function call(name,args={},allowError=false){for(let i=0;i<100;i++){
const result=await client.callTool({name,arguments:args});const data=JSON.parse(result.content.find(c=>c.type==='text').text);
if(result.isError&&data.code==='busy'){await pause(100);continue;}
if(result.isError&&!allowError)throw new Error(`${name}: ${data.code}: ${data.message}`);
return {data,result};
}throw new Error('Still busy');}
async function done(id){for(let i=0;i<1000;i++){const {data}=await call('operation_status',{operation_id:id});if(['applied','conflict','cancelled','failed','recovery_required'].includes(data.status))return data;await pause(50);}throw new Error(`Still running: ${id}`);}
async function apply(plan){const args={plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:'brush-test-'+randomUUID()};const {data}=await call('build_apply',args);return await done(data.operation_id);}
async function undo(id){const {data}=await call('operation_undo_prepare',{operation_id:id});const result=await apply(data);assert.equal(result.status,'applied');}
const min={x:-4,y:80,z:-4},max={x:4,y:92,z:4};
async function air(){const {data}=await call('region_inspect',{min,max,detail:'summary'});assert.deepEqual(data.palette,{'minecraft:air':1053});}
async function surface(){const {data}=await call('region_inspect',{min:{x:0,y:80,z:0},max:{x:0,y:92,z:0},detail:'blocks'});return Math.max(...data.blocks.filter(b=>b.state!=='minecraft:air').map(b=>b.pos.y));}
const brush={min,max,center:{x:0,z:0},radius:3,strength:1,falloff:0.5};
let base;
try{
await client.connect(transport);
assert.ok((await call('project_context')).data.capabilities.includes('terrain_brush_prepare'));await air();
const {data:basePlan}=await call('build_prepare',{recipe:{version:1,operations:[
{type:'box',min,max:{x:4,y:83,z:4},block:'minecraft:stone'},
{type:'box',min:{x:-4,y:84,z:-4},max:{x:4,y:84,z:4},block:'minecraft:grass_block'},
]}});base=await apply(basePlan);assert.equal(base.status,'applied');assert.equal(await surface(),84);
for(const [action,extra,expected] of [['raise',{amount:3},87],['lower',{amount:2},82],['flatten',{height:86},86]]){
const {data:plan,result}=await call('terrain_brush_prepare',{brush:{...brush,action,...extra}});
assert.equal(plan.plan_state,'prepared');assert.equal(plan.dependency_blocks,1053);assert.equal(plan.world_edited,false);assert.equal(await surface(),84);
const png=result.content.find(c=>c.type==='image');assert.ok(png);
if(action==='raise')await writeFile('../docs/references/terrain-brush-live-preview.png',Buffer.from(png.data,'base64'));
const applied=await apply(plan);assert.equal(applied.status,'applied');assert.equal(await surface(),expected);
await undo(applied.operation_id);assert.equal(await surface(),84);
console.log(JSON.stringify({action,status:'passed',written:applied.written,center_after:expected,undo_height:84}));
}
const {data:spikePlan}=await call('terrain_brush_prepare',{brush:{...brush,radius:1,action:'raise',amount:4,falloff:0}});const spike=await apply(spikePlan);assert.equal(spike.status,'applied');
const {data:smoothPlan}=await call('terrain_brush_prepare',{brush:{...brush,action:'smooth',smooth_radius:1}});const smooth=await apply(smoothPlan);assert.equal(smooth.status,'applied');assert.ok(await surface()<88);await undo(smooth.operation_id);assert.equal(await surface(),88);
const spikeMin={x:-1,y:84,z:-1},spikeMax={x:1,y:88,z:1};
const spikeSnapshot=(await call('region_inspect',{min:spikeMin,max:spikeMax,detail:'blocks'})).data.blocks;
for(const b of spikeSnapshot){const inside=b.pos.x*b.pos.x+b.pos.z*b.pos.z<=1;assert.equal(b.state,inside?(b.pos.y===88?'minecraft:grass_block[snowy=false]':'minecraft:stone'):(b.pos.y===84?'minecraft:grass_block[snowy=false]':'minecraft:air'));}
const {data:removeSpike}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min:{x:-1,y:85,z:-1},max:spikeMax,block:'minecraft:air'},{type:'box',min:spikeMin,max:{x:1,y:84,z:1},block:'minecraft:grass_block'}]}});
assert.equal((await apply(removeSpike)).status,'applied');assert.equal(await surface(),84);
console.log(JSON.stringify({action:'smooth',status:'passed',written:smooth.written}));
const {data:stale}=await call('terrain_brush_prepare',{brush:{...brush,action:'raise',amount:2}});
const point={x:4,y:86,z:0};const {data:foreignPlan}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min:point,max:point,block:'minecraft:gold_block'}]}});const foreign=await apply(foreignPlan);assert.equal(foreign.status,'applied');
const conflict=await apply(stale);assert.equal(conflict.status,'conflict');assert.equal(conflict.written,0);
const denied=await call('terrain_brush_prepare',{brush:{...brush,action:'raise',amount:2}},true);assert.ok(denied.result.isError);await undo(foreign.operation_id);
const {data:empty}=await call('terrain_brush_prepare',{brush:{...brush,action:'flatten',height:84}});assert.equal(empty.plan_state,'empty');assert.ok(!empty.plan_id);
// Base ownership has been superseded by checked brush undos. Verify every fixture cell,
// then prepare a fresh checked cleanup of this isolated fixture; never force the old undo.
const baseMax={x:4,y:84,z:4};
const restored=(await call('region_inspect',{min,max:baseMax,detail:'blocks'})).data.blocks;
assert.equal(restored.length,405);for(const b of restored)assert.equal(b.state,b.pos.y===84?'minecraft:grass_block[snowy=false]':'minecraft:stone');
const {data:cleanup}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max:baseMax,block:'minecraft:air'}]}});
assert.equal((await apply(cleanup)).status,'applied');base=undefined;await air();console.log('LIVE BRUSH MCP PASSED; full fixture restored to air');
}finally{if(base)console.error(`Fixture needs inspection; base operation: ${base.operation_id}`);await client.close();}
+58
View File
@@ -0,0 +1,58 @@
// Opt-in live test in an isolated all-air fixture. No credentials are printed.
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { resolve } from 'node:path';
import { readFile } from 'node:fs/promises';
import { setTimeout as pause } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const client=new Client({name:'terrain-live-test',version:'1'});
const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:Object.fromEntries(Object.entries(process.env).filter(([k,v])=>v!==undefined&&k!=='MCB_TOKEN')),stderr:'pipe'});
async function call(name,args={},allowError=false){for(let i=0;i<100;i++){
const result=await client.callTool({name,arguments:args});const data=JSON.parse(result.content.find(c=>c.type==='text').text);
if(result.isError&&data.code==='busy'){await pause(100);continue;}
if(result.isError&&!allowError)throw new Error(`${name}: ${data.code}: ${data.message}`);
return {data,result};
}throw new Error('Still busy');}
async function done(id){for(let i=0;i<1000;i++){const {data}=await call('operation_status',{operation_id:id});if(['applied','conflict','cancelled','failed','recovery_required'].includes(data.status))return data;await pause(50);}throw new Error(`Still running: ${id}`);}
async function apply(plan){const args={plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:'terrain-test-'+randomUUID()};const {data}=await call('build_apply',args);const result=await done(data.operation_id);return {args,result};}
async function undo(id){const {data}=await call('operation_undo_prepare',{operation_id:id});const {result}=await apply(data);assert.equal(result.status,'applied');return result;}
const recipe=JSON.parse(await readFile('../examples/terrain/small-hill.json','utf8'));
const {min,max}=recipe;
const inspect=async()=> (await call('region_inspect',{min,max,detail:'blocks'})).data.blocks;
const air=blocks=>assert.ok(blocks.every(b=>b.state==='minecraft:air'));
try{
await client.connect(transport);
const {data:context}=await call('project_context');assert.ok(context.capabilities.includes('terrain_prepare'));
air(await inspect());
const {data:preview,result:previewResult}=await call('terrain_preview',{recipe});
assert.equal(preview.kind,'terrain_heightmap_preview');assert.equal(preview.world_verified,false);assert.equal(preview.tile_count,1);
const png=previewResult.content.find(c=>c.type==='image');assert.ok(png);assert.equal(Buffer.from(png.data,'base64').subarray(1,4).toString(),'PNG');
air(await inspect());
const {data:plan}=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0});air(await inspect());assert.ok(plan.changed_blocks>0);
const {args,result}=await apply(plan);assert.equal(result.status,'applied');
assert.equal((await call('build_apply',args)).data.operation_id,result.operation_id);
const built=await inspect();assert.ok(built.some(b=>b.state.startsWith('minecraft:grass_block')));assert.ok(built.some(b=>b.state==='minecraft:dirt'));
assert.equal(built.filter(b=>b.state!=='minecraft:air').length,result.written);
assert.equal((await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0})).data.status,'empty');
await undo(result.operation_id);air(await inspect());
console.log(JSON.stringify({check:'terrain-native-preview-prepare-apply-idempotency-undo',status:'passed',written:result.written}));
const {data:stale}=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0});
const {data:goldPlan}=await call('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max:min,block:'minecraft:gold_block'}]}});
const {result:gold}=await apply(goldPlan);assert.equal(gold.status,'applied');
const denied=await call('terrain_prepare',{terrain_id:preview.terrain_id,tile_index:0},true);assert.equal(denied.data.code,'protected_terrain');
const {result:conflict}=await apply(stale);assert.equal(conflict.status,'conflict');assert.equal(conflict.written,0);
const masked=structuredClone(recipe);masked.preserve=[{min,max:min}];
const {data:maskedPreview}=await call('terrain_preview',{recipe:masked});
const {data:maskedPlan}=await call('terrain_prepare',{terrain_id:maskedPreview.terrain_id,tile_index:0});
const {result:maskedDone}=await apply(maskedPlan);assert.equal(maskedDone.status,'applied');
assert.equal((await call('region_inspect',{min,max:min,detail:'blocks'})).data.blocks[0].state,'minecraft:gold_block');
await undo(maskedDone.operation_id);await undo(gold.operation_id);air(await inspect());
console.log(JSON.stringify({check:'protected-existing-build-preserve-mask-and-stale-plan-conflict',status:'passed'}));
const empty=structuredClone(recipe);empty.preserve=[{min,max}];
const {data:emptyPreview}=await call('terrain_preview',{recipe:empty});assert.equal((await call('terrain_prepare',{terrain_id:emptyPreview.terrain_id,tile_index:0})).data.status,'empty');
const outside=structuredClone(recipe);outside.min.x=200;outside.max.x=207;
const {data:outsidePreview}=await call('terrain_preview',{recipe:outside});assert.equal((await call('terrain_prepare',{terrain_id:outsidePreview.terrain_id,tile_index:0},true)).data.code,'out_of_bounds');
assert.equal((await call('terrain_prepare',{terrain_id:'0'.repeat(64),tile_index:0},true)).data.code,'not_found');
air(await inspect());console.log('LIVE TERRAIN MCP PASSED; test area restored to air');
}finally{await client.close();}
+72 -1
View File
@@ -19,7 +19,9 @@ test('real stdio MCP lists tools, validates recipes, calls backend and returns c
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(client.getInstructions().includes('Avoid large rectangular plateaus'));
assert.ok(client.getInstructions().includes('NOT as terrain_preview parameters'));
const list=await client.listTools();assert.equal(list.tools.length,19);
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');
@@ -35,6 +37,75 @@ test('real stdio MCP lists tools, validates recipes, calls backend and returns c
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');
const recipe={version:1,min:{x:0,y:0,z:0},max:{x:31,y:31,z:31},base_height:8,seed:1,mode:'sculpt',noise:{amplitude:3,scale:16},palette:{rock:'minecraft:stone',soil:'minecraft:dirt',surface:'minecraft:grass_block',soil_depth:2},features:[{type:'plateau',min:{x:2,z:2},max:{x:8,z:8},height:14,falloff:4}],preserve:[]};
await client.callTool({name:'terrain_preview',arguments:{recipe}});assert.equal(seen.at(-1).method,'terrain_preview');assert.equal(seen.at(-1).params.resolution,128);
await client.callTool({name:'terrain_prepare',arguments:{terrain_id:'a'.repeat(64),tile_index:1}});assert.equal(seen.at(-1).method,'terrain_prepare');
const beforeTerrain=seen.length;
for(const args of [{recipe:{...recipe,script:'execute'}},{recipe:{...recipe,features:[{type:'channel',points:[],width:2,falloff:3,height:0}]}},{recipe:{...recipe,palette:{...recipe.palette,rock:'minecraft:water'}}}]) assert.equal((await client.callTool({name:'terrain_preview',arguments:args})).isError,true);
assert.equal((await client.callTool({name:'terrain_prepare',arguments:{terrain_id:'../../file',tile_index:-1}})).isError,true);
assert.equal(seen.length,beforeTerrain);
const brush={min:{x:-4,y:0,z:-4},max:{x:4,y:12,z:4},center:{x:0,z:0},radius:3,action:'raise',amount:2};
await client.callTool({name:'terrain_brush_prepare',arguments:{brush}});
assert.equal(seen.at(-1).method,'terrain_brush_prepare');assert.equal(seen.at(-1).params.brush.falloff,0.5);
const beforeBrush=seen.length;
for(const bad of [{...brush,radius:50},{...brush,strength:2},{...brush,action:'execute'},{...brush,script:'run'}])assert.equal((await client.callTool({name:'terrain_brush_prepare',arguments:{brush:bad}})).isError,true);
assert.equal(seen.length,beforeBrush);
});
test('material discovery uses bounded read-only calls and never expands registry in schemas or instructions', 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);
const result=rpc.method==='material_search'
?{catalog_version:'v1',query:rpc.params.query??'',kind:rpc.params.kind,total:2,results:[{id:'minecraft:copper_door',block:true,item:true}],next_cursor:'next-page'}
:rpc.method==='material_describe'
?{catalog_version:'v1',id:'minecraft:copper_door',block:true,item:true,placeable:true,default_state:'minecraft:copper_door[facing=north,half=lower,hinge=left,open=false,powered=false]',properties:{facing:['north','south','east','west'],half:['lower','upper'],hinge:['left','right'],open:['false','true'],powered:['false','true']}}
:{status:'prepared'};
res.setHeader('content-type','application/json');res.end(JSON.stringify({ok:true,result}));
});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:'materials-test',version:'1'});
t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();});
await client.connect(transport);
const list=await client.listTools();
const discovery=list.tools.filter(tool=>tool.name.startsWith('material_'));
assert.deepEqual(discovery.map(tool=>tool.name),['material_search','material_describe']);
for(const tool of discovery){assert.equal(tool.annotations.readOnlyHint,true);assert.equal(tool.annotations.idempotentHint,true);assert.equal(tool.annotations.destructiveHint,false);}
assert.ok(client.getInstructions().includes('project_context contains only a catalog summary'));
assert.ok(client.getInstructions().includes('do not enumerate the registry'));
assert.ok(!JSON.stringify(discovery).includes('minecraft:copper_door'),'registry entries must be discovered, not embedded in the MCP tool catalog');
const found=await client.callTool({name:'material_search',arguments:{query:'copper door'}});
assert.equal(seen.at(-1).method,'material_search');assert.equal(seen.at(-1).params.limit,16);assert.equal(seen.at(-1).params.kind,'block');
assert.equal(JSON.parse(found.content[0].text).results[0].id,'minecraft:copper_door');
await client.callTool({name:'material_search',arguments:{query:'copper door',kind:'all',limit:32,cursor:'next-page'}});
assert.equal(seen.at(-1).params.cursor,'next-page');assert.equal(seen.at(-1).params.kind,'all');
const described=JSON.parse((await client.callTool({name:'material_describe',arguments:{id:'minecraft:copper_door'}})).content[0].text);
assert.equal(seen.at(-1).method,'material_describe');assert.deepEqual(described.properties.half,['lower','upper']);
const beforeInvalid=seen.length;
for(const arguments_ of [{query:'x'.repeat(97)},{kind:'entity'},{limit:0},{limit:33},{limit:1.5},{cursor:''},{cursor:'x'.repeat(101)}]){
assert.equal((await client.callTool({name:'material_search',arguments:arguments_})).isError,true);
}
for(const id of ['copper_door','minecraft:stone[foo=bar]','minecraft:chest{Items:[]}','other:stone','minecraft:../stone',`minecraft:${'x'.repeat(119)}`]){
assert.equal((await client.callTool({name:'material_describe',arguments:{id}})).isError,true);
}
assert.equal(seen.length,beforeInvalid,'invalid discovery arguments must be rejected before backend transport');
const box=block=>({version:1,operations:[{type:'box',min:{x:0,y:64,z:0},max:{x:0,y:64,z:0},block}]});
for(const block of ['minecraft:copper_door[facing=west,half=lower,hinge=left,open=false,powered=false]','minecraft:water[level=0]']){
assert.ok(!(await client.callTool({name:'build_prepare',arguments:{recipe:box(block)}})).isError,'ordinary recipes must not retain the old material allowlist');
}
const snapshot={pos:{x:0,y:64,z:0},state:'minecraft:chest[facing=north,type=single,waterlogged=false]',snapshot_id:'a'.repeat(64)};
assert.ok(!(await client.callTool({name:'build_prepare',arguments:{recipe:box('minecraft:chest[facing=west]'),expected_blocks:[snapshot]}})).isError);
assert.equal(seen.at(-1).params.expected_blocks[0].snapshot_id,snapshot.snapshot_id,'block-entity snapshot digest must survive MCP transport unchanged');
const beforeBadSnapshot=seen.length;
for(const snapshot_id of ['a'.repeat(63),'A'.repeat(64),'not-a-snapshot']){
assert.equal((await client.callTool({name:'build_prepare',arguments:{recipe:box('minecraft:stone'),expected_blocks:[{...snapshot,snapshot_id}]}})).isError,true);
}
assert.equal(seen.length,beforeBadSnapshot);
const beforeUnsafe=seen.length;
for(const block of ['minecraft:command_block{Command:"say hello"}',`minecraft:stone[p=${'x'.repeat(1024)}]`]){
assert.equal((await client.callTool({name:'build_prepare',arguments:{recipe:box(block)}})).isError,true);
}
assert.equal(seen.length,beforeUnsafe,'state length and no-NBT boundaries must hold before backend transport');
});
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'});
+56 -2
View File
@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises';
import {mkdtemp,readFile,writeFile,readdir,rm,stat} 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';
import {codexPaths,prepareCodexHome,CODEX_CONFIG,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js';
const oldManagedConfig=CODEX_CONFIG_TOML.replace('code_mode_host = true\n','code_mode_host = false\n');
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);
@@ -13,9 +14,62 @@ test('dedicated home gets known config and never overwrites existing user config
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('migrates only the known old managed config atomically, backs it up and preserves authentication',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);
const configPath=join(paths.codexHome,'config.toml');
const authPath=join(paths.codexHome,'auth.json');
const syntheticAuth='{"test_fixture":"preserve-me-byte-for-byte"}\n';
await writeFile(configPath,oldManagedConfig);await writeFile(authPath,syntheticAuth,{mode:0o600});
await prepareCodexHome(paths);
assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML);
assert.equal(await readFile(`${configPath}.before-code-mode-host`,'utf8'),oldManagedConfig);
assert.equal(await readFile(authPath,'utf8'),syntheticAuth);
if(process.platform!=='win32'){
assert.equal((await stat(configPath)).mode&0o777,0o600);
assert.equal((await stat(`${configPath}.before-code-mode-host`)).mode&0o777,0o600);
}
await prepareCodexHome(paths);
assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML);
assert.equal(await readFile(`${configPath}.before-code-mode-host`,'utf8'),oldManagedConfig);
assert.equal(await readFile(authPath,'utf8'),syntheticAuth);
assert.equal((await readdir(paths.codexHome)).filter(name=>name.endsWith('.tmp')).length,0);
});
test('refuses even small custom changes to old managed configuration without creating a backup',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);
const configPath=join(paths.codexHome,'config.toml');
for(const custom of [oldManagedConfig+'# my settings\n',oldManagedConfig.replace('shell_tool = false','shell_tool = true')]){
await writeFile(configPath,custom);
await assert.rejects(prepareCodexHome(paths),/will not be overwritten/);
assert.equal(await readFile(configPath,'utf8'),custom);
await assert.rejects(readFile(`${configPath}.before-code-mode-host`),{code:'ENOENT'});
}
});
test('resumes with an exact previous backup but refuses to overwrite a different backup',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);
const configPath=join(paths.codexHome,'config.toml');const backupPath=`${configPath}.before-code-mode-host`;
await writeFile(configPath,oldManagedConfig);await writeFile(backupPath,'different backup\n');
await assert.rejects(prepareCodexHome(paths),/backup.*will not be overwritten/);
assert.equal(await readFile(configPath,'utf8'),oldManagedConfig);
assert.equal(await readFile(backupPath,'utf8'),'different backup\n');
await writeFile(backupPath,oldManagedConfig);await prepareCodexHome(paths);
assert.equal(await readFile(configPath,'utf8'),CODEX_CONFIG_TOML);
assert.equal(await readFile(backupPath,'utf8'),oldManagedConfig);
});
test('code-mode host is enabled without enabling the separately restricted integrations',()=>{
assert.equal(CODEX_CONFIG.features.code_mode_host,true);
for(const [feature,enabled] of Object.entries(CODEX_CONFIG.features)){
if(feature!=='code_mode_host')assert.equal(enabled,false,feature);
}
assert.equal(CODEX_CONFIG.sandbox_mode,'read-only');assert.equal(CODEX_CONFIG.web_search,'disabled');
});
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.equal(report.codeModeHostRequested,true);assert.equal(report.codeModeHostRequiredForModelToolMode,'code_mode_only');
assert.ok(report.limitations.some(text=>text.includes('Doctor does not invoke a model')));
assert.ok(report.limitations.some(text=>text.includes('temporary paths')));
});