Files

87 lines
7.2 KiB
JavaScript

// 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(); }