Initial minecraft-builder-mcp prototype and Gothic hall build
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
Vendored
+24
@@ -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'}});}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(); }
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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')));
|
||||
});
|
||||
Reference in New Issue
Block a user