Release v0.4.0

This commit is contained in:
winlifes
2026-06-10 20:26:09 -07:00
parent 03e5ab8dfe
commit 6405dced7d
27 changed files with 1983 additions and 52 deletions
+16
View File
@@ -0,0 +1,16 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const { collectUuidReferences } = require('../lib/tools/assets-advanced');
test('collectUuidReferences finds structured and literal UUID references', () => {
const refs = collectUuidReferences(JSON.stringify({
__type__: 'cc.Prefab',
sprite: { __uuid__: '2d3KcYpS5HCKb6wU0v5c9x' },
nested: [{ assetUuid: '550e8400-e29b-41d4-a716-446655440000' }],
}));
assert.equal(refs.some((ref) => ref.uuid === '2d3KcYpS5HCKb6wU0v5c9x' && ref.source === 'structured'), true);
assert.equal(refs.some((ref) => ref.uuid === '550e8400-e29b-41d4-a716-446655440000'), true);
});
+89
View File
@@ -0,0 +1,89 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
broadcastEditorMessage,
getEditorPreference,
setEditorPreference,
tryEditorRequests,
tryEditorRequestsStatus,
} = require('../lib/tools/cocos-project');
test('tryEditorRequests returns the first successful editor message candidate', async () => {
const calls = [];
global.Editor = {
Message: {
request: async (channel, method, payload) => {
calls.push({ channel, method, payload });
if (method === 'bad') {
throw new Error('nope');
}
return { ok: true };
},
},
};
try {
const result = await tryEditorRequests([
{ channel: 'scene', method: 'bad' },
{ channel: 'scene', method: 'save-scene', args: [{ force: true }] },
]);
assert.equal(result.ok, true);
assert.equal(result.method, 'save-scene');
assert.equal(calls.length, 2);
} finally {
delete global.Editor;
}
});
test('tryEditorRequestsStatus returns an unavailable payload instead of throwing', async () => {
global.Editor = {
Message: {
request: async () => {
throw new Error('missing');
},
},
};
try {
const result = await tryEditorRequestsStatus([{ channel: 'builder', method: 'query-build-status' }]);
assert.equal(result.ok, false);
assert.equal(result.available, false);
assert.equal(result.attempts.length, 1);
} finally {
delete global.Editor;
}
});
test('preference helpers and broadcast use available Editor APIs', () => {
const sent = [];
const store = new Map();
global.Editor = {
Message: {
send(channel, message, payload) {
sent.push({ channel, message, payload });
},
},
Profile: {
getProject(key) {
return store.get(key);
},
setProject(key, value) {
store.set(key, value);
},
},
};
try {
setEditorPreference('project', 'preview.port', 7456);
assert.equal(getEditorPreference('project', 'preview.port'), 7456);
const result = broadcastEditorMessage({ channel: 'scene', message: 'custom-event', payload: { ok: true } });
assert.equal(result.sent, true);
assert.deepEqual(sent[0], { channel: 'scene', message: 'custom-event', payload: { ok: true } });
} finally {
delete global.Editor;
}
});
+58
View File
@@ -0,0 +1,58 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const {
assertJavascriptSafety,
inspectJavascriptSafety,
} = require('../lib/javascript-safety');
const PROJECT_PATH = path.resolve('/tmp/funplay-cocos-test-project');
test('JavaScript safety allows project-local write snippets by default', () => {
const result = inspectJavascriptSafety(
"fs.writeFileSync(path.join(context.projectPath, 'assets/generated.ts'), 'export {};');",
{ projectPath: PROJECT_PATH }
);
assert.equal(result.ok, true);
});
test('JavaScript safety blocks delete operations', () => {
assert.throws(
() => assertJavascriptSafety("fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });", {
projectPath: PROJECT_PATH,
}),
/delete\/truncate/
);
});
test('JavaScript safety blocks traversal and home path literals', () => {
const result = inspectJavascriptSafety(
"fs.writeFileSync('../outside.txt', 'x'); fs.writeFileSync('~/secret.txt', 'x');",
{ projectPath: PROJECT_PATH }
);
assert.equal(result.ok, false);
assert.equal(result.violations.some((item) => item.includes('path traversal')), true);
assert.equal(result.violations.some((item) => item.includes('user-home')), true);
});
test('JavaScript safety blocks absolute paths outside the project', () => {
const result = inspectJavascriptSafety("fs.writeFileSync('/tmp/outside.txt', 'x');", {
projectPath: PROJECT_PATH,
});
assert.equal(result.ok, false);
assert.equal(result.violations.some((item) => item.includes('absolute path outside')), true);
});
test('JavaScript safety blocks child_process usage', () => {
assert.throws(
() => assertJavascriptSafety("const cp = require('child_process'); cp.execSync('rm -rf /tmp/x');", {
projectPath: PROJECT_PATH,
}),
/child_process/
);
});
+11
View File
@@ -6,6 +6,7 @@ const os = require('node:os');
const path = require('node:path');
const test = require('node:test');
const {
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
@@ -39,6 +40,16 @@ test('createProjectSkill writes a Codex project skill', () => {
assert.equal(listed.skills.some((skill) => skill.path === result.path), true);
});
test('createCocosMcpProjectSkill writes the recommended MCP workflow skill', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-default-skill-'));
const result = createCocosMcpProjectSkill(projectPath);
assert.equal(result.path, '.codex/skills/funplay-cocos-mcp-workflow/SKILL.md');
const content = readProjectInstruction(projectPath, result.path).content;
assert.match(content, /Funplay Cocos MCP Workflow/);
assert.match(content, /inspect_asset_dependencies/);
});
test('project instruction helpers reject traversal outside the project', () => {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'funplay-cocos-instructions-safe-'));
assert.throws(
+67 -3
View File
@@ -9,7 +9,7 @@ const {
SUPPORTED_PROTOCOL_VERSIONS,
} = require('../lib/server');
function createServer(toolRegistry = {}, config = {}) {
function createServer(toolRegistry = {}, config = {}, options = {}) {
return new McpServer({
config: { host: '127.0.0.1', port: 8765, ...config },
toolRegistry: {
@@ -28,8 +28,10 @@ function createServer(toolRegistry = {}, config = {}) {
},
interactionLog: { add() {} },
runtimeLog: { add() {} },
serverName: 'test-server',
serverVersion: '0.0.0-test',
serverName: options.serverName || 'test-server',
serverVersion: options.serverVersion || '0.0.0-test',
projectName: options.projectName || 'test-project',
projectIdentity: options.projectIdentity || 'test-project-id',
});
}
@@ -104,6 +106,7 @@ test('initialize negotiates the current MCP protocol version by default', async
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
assert.equal(response.result.serverInfo.name, 'test-server');
assert.equal(response.result.funplay.projectIdentity, 'test-project-id');
});
test('initialize can negotiate an older supported MCP protocol version', async () => {
@@ -286,6 +289,67 @@ test('HTTP GET /tools returns debug tool metadata and curl examples', async () =
}
});
test('HTTP GET /health returns project identity metadata', async () => {
const server = createServer({}, { port: 0 }, { projectIdentity: 'health-project-id' });
await server.start();
try {
const response = await httpGet(server.getPort(), '/health');
const payload = JSON.parse(response.body);
assert.equal(response.statusCode, 200);
assert.equal(payload.ok, true);
assert.equal(payload.projectName, 'test-project');
assert.equal(payload.projectIdentity, 'health-project-id');
} finally {
await server.stop();
}
});
test('start attaches to an existing same-project listener on the configured port', async () => {
const owner = createServer({}, { port: 0 }, { projectIdentity: 'same-project' });
await owner.start();
const attached = createServer({}, { port: owner.getPort() }, { projectIdentity: 'same-project' });
try {
await attached.start();
assert.equal(attached.isRunning(), true);
assert.equal(attached.getPort(), owner.getPort());
assert.equal(attached.getAttachInfo().projectIdentity, 'same-project');
await attached.stop();
assert.equal(attached.isRunning(), false);
const response = await httpGet(owner.getPort(), '/health');
assert.equal(response.statusCode, 200);
} finally {
if (attached.isRunning()) {
await attached.stop();
}
await owner.stop();
}
});
test('start falls back instead of attaching to a different project listener', async () => {
const owner = createServer({}, { port: 0 }, { projectIdentity: 'owner-project' });
await owner.start();
const contender = createServer({}, { port: owner.getPort() }, { projectIdentity: 'other-project' });
try {
await contender.start();
assert.equal(contender.isRunning(), true);
assert.notEqual(contender.getPort(), owner.getPort());
assert.equal(contender.getAttachInfo(), null);
assert.equal(contender.getPortFallbackInfo().requestedPort, owner.getPort());
} finally {
if (contender.isRunning()) {
await contender.stop();
}
await owner.stop();
}
});
test('HTTP initialize can return an optional session id when sessions are enabled', async () => {
const server = createServer({}, { port: 0, enableSessions: true });
await server.start();
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
applyToolProfile,
createToolProfileSnapshot,
deleteToolProfile,
exportToolProfiles,
importToolProfiles,
normalizeSavedToolProfiles,
upsertToolProfile,
} = require('../lib/tool-profiles');
test('tool profiles normalize, upsert, and dedupe by name', () => {
const profiles = normalizeSavedToolProfiles([
{ name: 'QA', toolProfile: 'custom', enabledToolCategories: 'assets\nlogs' },
{ name: 'qa', toolProfile: 'full', disabledTools: ['delete_asset'] },
{ name: '' },
]);
assert.equal(profiles.length, 1);
assert.equal(profiles[0].name, 'qa');
assert.equal(profiles[0].toolProfile, 'full');
const updated = upsertToolProfile(profiles, {
name: 'Prototype',
toolProfile: 'custom',
enabledToolCategories: ['ui'],
});
assert.equal(updated.length, 2);
assert.equal(updated.some((profile) => profile.name === 'Prototype'), true);
});
test('tool profiles snapshot and apply exposure config', () => {
const snapshot = createToolProfileSnapshot({
toolProfile: 'custom',
enabledToolCategories: ['assets'],
disabledToolCategories: ['input'],
enabledTools: ['write_file'],
disabledTools: ['delete_asset'],
}, 'Asset QA');
const applied = applyToolProfile({ port: 8765 }, snapshot);
assert.equal(applied.port, 8765);
assert.equal(applied.activeToolProfileName, 'Asset QA');
assert.deepEqual(applied.enabledToolCategories, ['assets']);
assert.deepEqual(applied.disabledTools, ['delete_asset']);
});
test('tool profiles import, export, and delete', () => {
const imported = importToolProfiles([], JSON.stringify({
version: 1,
profiles: [
{ name: 'Core QA', toolProfile: 'core' },
{ name: 'Debug', toolProfile: 'custom', enabledToolCategories: ['logs', 'diagnostics'] },
],
}));
assert.equal(imported.length, 2);
assert.deepEqual(exportToolProfiles(imported).profiles.map((profile) => profile.name), ['Core QA', 'Debug']);
const remaining = deleteToolProfile(imported, 'Debug');
assert.deepEqual(remaining.map((profile) => profile.name), ['Core QA']);
});
+39 -2
View File
@@ -21,11 +21,13 @@ function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-
test('core profile exposes the documented focused tool set', () => {
const tools = createRegistry('core').listTools();
assert.equal(tools.length, 34);
assert.equal(tools.length, 37);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'get_tool_catalog'), true);
assert.equal(tools.some((tool) => tool.name === 'validate_scene'), true);
assert.equal(tools.some((tool) => tool.name === 'inspect_asset_dependencies'), true);
assert.equal(tools.some((tool) => tool.name === 'get_build_status'), true);
assert.equal(tools.some((tool) => tool.name === 'get_performance_snapshot'), true);
assert.equal(tools.some((tool) => tool.name === 'list_project_instructions'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
@@ -34,10 +36,14 @@ test('core profile exposes the documented focused tool set', () => {
test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools();
assert.equal(tools.length, 89);
assert.equal(tools.length, 101);
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
assert.equal(tools.some((tool) => tool.name === 'edit_prefab_json'), true);
assert.equal(tools.some((tool) => tool.name === 'create_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'create_cocos_mcp_project_skill'), true);
assert.equal(tools.some((tool) => tool.name === 'bind_button_click_event'), true);
assert.equal(tools.some((tool) => tool.name === 'open_build_panel'), true);
assert.equal(tools.some((tool) => tool.name === 'broadcast_editor_message'), true);
assert.equal(tools.some((tool) => tool.name === 'get_editor_state'), true);
assert.equal(tools.some((tool) => tool.name === 'set_selection'), true);
});
@@ -98,3 +104,34 @@ test('callToolDetailed preserves screenshot image text while keeping structured
assert.equal(result.value.data.image, true);
assert.equal(result.value.data.mimeType, 'image/png');
});
test('execute_javascript safety checks block risky editor snippets by default', async () => {
const registry = createRegistry('core');
await assert.rejects(
() => registry.callToolDetailed('execute_javascript', {
context: 'editor',
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
}),
/JavaScript safety checks blocked/
);
});
test('execute_javascript safety checks can be explicitly disabled per call', async () => {
let called = false;
const registry = createRegistry('core', path.resolve('/tmp/funplay-cocos-test-project'), {}, {
editorExecutor: async () => {
called = true;
return { ok: true };
},
});
const result = await registry.callToolDetailed('execute_javascript', {
context: 'editor',
code: "fs.rmSync(path.join(context.projectPath, 'assets'), { recursive: true });",
safety_checks: false,
});
assert.equal(called, true);
assert.equal(result.value.ok, true);
});