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
+24
View File
@@ -1,7 +1,9 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { normalizeSavedToolProfiles } = require('./tool-profiles');
const DEFAULTS = {
host: '127.0.0.1',
@@ -12,9 +14,12 @@ const DEFAULTS = {
enabledToolCategories: [],
disabledToolCategories: [],
enableSessions: false,
executeJavascriptSafetyChecks: true,
autostart: true,
maxInteractionLogEntries: 50,
lastClientTargetId: 'claude_code',
activeToolProfileName: '',
savedToolProfiles: [],
};
function getProjectPath() {
@@ -28,6 +33,19 @@ function getProjectName() {
return path.basename(getProjectPath());
}
function normalizeProjectIdentityPath(projectPath) {
const normalized = path.resolve(String(projectPath || process.cwd())).replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function getProjectIdentity(projectPath = getProjectPath()) {
return crypto
.createHash('sha256')
.update(`funplay-cocos-mcp:${normalizeProjectIdentityPath(projectPath)}`)
.digest('hex')
.slice(0, 24);
}
function getCocosVersion() {
if (global.Editor && Editor.App) {
if (typeof Editor.App.version === 'string' && Editor.App.version) {
@@ -106,11 +124,16 @@ function loadConfig() {
enabledToolCategories: normalizeStringList(fileConfig.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(fileConfig.disabledToolCategories).map((item) => item.toLowerCase()),
enableSessions: typeof fileConfig.enableSessions === 'boolean' ? fileConfig.enableSessions : DEFAULTS.enableSessions,
executeJavascriptSafetyChecks: typeof fileConfig.executeJavascriptSafetyChecks === 'boolean'
? fileConfig.executeJavascriptSafetyChecks
: DEFAULTS.executeJavascriptSafetyChecks,
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
: DEFAULTS.maxInteractionLogEntries,
lastClientTargetId: normalizeClientTargetId(fileConfig.lastClientTargetId),
activeToolProfileName: typeof fileConfig.activeToolProfileName === 'string' ? fileConfig.activeToolProfileName : '',
savedToolProfiles: normalizeSavedToolProfiles(fileConfig.savedToolProfiles),
configPath,
configError: fileConfig.__error || '',
};
@@ -120,6 +143,7 @@ module.exports = {
DEFAULTS,
getProjectPath,
getProjectName,
getProjectIdentity,
getCocosVersion,
loadConfig,
normalizeProfile,
+114
View File
@@ -0,0 +1,114 @@
'use strict';
const path = require('path');
const { isPathInside } = require('./path-safety');
const DELETE_METHOD_PATTERN = /\bfs(?:\s*\.\s*promises)?\s*\.\s*(rm|rmdir|unlink|truncate|rmSync|rmdirSync|unlinkSync|truncateSync)\s*\(/;
const WRITE_STREAM_PATTERN = /\bfs\s*\.\s*(createWriteStream|openSync)\s*\(/;
const SHELL_PATTERN = /require\s*\(\s*['"]child_process['"]\s*\)|\bchild_process\s*\.|\b(exec|execFile|spawn|fork|execSync|execFileSync|spawnSync)\s*\(/;
const WRITE_METHOD_PATTERN = /\bfs(?:\s*\.\s*promises)?\s*\.\s*(writeFile|appendFile|copyFile|cp|rename|mkdir|writeFileSync|appendFileSync|copyFileSync|cpSync|renameSync|mkdirSync)\s*\(/;
const HOME_PATH_PATTERN = /(?:^~(?:\/|\\|$)|\$HOME|%USERPROFILE%|%HOMEPATH%)/i;
const TRAVERSAL_PATTERN = /(^|[\\/])\.\.([\\/]|$)/;
function extractStringLiterals(code) {
const literals = [];
const pattern = /(['"`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
let match;
while ((match = pattern.exec(String(code || '')))) {
literals.push(match[2]);
}
return literals;
}
function isAbsoluteLiteral(value) {
return path.isAbsolute(value)
|| path.win32.isAbsolute(value)
|| /^\\\\/.test(value);
}
function isAbsoluteLiteralInsideProject(projectPath, value) {
if (!projectPath) {
return false;
}
if (path.win32.isAbsolute(value)) {
const root = projectPath.replace(/\//g, '\\');
const relative = path.win32.relative(root, value);
return relative === '' || (relative && !relative.startsWith('..') && !path.win32.isAbsolute(relative));
}
if (path.isAbsolute(value)) {
return isPathInside(projectPath, path.resolve(value));
}
return false;
}
function inspectJavascriptSafety(code, options = {}) {
const source = String(code || '');
const projectPath = options.projectPath ? path.resolve(String(options.projectPath)) : '';
const violations = [];
if (DELETE_METHOD_PATTERN.test(source)) {
violations.push('direct fs delete/truncate calls are blocked by default');
}
if (WRITE_STREAM_PATTERN.test(source)) {
violations.push('raw writable file streams are blocked by default');
}
if (SHELL_PATTERN.test(source)) {
violations.push('child_process execution is blocked by default');
}
const hasFileMutation = DELETE_METHOD_PATTERN.test(source)
|| WRITE_METHOD_PATTERN.test(source)
|| WRITE_STREAM_PATTERN.test(source);
if (hasFileMutation && /\bos\s*\.\s*homedir\s*\(/.test(source)) {
violations.push('file mutations derived from os.homedir() are blocked by default');
}
if (hasFileMutation && /\bprocess\s*\.\s*env\s*\.\s*(HOME|USERPROFILE|HOMEPATH|APPDATA|LOCALAPPDATA|TMP|TEMP)\b/.test(source)) {
violations.push('file mutations derived from user/system environment paths are blocked by default');
}
for (const literal of extractStringLiterals(source)) {
if (HOME_PATH_PATTERN.test(literal)) {
violations.push(`user-home path literal is blocked: ${literal}`);
continue;
}
if (TRAVERSAL_PATTERN.test(literal)) {
violations.push(`path traversal literal is blocked: ${literal}`);
continue;
}
if (isAbsoluteLiteral(literal)) {
if (!isAbsoluteLiteralInsideProject(projectPath, literal)) {
violations.push(`absolute path outside the Cocos project is blocked: ${literal}`);
}
}
}
return {
ok: violations.length === 0,
violations: Array.from(new Set(violations)),
};
}
function assertJavascriptSafety(code, options = {}) {
const result = inspectJavascriptSafety(code, options);
if (result.ok) {
return result;
}
throw new Error(
'JavaScript safety checks blocked this code: ' +
`${result.violations.join('; ')}. ` +
'Use project-relative helper/file tools, or pass safety_checks=false only after reviewing the risk.'
);
}
module.exports = {
assertJavascriptSafety,
inspectJavascriptSafety,
};
+18
View File
@@ -146,8 +146,26 @@ function createProjectSkill(projectPath, options = {}) {
});
}
function createCocosMcpProjectSkill(projectPath, options = {}) {
return createProjectSkill(projectPath, {
skillName: options.skillName || 'funplay-cocos-mcp-workflow',
title: options.title || 'Funplay Cocos MCP Workflow',
description: options.description || 'Use this skill when editing, validating, or debugging this Cocos Creator project through Funplay Cocos MCP.',
overwrite: options.overwrite !== false,
instructions: String(options.instructions || '').trim() || [
'- Start by reading `cocos://project/context` or calling `get_editor_state` to confirm the active project, scene, server URL, and tool profile.',
'- Prefer `execute_javascript` for high-level scene/editor orchestration, but keep safety checks enabled unless the code was reviewed.',
'- Use focused tools when they are better primitives: `list_assets`, `inspect_asset_dependencies`, `validate_asset_dependencies`, `run_script_diagnostics`, `get_script_diagnostic_context`, and screenshot tools.',
'- For UI work, inspect the active Canvas/hierarchy first, mutate the smallest necessary node/component set, then verify with `validate_scene` and a screenshot.',
'- For prefab or asset edits, inspect dependencies/references before mutation and refresh assets afterward.',
'- When changing tool exposure, save a named tool profile so the same client setup can be restored later.',
].join('\n'),
});
}
module.exports = {
KNOWN_INSTRUCTION_PATHS,
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
+135 -3
View File
@@ -84,7 +84,11 @@ class McpServer {
this.runtimeLog = options.runtimeLog;
this.serverName = options.serverName;
this.serverVersion = options.serverVersion;
this.projectName = options.projectName || '';
this.projectIdentity = options.projectIdentity || '';
this.server = null;
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
@@ -93,10 +97,13 @@ class McpServer {
}
isRunning() {
return Boolean(this.server && this.server.listening);
return Boolean(this.attached || (this.server && this.server.listening));
}
getPort() {
if (this.attached && this.actualPort) {
return this.actualPort;
}
if (this.server && typeof this.server.address === 'function') {
const address = this.server.address();
if (address && typeof address.port === 'number') {
@@ -114,6 +121,10 @@ class McpServer {
return this.portFallbackInfo;
}
getAttachInfo() {
return this.attachedInfo;
}
log(level, message) {
if (this.runtimeLog && typeof this.runtimeLog.add === 'function') {
this.runtimeLog.add(level, message);
@@ -137,13 +148,21 @@ class McpServer {
this.actualPort = null;
this.portFallbackInfo = null;
this.attached = false;
this.attachedInfo = null;
const requestHandler = async (request, response) => {
try {
const requestUrl = new URL(request.url || '/', 'http://localhost');
if (request.method === 'GET' && requestUrl.pathname === '/health') {
this.log('info', 'GET /health');
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
return json(response, 200, {
ok: true,
name: this.serverName,
version: this.serverVersion,
projectName: this.projectName,
projectIdentity: this.projectIdentity,
}, this.negotiatedProtocolVersion);
}
if (request.method === 'GET' && requestUrl.pathname === '/tools') {
@@ -288,7 +307,12 @@ class McpServer {
return;
} catch (error) {
lastError = error;
candidate.removeAllListeners();
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
if (await this.tryAttachToExisting(port)) {
return;
}
const nextPort = port + 1;
this.log(
'warn',
@@ -299,7 +323,6 @@ class McpServer {
continue;
}
candidate.removeAllListeners();
break;
}
}
@@ -311,6 +334,15 @@ class McpServer {
}
async stop() {
if (this.attached) {
this.log('info', `Detached from existing MCP listener on ${this.config.host}:${this.actualPort}.`);
this.attached = false;
this.attachedInfo = null;
this.actualPort = null;
this.portFallbackInfo = null;
return;
}
if (!this.server) {
this.log('info', 'Stop skipped: server object is empty.');
return;
@@ -321,6 +353,7 @@ class McpServer {
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.attachedInfo = null;
await new Promise((resolve, reject) => {
active.close((error) => {
if (error) {
@@ -334,6 +367,100 @@ class McpServer {
});
}
async tryAttachToExisting(port) {
if (!this.projectIdentity || this.config.attachToExisting === false || port === 0) {
return false;
}
const probe = await this.probeExistingServer(port);
if (!probe || !probe.result) {
this.log('warn', `Port ${port} is occupied, but no compatible Funplay MCP initialize response was received.`);
return false;
}
const result = probe.result || {};
const serverInfo = result.serverInfo || {};
const funplay = result.funplay || {};
const remoteProjectIdentity = funplay.projectIdentity || serverInfo.projectIdentity || '';
const remoteName = serverInfo.name || '';
if (remoteName === this.serverName && remoteProjectIdentity === this.projectIdentity) {
this.attached = true;
this.attachedInfo = {
host: this.config.host,
port,
serverName: remoteName,
projectName: funplay.projectName || this.projectName,
projectIdentity: remoteProjectIdentity,
version: serverInfo.version || '',
};
this.actualPort = port;
this.portFallbackInfo = null;
this.log('info', `Attached to existing MCP listener for this project at http://${this.config.host}:${port}/.`);
return true;
}
this.log(
'warn',
`Port ${port} belongs to another listener; expected name=${this.serverName}, project=${this.projectIdentity}, ` +
`got name=${remoteName || 'unknown'}, project=${remoteProjectIdentity || 'unknown'}.`
);
return false;
}
probeExistingServer(port) {
const body = JSON.stringify({
jsonrpc: '2.0',
id: 'funplay-probe',
method: 'initialize',
params: {
protocolVersion: MCP_PROTOCOL_VERSION,
clientInfo: {
name: 'funplay-cocos-mcp-probe',
version: this.serverVersion,
},
},
});
return new Promise((resolve) => {
const request = http.request(
{
host: this.config.host,
port,
method: 'POST',
path: '/',
timeout: 600,
headers: {
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
},
(response) => {
const chunks = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
if (response.statusCode !== 200) {
resolve(null);
return;
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
} catch (error) {
resolve(null);
}
});
}
);
request.on('timeout', () => {
request.destroy();
resolve(null);
});
request.on('error', () => resolve(null));
request.end(body);
});
}
listen(server, port, host) {
return new Promise((resolve, reject) => {
const onError = (error) => {
@@ -559,6 +686,11 @@ class McpServer {
name: this.serverName,
version: this.serverVersion,
},
funplay: {
server: 'funplay-cocos-mcp',
projectName: this.projectName,
projectIdentity: this.projectIdentity,
},
capabilities: {
tools: {},
resources: {},
+155
View File
@@ -0,0 +1,155 @@
'use strict';
const PROFILE_FIELDS = [
'toolProfile',
'enabledToolCategories',
'disabledToolCategories',
'enabledTools',
'disabledTools',
];
function normalizeProfileName(value) {
const normalized = String(value || '').trim();
if (!normalized) {
throw new Error('profile name is required.');
}
return normalized.slice(0, 80);
}
function normalizeStringList(value) {
if (Array.isArray(value)) {
return value.map((item) => String(item || '').trim()).filter(Boolean);
}
if (typeof value === 'string') {
return value.split(/[\n,]/).map((item) => item.trim()).filter(Boolean);
}
return [];
}
function normalizeProfileMode(value) {
const normalized = String(value || 'core').trim().toLowerCase();
return normalized === 'full' || normalized === 'custom' ? normalized : 'core';
}
function normalizeToolProfile(value) {
const profile = value || {};
return {
name: normalizeProfileName(profile.name),
toolProfile: normalizeProfileMode(profile.toolProfile),
enabledToolCategories: normalizeStringList(profile.enabledToolCategories).map((item) => item.toLowerCase()),
disabledToolCategories: normalizeStringList(profile.disabledToolCategories).map((item) => item.toLowerCase()),
enabledTools: normalizeStringList(profile.enabledTools),
disabledTools: normalizeStringList(profile.disabledTools),
updatedAt: profile.updatedAt ? String(profile.updatedAt) : new Date().toISOString(),
};
}
function normalizeSavedToolProfiles(value) {
const profiles = [];
const seen = new Set();
for (const item of Array.isArray(value) ? value : []) {
try {
const profile = normalizeToolProfile(item);
const key = profile.name.toLowerCase();
if (seen.has(key)) {
const index = profiles.findIndex((existing) => existing.name.toLowerCase() === key);
profiles[index] = profile;
} else {
seen.add(key);
profiles.push(profile);
}
} catch (error) {
// Ignore malformed saved profile entries rather than breaking extension startup.
}
}
return profiles.sort((left, right) => left.name.localeCompare(right.name));
}
function createToolProfileSnapshot(config = {}, name) {
return normalizeToolProfile({
name,
toolProfile: config.toolProfile,
enabledToolCategories: config.enabledToolCategories,
disabledToolCategories: config.disabledToolCategories,
enabledTools: config.enabledTools,
disabledTools: config.disabledTools,
});
}
function upsertToolProfile(savedProfiles, profile) {
const normalized = normalizeToolProfile(profile);
const profiles = normalizeSavedToolProfiles(savedProfiles);
const key = normalized.name.toLowerCase();
const index = profiles.findIndex((item) => item.name.toLowerCase() === key);
if (index >= 0) {
profiles[index] = normalized;
} else {
profiles.push(normalized);
}
return normalizeSavedToolProfiles(profiles);
}
function deleteToolProfile(savedProfiles, name) {
const key = normalizeProfileName(name).toLowerCase();
return normalizeSavedToolProfiles(savedProfiles)
.filter((profile) => profile.name.toLowerCase() !== key);
}
function findToolProfile(savedProfiles, name) {
const key = normalizeProfileName(name).toLowerCase();
return normalizeSavedToolProfiles(savedProfiles)
.find((profile) => profile.name.toLowerCase() === key) || null;
}
function applyToolProfile(config = {}, profile) {
const normalized = normalizeToolProfile(profile);
const next = { ...config };
for (const field of PROFILE_FIELDS) {
next[field] = Array.isArray(normalized[field])
? normalized[field].slice()
: normalized[field];
}
next.activeToolProfileName = normalized.name;
return next;
}
function exportToolProfiles(savedProfiles) {
return {
version: 1,
profiles: normalizeSavedToolProfiles(savedProfiles),
};
}
function parseProfileImportPayload(payload) {
if (typeof payload === 'string') {
return JSON.parse(payload);
}
return payload || {};
}
function importToolProfiles(savedProfiles, payload, options = {}) {
const parsed = parseProfileImportPayload(payload);
const incoming = Array.isArray(parsed)
? parsed
: Array.isArray(parsed.profiles)
? parsed.profiles
: [];
if (!incoming.length) {
throw new Error('No tool profiles found in import payload.');
}
const base = options.replace ? [] : normalizeSavedToolProfiles(savedProfiles);
return incoming.reduce((profiles, profile) => upsertToolProfile(profiles, profile), base);
}
module.exports = {
applyToolProfile,
createToolProfileSnapshot,
deleteToolProfile,
exportToolProfiles,
findToolProfile,
importToolProfiles,
normalizeSavedToolProfiles,
normalizeToolProfile,
upsertToolProfile,
};
+62 -1
View File
@@ -23,6 +23,7 @@ const {
} = require('./logs');
const { resolveProjectPath } = require('./path-safety');
const {
createCocosMcpProjectSkill,
createProjectSkill,
listProjectInstructions,
readProjectInstruction,
@@ -36,14 +37,22 @@ const {
revertPrefabInstance,
validatePrefabReferences,
} = require('./prefabs');
const { createAssetsAdvancedTools } = require('./tools/assets-advanced');
const { createCocosProjectTools } = require('./tools/cocos-project');
const { buildSnippet, createFileTools, refreshAssets } = require('./tools/files');
const { createSceneEventTools } = require('./tools/scene-events');
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
const { checkForUpdate } = require('./update-checker');
const { assertJavascriptSafety } = require('./javascript-safety');
const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const TOOL_CATEGORY_RULES = [
['project', /^(get_project_info|get_editor_state|get_tool_catalog)$/],
['build', /^(get_build_status|open_build_panel|run_project_preview|save_current_scene)$/],
['preferences', /preference/],
['broadcast', /broadcast/],
['events', /event|bind_button_click|button_click/],
['updates', /update/],
['logs', /log/],
['diagnostics', /diagnostic|validate/],
@@ -306,6 +315,30 @@ function toOutput(value) {
return safeStringify(value);
}
function useJavascriptSafetyChecks(args, runtimeContext) {
if (args && typeof args.safety_checks === 'boolean') {
return args.safety_checks;
}
if (args && typeof args.safetyChecks === 'boolean') {
return args.safetyChecks;
}
const config = runtimeContext && runtimeContext.config;
if (config && typeof config.executeJavascriptSafetyChecks === 'boolean') {
return config.executeJavascriptSafetyChecks;
}
return true;
}
function assertToolJavascriptSafety(args, runtimeContext) {
if (!useJavascriptSafetyChecks(args, runtimeContext)) {
return;
}
assertJavascriptSafety(args && args.code, {
projectPath: runtimeContext && runtimeContext.projectPath,
});
}
async function resolveNodeUuid(sceneBridge, args) {
if (args && args.uuid) {
return String(args.uuid);
@@ -328,11 +361,14 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
context: { type: 'string', description: 'Execution context: scene or editor.' },
code: { type: 'string', description: 'JavaScript code to execute. May directly return a value, define run(env), or export a function.' },
args: { type: 'object', description: 'Optional JSON object passed into the script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
},
['context', 'code']
),
handler: async (args) => {
const context = String(args.context || '').toLowerCase();
const runtimeContext = getRuntimeContext();
assertToolJavascriptSafety(args, runtimeContext);
if (context === 'scene') {
return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} });
}
@@ -353,10 +389,14 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
{
code: { type: 'string', description: 'JavaScript code to execute inside the scene script context.' },
args: { type: 'object', description: 'Optional JSON object passed to the scene script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
},
['code']
),
handler: async (args) => sceneBridge.call('executeCode', { code: args.code, args: args.args || {} }),
handler: async (args) => {
assertToolJavascriptSafety(args, getRuntimeContext());
return sceneBridge.call('executeCode', { code: args.code, args: args.args || {} });
},
},
{
name: 'execute_editor_script',
@@ -366,10 +406,12 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
{
code: { type: 'string', description: 'JavaScript code to execute inside the editor context.' },
args: { type: 'object', description: 'Optional JSON object passed to the editor script.' },
safety_checks: { type: 'boolean', description: 'Override the project default JavaScript safety checks for this call.' },
},
['code']
),
handler: async (args) => {
assertToolJavascriptSafety(args, getRuntimeContext());
if (typeof editorExecutor !== 'function') {
throw new Error('Editor JavaScript execution is unavailable.');
}
@@ -512,6 +554,22 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return createProjectSkill(projectPath, args);
},
},
{
name: 'create_cocos_mcp_project_skill',
profile: 'full',
description: '[core] Create a recommended local Codex project skill for Funplay Cocos MCP workflows.',
inputSchema: createSchema(
{
skillName: { type: 'string', description: 'Optional filesystem-safe project skill name.' },
overwrite: { type: 'boolean', description: 'Allow overwriting an existing skill. Defaults to true.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return createCocosMcpProjectSkill(projectPath, args);
},
},
{
name: 'set_selection',
profile: 'core',
@@ -656,6 +714,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
inputSchema: createSchema({}, []),
handler: async () => getRuntimeContext(),
},
...createCocosProjectTools({ createSchema }),
{
name: 'list_scenes',
profile: 'core',
@@ -961,6 +1020,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
return selectAsset(info.uuid || args.target);
},
},
...createAssetsAdvancedTools({ createSchema, getRuntimeContext }),
{
name: 'get_editor_selection',
profile: 'full',
@@ -1463,6 +1523,7 @@ function createToolRegistry({ getRuntimeContext, getStatus, interactionLog, runt
),
handler: async (args) => sceneBridge.call('simulateButtonClick', args),
},
...createSceneEventTools({ createSchema, sceneBridge }),
{
name: 'invoke_component_method',
profile: 'full',
+221
View File
@@ -0,0 +1,221 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { listAssets, queryAssetInfo } = require('../assets');
const { resolveProjectPath } = require('../path-safety');
const UUID_KEY_PATTERN = /uuid|assetUuid|prefabUuid|sceneUuid|__uuid__/i;
const UUID_LITERAL_PATTERN = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[A-Za-z0-9+/=-]{20,32}/g;
function assetUrlToPath(projectPath, url) {
if (!url || !String(url).startsWith('db://assets/')) {
return '';
}
return path.join(projectPath, String(url).slice('db://'.length));
}
function assetFilePath(projectPath, info) {
const candidates = [
info && info.file,
info && info.path,
info && info.source,
info && info.url ? assetUrlToPath(projectPath, info.url) : '',
].filter(Boolean);
for (const candidate of candidates) {
const fullPath = resolveProjectPath(projectPath, candidate);
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
return fullPath;
}
}
return '';
}
function collectStructuredUuidReferences(value, refs = [], pointer = '') {
if (value == null) {
return refs;
}
if (Array.isArray(value)) {
value.forEach((item, index) => collectStructuredUuidReferences(item, refs, `${pointer}/${index}`));
return refs;
}
if (typeof value !== 'object') {
return refs;
}
for (const [key, child] of Object.entries(value)) {
const childPointer = `${pointer}/${key}`;
if (typeof child === 'string' && UUID_KEY_PATTERN.test(key)) {
refs.push({ uuid: child, path: childPointer, key, source: 'structured' });
continue;
}
collectStructuredUuidReferences(child, refs, childPointer);
}
return refs;
}
function collectTextUuidReferences(text) {
const refs = [];
const seen = new Set();
let match;
while ((match = UUID_LITERAL_PATTERN.exec(String(text || '')))) {
const uuid = match[0];
if (seen.has(uuid)) {
continue;
}
seen.add(uuid);
refs.push({ uuid, path: `@${match.index}`, key: '', source: 'text' });
}
return refs;
}
function dedupeReferences(refs) {
const seen = new Set();
const result = [];
for (const ref of refs) {
const key = `${ref.uuid}:${ref.path}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(ref);
}
return result;
}
function collectUuidReferences(content) {
const refs = [];
try {
refs.push(...collectStructuredUuidReferences(JSON.parse(content)));
} catch (error) {
// Non-JSON assets still get a literal reference scan below.
}
refs.push(...collectTextUuidReferences(content));
return dedupeReferences(refs);
}
async function inspectAssetDependencies(projectPath, target, options = {}) {
const info = await queryAssetInfo(target);
const filePath = assetFilePath(projectPath, info);
if (!filePath) {
throw new Error(`Asset file was not found: ${target}`);
}
const content = fs.readFileSync(filePath, 'utf8');
const limit = Number.isFinite(options.limit) ? Math.max(1, Math.min(500, options.limit)) : 200;
const references = collectUuidReferences(content).slice(0, limit);
const dependencies = [];
const missing = [];
for (const ref of references) {
try {
const asset = await queryAssetInfo(ref.uuid);
dependencies.push({
...ref,
exists: true,
asset: {
uuid: asset.uuid,
url: asset.url,
type: asset.type,
importer: asset.importer,
},
});
} catch (error) {
missing.push({ ...ref, exists: false, error: error.message });
}
}
return {
ok: missing.length === 0,
target,
asset: {
uuid: info.uuid,
url: info.url,
type: info.type,
},
filePath: path.relative(projectPath, filePath).replace(/\\/g, '/'),
referenceCount: references.length,
dependencyCount: dependencies.length,
missingCount: missing.length,
dependencies,
missing,
};
}
async function validateAssetDependencies(projectPath, options = {}) {
const targets = options.target
? [options.target]
: (await listAssets({ pattern: options.pattern || 'db://assets/**', ccType: options.ccType }))
.slice(0, Number.isFinite(options.limit) ? Math.max(1, Math.min(200, options.limit)) : 50)
.map((asset) => asset.uuid || asset.url)
.filter(Boolean);
const assets = [];
for (const target of targets) {
try {
assets.push(await inspectAssetDependencies(projectPath, target, options));
} catch (error) {
assets.push({
ok: false,
target,
error: error.message,
missingCount: 1,
});
}
}
const missingCount = assets.reduce((sum, asset) => sum + (Number(asset.missingCount) || 0), 0);
return {
ok: missingCount === 0,
assetCount: assets.length,
missingCount,
assets,
};
}
function createAssetsAdvancedTools({ createSchema, getRuntimeContext }) {
return [
{
name: 'inspect_asset_dependencies',
profile: 'core',
description: '[specialist] Inspect UUID-style dependencies referenced by a serialized Cocos asset.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Asset uuid, db url, or project path.' },
limit: { type: 'number', description: 'Maximum dependency references to inspect.' },
},
['target']
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await inspectAssetDependencies(projectPath, args.target, args);
},
},
{
name: 'validate_asset_dependencies',
profile: 'core',
description: '[specialist] Validate UUID-style dependencies for one asset or a project asset query.',
inputSchema: createSchema(
{
target: { type: 'string', description: 'Optional asset uuid, db url, or project path.' },
pattern: { type: 'string', description: 'Asset-db pattern used when target is omitted.' },
ccType: { type: 'string', description: 'Optional Cocos asset type filter.' },
limit: { type: 'number', description: 'Maximum assets to scan when target is omitted.' },
},
[]
),
handler: async (args) => {
const { projectPath } = getRuntimeContext();
return await validateAssetDependencies(projectPath, args);
},
},
];
}
module.exports = {
collectUuidReferences,
createAssetsAdvancedTools,
inspectAssetDependencies,
validateAssetDependencies,
};
+245
View File
@@ -0,0 +1,245 @@
'use strict';
function hasEditorMessage() {
return Boolean(global.Editor && Editor.Message);
}
function ensureEditorMessage() {
if (!hasEditorMessage()) {
throw new Error('Editor.Message is unavailable in this Cocos extension host.');
}
}
async function requestEditorMessage(channel, method, ...args) {
ensureEditorMessage();
if (typeof Editor.Message.request !== 'function') {
throw new Error('Editor.Message.request is unavailable in this Cocos extension host.');
}
return await Editor.Message.request(channel, method, ...args);
}
async function tryEditorRequests(candidates) {
const attempts = [];
for (const candidate of candidates) {
const channel = candidate.channel;
const method = candidate.method;
const args = Array.isArray(candidate.args) ? candidate.args : [];
try {
const result = await requestEditorMessage(channel, method, ...args);
return {
ok: true,
channel,
method,
result,
attempts,
};
} catch (error) {
attempts.push({ channel, method, error: error.message });
}
}
const message = attempts.length
? attempts.map((attempt) => `${attempt.channel}.${attempt.method}: ${attempt.error}`).join('; ')
: 'no editor message candidates were provided';
const error = new Error(`No compatible Cocos editor message succeeded: ${message}`);
error.attempts = attempts;
throw error;
}
async function tryEditorRequestsStatus(candidates) {
try {
return await tryEditorRequests(candidates);
} catch (error) {
return {
ok: false,
available: false,
attempts: error.attempts || [],
error: error.message,
};
}
}
async function openPanel(panelName) {
const id = String(panelName || 'builder').trim();
if (!id) {
throw new Error('panelName is required.');
}
if (!global.Editor || !Editor.Panel || typeof Editor.Panel.open !== 'function') {
throw new Error('Editor.Panel.open is unavailable in this Cocos extension host.');
}
const result = await Editor.Panel.open(id);
return { opened: true, panelName: id, result };
}
function getEditorPreference(scope, key) {
if (!global.Editor || !Editor.Profile) {
throw new Error('Editor.Profile is unavailable in this Cocos extension host.');
}
const normalizedScope = String(scope || 'project').toLowerCase();
const target = normalizedScope === 'global' ? Editor.Profile : Editor.Profile;
const getters = normalizedScope === 'global'
? ['getConfig', 'getGlobal']
: ['getProject', 'getConfig'];
for (const getter of getters) {
if (typeof target[getter] === 'function') {
return target[getter](key);
}
}
throw new Error('No compatible Editor.Profile getter is available.');
}
function setEditorPreference(scope, key, value) {
if (!global.Editor || !Editor.Profile) {
throw new Error('Editor.Profile is unavailable in this Cocos extension host.');
}
const normalizedScope = String(scope || 'project').toLowerCase();
const target = Editor.Profile;
const setters = normalizedScope === 'global'
? ['setConfig', 'setGlobal']
: ['setProject', 'setConfig'];
for (const setter of setters) {
if (typeof target[setter] === 'function') {
const result = target[setter](key, value);
return { set: true, scope: normalizedScope, key, value, method: setter, result };
}
}
throw new Error('No compatible Editor.Profile setter is available.');
}
function broadcastEditorMessage(options = {}) {
ensureEditorMessage();
const channel = String(options.channel || '').trim();
const message = String(options.message || '').trim();
if (!message) {
throw new Error('message is required.');
}
const payload = options.payload === undefined ? {} : options.payload;
if (channel && typeof Editor.Message.send === 'function') {
const result = Editor.Message.send(channel, message, payload);
return { sent: true, mode: 'send', channel, message, payload, result };
}
if (typeof Editor.Message.broadcast === 'function') {
const result = Editor.Message.broadcast(message, payload);
return { sent: true, mode: 'broadcast', message, payload, result };
}
throw new Error('Neither Editor.Message.send nor Editor.Message.broadcast is available.');
}
function createCocosProjectTools({ createSchema }) {
return [
{
name: 'save_current_scene',
profile: 'full',
description: '[core] Save the currently open Cocos scene using available editor scene messages.',
inputSchema: createSchema({}, []),
handler: async () => {
const result = await tryEditorRequests([
{ channel: 'scene', method: 'save-scene' },
{ channel: 'scene', method: 'save' },
]);
return { saved: true, ...result };
},
},
{
name: 'open_build_panel',
profile: 'full',
description: '[core] Open the Cocos build panel, defaulting to the builder panel id.',
inputSchema: createSchema(
{
panelName: { type: 'string', description: 'Panel id to open. Defaults to builder.' },
},
[]
),
handler: async (args) => openPanel(args.panelName || 'builder'),
},
{
name: 'get_build_status',
profile: 'core',
description: '[specialist] Query Cocos build/preview status using known builder message variants.',
inputSchema: createSchema({}, []),
handler: async () => await tryEditorRequestsStatus([
{ channel: 'builder', method: 'query-build-status' },
{ channel: 'builder', method: 'get-build-status' },
{ channel: 'builder', method: 'query-build-tasks' },
]),
},
{
name: 'run_project_preview',
profile: 'full',
description: '[core] Start Cocos preview/run using known preview and builder message variants.',
inputSchema: createSchema(
{
platform: { type: 'string', description: 'Optional preview platform or build target.' },
},
[]
),
handler: async (args) => await tryEditorRequests([
{ channel: 'preview', method: 'start', args: [args || {}] },
{ channel: 'preview', method: 'open-preview', args: [args || {}] },
{ channel: 'builder', method: 'preview', args: [args || {}] },
]),
},
{
name: 'get_editor_preference',
profile: 'full',
description: '[core] Read a Cocos editor preference through Editor.Profile when available.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'Preference scope: project or global. Defaults to project.' },
key: { type: 'string', description: 'Preference key.' },
},
['key']
),
handler: async (args) => ({
scope: args.scope || 'project',
key: args.key,
value: getEditorPreference(args.scope, args.key),
}),
},
{
name: 'set_editor_preference',
profile: 'full',
description: '[core] Write a Cocos editor preference through Editor.Profile when available.',
inputSchema: createSchema(
{
scope: { type: 'string', description: 'Preference scope: project or global. Defaults to project.' },
key: { type: 'string', description: 'Preference key.' },
valueJson: { type: 'string', description: 'JSON encoded preference value.' },
},
['key', 'valueJson']
),
handler: async (args) => {
let value;
try {
value = JSON.parse(args.valueJson);
} catch (error) {
throw new Error(`valueJson must be valid JSON: ${error.message}`);
}
return setEditorPreference(args.scope, args.key, value);
},
},
{
name: 'broadcast_editor_message',
profile: 'full',
description: '[core] Send or broadcast a Cocos editor message for advanced editor automation.',
inputSchema: createSchema(
{
channel: { type: 'string', description: 'Optional Editor.Message channel for send().' },
message: { type: 'string', description: 'Message name to send or broadcast.' },
payload: { type: 'object', description: 'Optional JSON payload.' },
},
['message']
),
handler: async (args) => broadcastEditorMessage(args),
},
];
}
module.exports = {
broadcastEditorMessage,
createCocosProjectTools,
getEditorPreference,
setEditorPreference,
tryEditorRequests,
tryEditorRequestsStatus,
};
+45
View File
@@ -0,0 +1,45 @@
'use strict';
function createSceneEventTools({ createSchema, sceneBridge }) {
return [
{
name: 'list_button_click_events',
profile: 'full',
description: '[core] List click event bindings on a Cocos Button component.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Button node hierarchy path.' },
uuid: { type: 'string', description: 'Button node uuid.' },
name: { type: 'string', description: 'Fallback exact button node name.' },
},
[]
),
handler: async (args) => sceneBridge.call('listButtonClickEvents', args),
},
{
name: 'bind_button_click_event',
profile: 'full',
description: '[core] Bind a Cocos Button click event to a target node component method.',
inputSchema: createSchema(
{
path: { type: 'string', description: 'Button node hierarchy path.' },
uuid: { type: 'string', description: 'Button node uuid.' },
name: { type: 'string', description: 'Fallback exact button node name.' },
targetPath: { type: 'string', description: 'Target node path containing the handler component.' },
targetUuid: { type: 'string', description: 'Target node uuid containing the handler component.' },
targetName: { type: 'string', description: 'Fallback exact target node name.' },
componentName: { type: 'string', description: 'Target component class name.' },
handler: { type: 'string', description: 'Method name to invoke on the target component.' },
customEventData: { type: 'string', description: 'Optional custom event data string.' },
replace: { type: 'boolean', description: 'Replace an identical existing binding.' },
},
['componentName', 'handler']
),
handler: async (args) => sceneBridge.call('bindButtonClickEvent', args),
},
];
}
module.exports = {
createSceneEventTools,
};