Skip to main content
Glama
run-unreal-tool-tests.mjs23 kB
#!/usr/bin/env node import fs from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; import { performance } from 'node:perf_hooks'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; const failureKeywords = [ 'error', 'fail', 'invalid', 'missing', 'not found', 'reject', 'warning' ]; const successKeywords = [ 'success', 'spawn', 'visible', 'applied', 'returns', 'plays', 'updates', 'created', 'saved' ]; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, '..'); const defaultDocPath = path.resolve(repoRoot, 'docs', 'unreal-tool-test-cases.md'); const docPath = path.resolve(repoRoot, process.env.UNREAL_MCP_TEST_DOC ?? defaultDocPath); const reportsDir = path.resolve(repoRoot, 'tests', 'reports'); const resultsPath = path.join(reportsDir, `unreal-tool-test-results-${new Date().toISOString().replace(/[:]/g, '-')}.json`); const defaultFbxDir = normalizeWindowsPath(process.env.UNREAL_MCP_FBX_DIR ?? 'C:\\Users\\micro\\Downloads\\Compressed\\fbx'); const defaultFbxFile = normalizeWindowsPath(process.env.UNREAL_MCP_FBX_FILE ?? path.join(defaultFbxDir, 'test_model.fbx')); const cliOptions = parseCliOptions(process.argv.slice(2)); const serverCommand = process.env.UNREAL_MCP_SERVER_CMD ?? 'node'; const serverArgs = parseArgsList(process.env.UNREAL_MCP_SERVER_ARGS) ?? [path.join(repoRoot, 'dist', 'cli.js')]; const serverCwd = process.env.UNREAL_MCP_SERVER_CWD ?? repoRoot; async function main() { await ensureFbxDirectory(); const allCases = await loadTestCasesFromDoc(docPath); if (allCases.length === 0) { console.error(`No test cases detected in ${docPath}.`); process.exitCode = 1; return; } const filteredCases = allCases.filter((testCase) => { if (cliOptions.group && testCase.groupName !== cliOptions.group) return false; if (cliOptions.caseId && testCase.caseId !== cliOptions.caseId) return false; if (cliOptions.text && !testCase.scenario.toLowerCase().includes(cliOptions.text.toLowerCase())) { return false; } return true; }); if (filteredCases.length === 0) { console.warn('No test cases matched the provided filters. Exiting.'); return; } let transport; let client; const runResults = []; if (!cliOptions.dryRun) { try { transport = new StdioClientTransport({ command: serverCommand, args: serverArgs, cwd: serverCwd, stderr: 'inherit' }); client = new Client({ name: 'unreal-mcp-tool-test-runner', version: '0.1.0' }); await client.connect(transport); await client.listTools({}); } catch (err) { console.error('Failed to start or initialize MCP server:', err); if (transport) { try { await transport.close(); } catch { /* ignore */ } } process.exitCode = 1; return; } } for (const testCase of filteredCases) { if (testCase.skipReason) { runResults.push({ ...testCase, status: 'skipped', detail: testCase.skipReason }); console.log(formatResultLine(testCase, 'skipped', testCase.skipReason)); continue; } if (cliOptions.dryRun) { runResults.push({ ...testCase, status: 'skipped', detail: 'Dry run' }); console.log(formatResultLine(testCase, 'skipped', 'Dry run')); continue; } const started = performance.now(); try { const response = await client.callTool({ name: testCase.toolName, arguments: testCase.arguments }); const duration = performance.now() - started; const evaluation = evaluateExpectation(testCase, response); runResults.push({ ...testCase, status: evaluation.passed ? 'passed' : 'failed', durationMs: duration, detail: evaluation.reason, response }); console.log(formatResultLine(testCase, evaluation.passed ? 'passed' : 'failed', evaluation.reason, duration)); } catch (err) { const duration = performance.now() - started; runResults.push({ ...testCase, status: 'failed', durationMs: duration, detail: err instanceof Error ? err.message : String(err) }); console.log(formatResultLine(testCase, 'failed', err instanceof Error ? err.message : String(err), duration)); } } if (!cliOptions.dryRun) { try { await client.close(); } catch { // ignore } try { await transport.close(); } catch { // ignore } } await persistResults(runResults); summarize(runResults); if (runResults.some((result) => result.status === 'failed')) { process.exitCode = 1; } } function parseCliOptions(args) { const options = { dryRun: false, group: undefined, caseId: undefined, text: undefined }; for (const arg of args) { if (arg === '--dry-run') { options.dryRun = true; } else if (arg.startsWith('--group=')) { options.group = arg.slice('--group='.length); } else if (arg.startsWith('--case=')) { options.caseId = arg.slice('--case='.length); } else if (arg.startsWith('--text=')) { options.text = arg.slice('--text='.length); } } return options; } function parseArgsList(value) { if (!value) return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; if (trimmed.startsWith('[')) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) return parsed.map(String); } catch (_) { // fall through } } return trimmed.split(/\s+/).filter(Boolean); } async function loadTestCasesFromDoc(filePath) { const raw = await fs.readFile(filePath, 'utf8'); const lines = raw.split(/\r?\n/); const cases = []; let currentGroup = undefined; let inLegacySection = false; for (const line of lines) { if (line.startsWith('## ')) { const headerTitle = line.replace(/^##\s+/, '').trim(); if (headerTitle.toLowerCase().includes('legacy comprehensive matrix')) { inLegacySection = true; currentGroup = undefined; continue; } if (inLegacySection) { currentGroup = undefined; continue; } currentGroup = headerTitle; continue; } if (!currentGroup) continue; if (!line.startsWith('|') || /^\|\s*-+/.test(line) || /^\|\s*#\s*\|/.test(line)) { continue; } const columns = line.split('|').map((part) => part.trim()); if (columns.length < 5) continue; const index = columns[1]; const scenario = columns[2]; const example = columns[3]; const expected = columns[4]; const payload = extractPayload(example); const simplifiedGroup = simplifyGroupName(currentGroup); const enriched = enrichTestCase({ group: currentGroup, groupName: simplifiedGroup, index, scenario, example, expected, payload }); cases.push(enriched); } return cases; } function simplifyGroupName(groupTitle) { return groupTitle .replace(/`[^`]+`/g, '') .replace(/\([^)]*\)/g, '') .replace('Tools', 'Tools') .trim(); } function extractPayload(exampleColumn) { if (!exampleColumn) return undefined; const codeMatches = [...exampleColumn.matchAll(/`([^`]+)`/g)]; for (const match of codeMatches) { const snippet = match[1].trim(); if (!snippet) continue; const jsonCandidate = normalizeJsonCandidate(snippet); if (!jsonCandidate) continue; try { return { raw: snippet, value: JSON.parse(jsonCandidate) }; } catch (_) { continue; } } return undefined; } function normalizeJsonCandidate(snippet) { const trimmed = snippet.trim(); if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { return trimmed; } return undefined; } function enrichTestCase(rawCase) { const caseIdBase = `${rawCase.groupName.toLowerCase().replace(/\s+/g, '-')}-${rawCase.index}`; const base = { group: rawCase.group, groupName: rawCase.groupName, index: rawCase.index, scenario: rawCase.scenario, expected: rawCase.expected, example: rawCase.example, caseId: caseIdBase, payloadSnippet: rawCase.payload?.raw, arguments: undefined, toolName: undefined, skipReason: undefined }; const payloadValue = rawCase.payload?.value ? hydratePlaceholders(rawCase.payload.value) : undefined; const scenarioLower = rawCase.scenario.toLowerCase(); switch (rawCase.groupName) { case 'Lighting Tools': { if (!payloadValue) { return { ...base, skipReason: 'No JSON payload provided' }; } if (/lightmass|ensure/i.test(rawCase.scenario)) { return { ...base, skipReason: 'Scenario requires manual steps not exposed via consolidated tool' }; } let lightType; if (scenarioLower.includes('directional')) lightType = 'Directional'; else if (scenarioLower.includes('point')) lightType = 'Point'; else if (scenarioLower.includes('spot')) lightType = 'Spot'; else if (scenarioLower.includes('rect')) lightType = 'Rect'; else if (scenarioLower.includes('sky')) lightType = 'Sky'; else if (scenarioLower.includes('build lighting')) { return { ...base, skipReason: 'Skipping build lighting scenarios to avoid long editor runs' }; } else { return { ...base, skipReason: 'Unrecognized light type or scenario' }; } const args = { action: 'create_light', lightType, name: payloadValue.name ?? `${lightType}Light_${rawCase.index}` }; if (typeof payloadValue.intensity === 'number') { args.intensity = payloadValue.intensity; } if (Array.isArray(payloadValue.location) && payloadValue.location.length === 3) { args.location = { x: payloadValue.location[0], y: payloadValue.location[1], z: payloadValue.location[2] }; } if (Array.isArray(payloadValue.rotation) && payloadValue.rotation.length === 3) { args.rotation = { pitch: payloadValue.rotation[0], yaw: payloadValue.rotation[1], roll: payloadValue.rotation[2] }; } if (Array.isArray(payloadValue.color) && payloadValue.color.length === 3) { args.color = payloadValue.color; } if (payloadValue.radius !== undefined) { args.radius = payloadValue.radius; } if (payloadValue.innerCone !== undefined) { args.innerCone = payloadValue.innerCone; } if (payloadValue.outerCone !== undefined) { args.outerCone = payloadValue.outerCone; } if (payloadValue.width !== undefined) { args.width = payloadValue.width; } if (payloadValue.height !== undefined) { args.height = payloadValue.height; } if (payloadValue.falloffExponent !== undefined) { args.falloffExponent = payloadValue.falloffExponent; } if (typeof payloadValue.castShadows === 'boolean') { args.castShadows = payloadValue.castShadows; } if (payloadValue.temperature !== undefined) { args.temperature = payloadValue.temperature; } if (typeof payloadValue.sourceType === 'string') { args.sourceType = payloadValue.sourceType; } if (typeof payloadValue.cubemapPath === 'string') { args.cubemapPath = payloadValue.cubemapPath; } if (typeof payloadValue.recapture === 'boolean') { args.recapture = payloadValue.recapture; } return { ...base, toolName: 'manage_level', arguments: args }; } case 'Actor Tools': { if (!payloadValue) { return { ...base, skipReason: 'No JSON payload provided' }; } const args = { ...payloadValue }; if (Array.isArray(args.location) && args.location.length === 3) { args.location = { x: args.location[0], y: args.location[1], z: args.location[2] }; } if (Array.isArray(args.rotation) && args.rotation.length === 3) { args.rotation = { pitch: args.rotation[0], yaw: args.rotation[1], roll: args.rotation[2] }; } return { ...base, toolName: 'control_actor', arguments: args }; } case 'Asset Tools': { if (!payloadValue) { return { ...base, skipReason: 'Non-JSON payload requires manual execution' }; } if (!payloadValue.action) { return { ...base, skipReason: 'Action missing; unable to route to consolidated tool' }; } if (!['list', 'import', 'create_material'].includes(payloadValue.action)) { return { ...base, skipReason: `Action '${payloadValue.action}' not supported by automated runner` }; } return { ...base, toolName: 'manage_asset', arguments: payloadValue }; } case 'Animation Tools': { if (!payloadValue) { return { ...base, skipReason: 'No JSON payload provided' }; } const args = { ...payloadValue }; if (scenarioLower.includes('animation blueprint')) { args.action = 'create_animation_bp'; } else if (scenarioLower.includes('montage') || scenarioLower.includes('animation asset once')) { args.action = 'play_montage'; } else if (scenarioLower.includes('ragdoll')) { args.action = 'setup_ragdoll'; } if (!args.action) { return { ...base, skipReason: 'Scenario not supported by automated runner' }; } return { ...base, toolName: 'animation_physics', arguments: args }; } case 'Blueprint Tools': { if (!payloadValue) { return { ...base, skipReason: 'No JSON payload provided' }; } const args = { ...payloadValue }; if (!args.action) { args.action = scenarioLower.includes('component') ? 'add_component' : 'create'; } return { ...base, toolName: 'manage_blueprint', arguments: args }; } case 'Material Tools': { if (!payloadValue) { return { ...base, skipReason: 'No JSON payload provided' }; } const args = { ...payloadValue }; if (!args.action && typeof args.name === 'string' && typeof args.path === 'string') { args.action = 'create_material'; } if (args.action === 'create_material') { return { ...base, toolName: 'manage_asset', arguments: { action: 'create_material', name: typeof args.name === 'string' ? args.name : `M_Test_${rawCase.index}`, path: args.path } }; } return { ...base, skipReason: 'Material scenario not supported by automated runner' }; } case 'Niagara Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) { return { ...base, skipReason: 'Missing action for Niagara scenario' }; } if (Array.isArray(payloadValue.location) && payloadValue.location.length === 3) { payloadValue.location = { x: payloadValue.location[0], y: payloadValue.location[1], z: payloadValue.location[2] }; } return { ...base, toolName: 'create_effect', arguments: payloadValue }; } case 'Level Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) { return { ...base, skipReason: 'Missing action for level scenario' }; } return { ...base, toolName: 'manage_level', arguments: payloadValue }; } case 'Sequence Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; return { ...base, toolName: 'manage_sequence', arguments: payloadValue }; } case 'UI Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; return { ...base, toolName: 'system_control', arguments: payloadValue }; } case 'Physics Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (payloadValue.action === 'apply_force') { return { ...base, toolName: 'control_actor', arguments: payloadValue }; } return { ...base, skipReason: 'Physics scenario not mapped to consolidated tools' }; } case 'Landscape Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; return { ...base, toolName: 'build_environment', arguments: payloadValue }; } case 'Build Environment Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; return { ...base, toolName: 'build_environment', arguments: payloadValue }; } case 'Performance Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; if (scenarioLower.includes('engine quit')) { return { ...base, skipReason: 'Skipping engine quit to keep Unreal session alive during test run' }; } return { ...base, toolName: 'system_control', arguments: payloadValue }; } case 'System Control Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; if (payloadValue.action === 'engine_quit' || payloadValue.action === 'engine_start') { return { ...base, skipReason: 'Skipping engine process management during automated run' }; } return { ...base, toolName: 'system_control', arguments: payloadValue }; } case 'Debug Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) { payloadValue.action = 'debug_shape'; } return { ...base, toolName: 'create_effect', arguments: payloadValue }; } case 'Remote Control Preset Tools': { if (!payloadValue) return { ...base, skipReason: 'No JSON payload provided' }; if (!payloadValue.action) return { ...base, skipReason: 'Missing action in payload' }; return { ...base, toolName: 'manage_rc', arguments: payloadValue }; } default: return { ...base, skipReason: `Unknown tool group '${rawCase.groupName}'` }; } } function evaluateExpectation(testCase, response) { const lowerExpected = testCase.expected.toLowerCase(); const containsFailure = failureKeywords.some((word) => lowerExpected.includes(word)); const containsSuccess = successKeywords.some((word) => lowerExpected.includes(word)); const structuredSuccess = typeof response.structuredContent?.success === 'boolean' ? response.structuredContent.success : undefined; const actualSuccess = structuredSuccess ?? !response.isError; const expectedFailure = containsFailure && !containsSuccess; const passed = expectedFailure ? !actualSuccess : !!actualSuccess; let reason; if (response.isError) { reason = response.content?.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n'); } else if (response.structuredContent) { reason = JSON.stringify(response.structuredContent); } else if (response.content?.length) { reason = response.content.map((entry) => ('text' in entry ? entry.text : JSON.stringify(entry))).join('\n'); } else { reason = 'No structured response returned'; } return { passed, reason }; } function formatResultLine(testCase, status, detail, durationMs) { const durationText = typeof durationMs === 'number' ? ` (${durationMs.toFixed(1)} ms)` : ''; return `[${status.toUpperCase()}] ${testCase.groupName} #${testCase.index} – ${testCase.scenario}${durationText}${detail ? ` => ${detail}` : ''}`; } async function persistResults(results) { await fs.mkdir(reportsDir, { recursive: true }); const serializable = results.map((result) => ({ group: result.groupName, caseId: result.caseId, index: result.index, scenario: result.scenario, toolName: result.toolName, arguments: result.arguments, status: result.status, durationMs: result.durationMs, detail: result.detail })); await fs.writeFile(resultsPath, JSON.stringify({ generatedAt: new Date().toISOString(), docPath, results: serializable }, null, 2)); } function summarize(results) { const totals = results.reduce((acc, result) => { acc.total += 1; acc[result.status] = (acc[result.status] ?? 0) + 1; return acc; }, { total: 0, passed: 0, failed: 0, skipped: 0 }); console.log('\nSummary'); console.log('======='); console.log(`Total cases processed: ${totals.total}`); console.log(`Passed: ${totals.passed}`); console.log(`Failed: ${totals.failed}`); console.log(`Skipped: ${totals.skipped}`); console.log(`Results written to: ${resultsPath}`); } function normalizeWindowsPath(value) { if (typeof value !== 'string') return value; return value.replace(/\\+/g, '\\').replace(/\/+/g, '\\'); } function hydratePlaceholders(value) { if (typeof value === 'string') { return value .replaceAll('{{FBX_DIR}}', defaultFbxDir) .replaceAll('{{FBX_TEST_MODEL}}', defaultFbxFile); } if (Array.isArray(value)) { return value.map((entry) => hydratePlaceholders(entry)); } if (value && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([key, val]) => [key, hydratePlaceholders(val)])); } return value; } async function ensureFbxDirectory() { if (!defaultFbxDir) return; try { await fs.mkdir(defaultFbxDir, { recursive: true }); } catch (err) { console.warn(`Unable to ensure FBX directory '${defaultFbxDir}':`, err); } } main().catch((err) => { console.error('Unexpected error during test execution:', err); process.exitCode = 1; });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ChiR24/Unreal_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server