Initial commit: funplay cocos mcp
This commit is contained in:
+182
@@ -0,0 +1,182 @@
|
||||
'use strict';
|
||||
|
||||
async function safeRequest(channel, method, ...args) {
|
||||
if (!global.Editor || !Editor.Message || typeof Editor.Message.request !== 'function') {
|
||||
throw new Error('Editor.Message.request is unavailable in the Cocos extension host.');
|
||||
}
|
||||
return await Editor.Message.request(channel, method, ...args);
|
||||
}
|
||||
|
||||
function buildAssetTargetCandidates(uuidOrPath) {
|
||||
const raw = String(uuidOrPath || '').trim().replace(/\\/g, '/');
|
||||
const candidates = [];
|
||||
const add = (value) => {
|
||||
if (value && !candidates.includes(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
add(raw);
|
||||
|
||||
if (raw.startsWith('assets/')) {
|
||||
add(`db://${raw}`);
|
||||
} else if (raw.startsWith('/assets/')) {
|
||||
add(`db://${raw.slice(1)}`);
|
||||
}
|
||||
|
||||
if (raw.includes('/assets/')) {
|
||||
add(`db://assets/${raw.split('/assets/').pop()}`);
|
||||
}
|
||||
|
||||
if (raw.startsWith('db://assets/') && !raw.match(/\.[a-z0-9]+$/i)) {
|
||||
add(`${raw}.scene`);
|
||||
add(`${raw}.prefab`);
|
||||
add(`${raw}.ts`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestFirst(method, uuidOrPath) {
|
||||
const candidates = buildAssetTargetCandidates(uuidOrPath);
|
||||
let lastError = null;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await safeRequest('asset-db', method, candidate);
|
||||
if (result != null) {
|
||||
return result;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function listAssets(options = {}) {
|
||||
const payload = {};
|
||||
if (options.pattern) {
|
||||
payload.pattern = options.pattern;
|
||||
}
|
||||
if (options.ccType) {
|
||||
payload.ccType = options.ccType;
|
||||
}
|
||||
const result = await safeRequest('asset-db', 'query-assets', payload);
|
||||
return Array.isArray(result) ? result : [];
|
||||
}
|
||||
|
||||
async function queryAssetInfo(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
|
||||
const direct = await requestFirst('query-asset-info', uuidOrPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const url = await queryAssetUrl(uuidOrPath).catch(() => null);
|
||||
if (url) {
|
||||
const fromUrl = await safeRequest('asset-db', 'query-asset-info', url);
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Asset not found: ${uuidOrPath}`);
|
||||
}
|
||||
|
||||
async function queryAssetMeta(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
|
||||
const direct = await requestFirst('query-asset-meta', uuidOrPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
return await safeRequest('asset-db', 'query-asset-meta', info.uuid || info.url || uuidOrPath);
|
||||
}
|
||||
|
||||
async function queryAssetData(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
|
||||
const direct = await requestFirst('query-asset-data', uuidOrPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
return await safeRequest('asset-db', 'query-asset-data', info.uuid || info.url || uuidOrPath);
|
||||
}
|
||||
|
||||
async function queryAssetUrl(uuidOrPath) {
|
||||
if (!uuidOrPath) {
|
||||
throw new Error('Asset uuid or path is required.');
|
||||
}
|
||||
const result = await requestFirst('query-url', uuidOrPath);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
throw new Error(`Asset URL not found: ${uuidOrPath}`);
|
||||
}
|
||||
|
||||
async function openAsset(uuidOrPath) {
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
await safeRequest('asset-db', 'open-asset', info.uuid || uuidOrPath);
|
||||
return info;
|
||||
}
|
||||
|
||||
async function deleteAsset(uuidOrPath) {
|
||||
let url = String(uuidOrPath || '');
|
||||
if (!url.startsWith('db://')) {
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
url = info.url || (await queryAssetUrl(info.uuid || uuidOrPath));
|
||||
}
|
||||
|
||||
await safeRequest('asset-db', 'delete-asset', url);
|
||||
return { deleted: true, url };
|
||||
}
|
||||
|
||||
function selectAsset(uuid) {
|
||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.select !== 'function') {
|
||||
throw new Error('Editor.Selection.select is unavailable in this Cocos environment.');
|
||||
}
|
||||
|
||||
Editor.Selection.clear('asset');
|
||||
Editor.Selection.select('asset', uuid);
|
||||
return { selected: true, uuid };
|
||||
}
|
||||
|
||||
function getCurrentSelection() {
|
||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
||||
throw new Error('Editor.Selection API is unavailable in this Cocos environment.');
|
||||
}
|
||||
|
||||
return {
|
||||
asset: Editor.Selection.getSelected('asset') || '',
|
||||
node: Editor.Selection.getSelected('node') || '',
|
||||
type: typeof Editor.Selection.getLastSelectedType === 'function' ? Editor.Selection.getLastSelectedType() : '',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deleteAsset,
|
||||
getCurrentSelection,
|
||||
listAssets,
|
||||
openAsset,
|
||||
queryAssetData,
|
||||
queryAssetInfo,
|
||||
queryAssetMeta,
|
||||
queryAssetUrl,
|
||||
selectAsset,
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const SERVER_NAME = 'funplay_cocos';
|
||||
|
||||
function ensureParent(filePath) {
|
||||
const dir = path.dirname(filePath);
|
||||
if (dir && !fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!text) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
ensureParent(filePath);
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function configureJsonTarget(target) {
|
||||
const root = readJson(target.configPath);
|
||||
const rootKey = target.rootKey || 'mcpServers';
|
||||
if (!root[rootKey] || typeof root[rootKey] !== 'object' || Array.isArray(root[rootKey])) {
|
||||
root[rootKey] = {};
|
||||
}
|
||||
root[rootKey][SERVER_NAME] = target.entry;
|
||||
writeJson(target.configPath, root);
|
||||
}
|
||||
|
||||
function configureTomlTarget(target) {
|
||||
ensureParent(target.configPath);
|
||||
const sectionHeader = `[mcp_servers.${SERVER_NAME}]`;
|
||||
const section = `${sectionHeader}\nurl = "${target.url}"\n`;
|
||||
let content = fs.existsSync(target.configPath) ? fs.readFileSync(target.configPath, 'utf8') : '';
|
||||
|
||||
if (content.includes(sectionHeader)) {
|
||||
const start = content.indexOf(sectionHeader);
|
||||
const afterHeader = start + sectionHeader.length;
|
||||
const nextSection = content.indexOf('\n[', afterHeader);
|
||||
const end = nextSection >= 0 ? nextSection : content.length;
|
||||
content = `${content.slice(0, start)}${section}${content.slice(end)}`;
|
||||
} else {
|
||||
if (content.length > 0 && !content.endsWith('\n')) {
|
||||
content += '\n';
|
||||
}
|
||||
if (content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += section;
|
||||
}
|
||||
|
||||
fs.writeFileSync(target.configPath, content, 'utf8');
|
||||
}
|
||||
|
||||
function buildTargets(config) {
|
||||
const home = os.homedir();
|
||||
const url = `http://${config.host}:${config.port}/`;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'claude_code',
|
||||
name: 'Claude Code / Claude Desktop',
|
||||
configPath: path.join(home, '.claude.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { type: 'http', url },
|
||||
},
|
||||
{
|
||||
id: 'cursor',
|
||||
name: 'Cursor',
|
||||
configPath: path.join(home, '.cursor', 'mcp.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { url },
|
||||
},
|
||||
{
|
||||
id: 'vscode',
|
||||
name: 'VS Code',
|
||||
configPath: path.join(home, '.vscode', 'mcp.json'),
|
||||
rootKey: 'servers',
|
||||
entry: { type: 'http', url },
|
||||
},
|
||||
{
|
||||
id: 'trae',
|
||||
name: 'Trae',
|
||||
configPath: path.join(home, '.trae', 'mcp.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { url },
|
||||
},
|
||||
{
|
||||
id: 'kiro',
|
||||
name: 'Kiro',
|
||||
configPath: path.join(home, '.kiro', 'settings', 'mcp.json'),
|
||||
rootKey: 'mcpServers',
|
||||
entry: { type: 'http', url },
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
configPath: path.join(home, '.codex', 'config.toml'),
|
||||
isToml: true,
|
||||
url,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getTargetStatuses(config) {
|
||||
return buildTargets(config).map((target) => ({
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
configPath: target.configPath,
|
||||
configured: fs.existsSync(target.configPath),
|
||||
isToml: Boolean(target.isToml),
|
||||
}));
|
||||
}
|
||||
|
||||
function configureTarget(config, targetId) {
|
||||
const targets = buildTargets(config);
|
||||
const target = targets.find((item) => item.id === targetId);
|
||||
if (!target) {
|
||||
throw new Error(`Unknown MCP client target: ${targetId}`);
|
||||
}
|
||||
|
||||
if (target.isToml) {
|
||||
configureTomlTarget(target);
|
||||
} else {
|
||||
configureJsonTarget(target);
|
||||
}
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
configPath: target.configPath,
|
||||
configured: true,
|
||||
restartHint: `Please restart ${target.name} for the MCP configuration to take effect.`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SERVER_NAME,
|
||||
buildTargets,
|
||||
configureTarget,
|
||||
getTargetStatuses,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const DEFAULTS = {
|
||||
host: '127.0.0.1',
|
||||
port: 8765,
|
||||
toolProfile: 'core',
|
||||
autostart: true,
|
||||
maxInteractionLogEntries: 50,
|
||||
};
|
||||
|
||||
function getProjectPath() {
|
||||
if (global.Editor && Editor.Project && typeof Editor.Project.path === 'string' && Editor.Project.path) {
|
||||
return Editor.Project.path;
|
||||
}
|
||||
return process.cwd();
|
||||
}
|
||||
|
||||
function getProjectName() {
|
||||
return path.basename(getProjectPath());
|
||||
}
|
||||
|
||||
function getCocosVersion() {
|
||||
if (global.Editor && Editor.App) {
|
||||
if (typeof Editor.App.version === 'string' && Editor.App.version) {
|
||||
return Editor.App.version;
|
||||
}
|
||||
if (typeof Editor.App.ver === 'string' && Editor.App.ver) {
|
||||
return Editor.App.ver;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function loadJson(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
return {
|
||||
__error: `Failed to parse config file '${filePath}': ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function clampPort(value) {
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return DEFAULTS.port;
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function normalizeProfile(value) {
|
||||
return String(value || DEFAULTS.toolProfile).toLowerCase() === 'full' ? 'full' : 'core';
|
||||
}
|
||||
|
||||
function loadConfig() {
|
||||
const projectPath = getProjectPath();
|
||||
const configPath = path.join(projectPath, 'funplay-cocos-mcp.config.json');
|
||||
const fileConfig = loadJson(configPath) || {};
|
||||
|
||||
return {
|
||||
...DEFAULTS,
|
||||
...fileConfig,
|
||||
host: process.env.COCOS_MCP_HOST || fileConfig.host || DEFAULTS.host,
|
||||
port: clampPort(process.env.COCOS_MCP_PORT || fileConfig.port || DEFAULTS.port),
|
||||
toolProfile: normalizeProfile(process.env.COCOS_MCP_PROFILE || fileConfig.toolProfile || DEFAULTS.toolProfile),
|
||||
autostart: typeof fileConfig.autostart === 'boolean' ? fileConfig.autostart : DEFAULTS.autostart,
|
||||
maxInteractionLogEntries: Number.isInteger(fileConfig.maxInteractionLogEntries)
|
||||
? Math.max(10, Math.min(500, fileConfig.maxInteractionLogEntries))
|
||||
: DEFAULTS.maxInteractionLogEntries,
|
||||
configPath,
|
||||
configError: fileConfig.__error || '',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULTS,
|
||||
getProjectPath,
|
||||
getProjectName,
|
||||
getCocosVersion,
|
||||
loadConfig,
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
function exists(filePath) {
|
||||
try {
|
||||
return fs.existsSync(filePath);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findTypescriptBinary(projectPath) {
|
||||
const tscName = process.platform === 'win32' ? 'tsc.cmd' : 'tsc';
|
||||
const possibleRoots = [
|
||||
global.Editor && Editor.App && Editor.App.path,
|
||||
process.resourcesPath,
|
||||
global.Editor && Editor.App && Editor.App.path ? path.dirname(Editor.App.path) : '',
|
||||
global.Editor && Editor.App && Editor.App.path ? path.resolve(Editor.App.path, '..') : '',
|
||||
].filter(Boolean);
|
||||
|
||||
const editorBundledCandidates = [];
|
||||
for (const root of possibleRoots) {
|
||||
editorBundledCandidates.push(
|
||||
path.join(root, 'resources', '3d', 'engine', 'node_modules', '.bin', tscName),
|
||||
path.join(root, 'resources', '3d', 'engine', 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'resources', '3d', 'engine', 'node_modules', '@cocos', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'app.asar.unpacked', 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', '.bin', tscName),
|
||||
path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
path.join(root, 'Contents', 'Resources', 'resources', '3d', 'engine', 'node_modules', '@cocos', 'typescript', 'bin', 'tsc')
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(projectPath, 'node_modules', '.bin', tscName),
|
||||
path.join(projectPath, 'node_modules', 'typescript', 'bin', 'tsc'),
|
||||
...editorBundledCandidates,
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.includes(path.sep) && exists(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
if (!candidate.includes(path.sep)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
}
|
||||
|
||||
function findTsConfig(projectPath, explicitPath) {
|
||||
if (explicitPath) {
|
||||
return path.isAbsolute(explicitPath) ? explicitPath : path.join(projectPath, explicitPath);
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
path.join(projectPath, 'tsconfig.json'),
|
||||
path.join(projectPath, 'temp', 'tsconfig.cocos.json'),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => exists(candidate)) || '';
|
||||
}
|
||||
|
||||
function runExec(file, args, cwd) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(file, args, { cwd, maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
code: error && typeof error.code === 'number' ? error.code : 0,
|
||||
stdout: stdout || '',
|
||||
stderr: stderr || '',
|
||||
error: error ? error.message : '',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseTscOutput(output) {
|
||||
const lines = String(output || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const diagnostics = [];
|
||||
const regex = /^(.*)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.*)$/i;
|
||||
for (const line of lines) {
|
||||
const match = regex.exec(line);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
diagnostics.push({
|
||||
file: match[1],
|
||||
line: Number(match[2]),
|
||||
column: Number(match[3]),
|
||||
code: match[4],
|
||||
message: match[5],
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
async function runScriptDiagnostics(projectPath, options = {}) {
|
||||
const tsconfigPath = findTsConfig(projectPath, options.tsconfigPath);
|
||||
if (!tsconfigPath || !exists(tsconfigPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
tool: 'typescript',
|
||||
summary: 'No tsconfig.json was found in the Cocos project.',
|
||||
diagnostics: [],
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
};
|
||||
}
|
||||
|
||||
const binary = findTypescriptBinary(projectPath);
|
||||
const args = binary.endsWith('npx') || binary.endsWith('npx.cmd')
|
||||
? ['tsc', '--noEmit', '-p', tsconfigPath, '--pretty', 'false']
|
||||
: ['--noEmit', '-p', tsconfigPath, '--pretty', 'false'];
|
||||
|
||||
const result = await runExec(binary, args, projectPath);
|
||||
const mergedOutput = [result.stdout, result.stderr, result.error].filter(Boolean).join('\n').trim();
|
||||
const diagnostics = parseTscOutput(mergedOutput);
|
||||
const ok = result.code === 0 && diagnostics.length === 0;
|
||||
|
||||
return {
|
||||
ok,
|
||||
tool: 'typescript',
|
||||
binary,
|
||||
tsconfigPath,
|
||||
exitCode: result.code,
|
||||
summary: ok
|
||||
? 'TypeScript diagnostics completed successfully with no errors.'
|
||||
: diagnostics.length
|
||||
? `Found ${diagnostics.length} TypeScript error(s).`
|
||||
: mergedOutput || 'TypeScript diagnostics reported a non-zero exit code.',
|
||||
diagnostics,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runScriptDiagnostics,
|
||||
};
|
||||
@@ -0,0 +1,281 @@
|
||||
'use strict';
|
||||
|
||||
function getElectron() {
|
||||
try {
|
||||
return require('electron');
|
||||
} catch (error) {
|
||||
throw new Error(`Electron APIs are unavailable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getAllWindows() {
|
||||
const electron = getElectron();
|
||||
const BrowserWindow = electron.BrowserWindow;
|
||||
if (!BrowserWindow || typeof BrowserWindow.getAllWindows !== 'function') {
|
||||
throw new Error('Electron BrowserWindow API is unavailable.');
|
||||
}
|
||||
|
||||
return BrowserWindow.getAllWindows().filter((window) => window && !window.isDestroyed());
|
||||
}
|
||||
|
||||
function inferWindowKind(title) {
|
||||
const normalized = normalizeText(title);
|
||||
if (normalized.includes('simulator')) {
|
||||
return 'simulator';
|
||||
}
|
||||
if (normalized.includes('preview')) {
|
||||
return 'preview';
|
||||
}
|
||||
if (normalized.includes('cocos creator') || normalized.includes('cocos')) {
|
||||
return 'editor';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function listWindows() {
|
||||
return getAllWindows().map((window, index) => ({
|
||||
index,
|
||||
id: typeof window.id === 'number' ? window.id : index,
|
||||
title: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
bounds: typeof window.getBounds === 'function' ? window.getBounds() : null,
|
||||
visible: typeof window.isVisible === 'function' ? window.isVisible() : true,
|
||||
focused: typeof window.isFocused === 'function' ? window.isFocused() : false,
|
||||
kind: inferWindowKind(typeof window.getTitle === 'function' ? window.getTitle() : ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function pickWindow(options = {}) {
|
||||
const electron = getElectron();
|
||||
const BrowserWindow = electron.BrowserWindow;
|
||||
const windows = getAllWindows();
|
||||
if (!windows.length) {
|
||||
throw new Error('No Electron windows are available.');
|
||||
}
|
||||
|
||||
const titleContains = normalizeText(options.titleContains);
|
||||
const windowKind = normalizeText(options.windowKind || 'focused');
|
||||
const focusedWindow = BrowserWindow.getFocusedWindow && BrowserWindow.getFocusedWindow();
|
||||
|
||||
const titleMatches = (window) => {
|
||||
if (!titleContains) {
|
||||
return true;
|
||||
}
|
||||
return normalizeText(window.getTitle && window.getTitle()).includes(titleContains);
|
||||
};
|
||||
|
||||
const kindMatches = (window) => {
|
||||
const kind = inferWindowKind(window.getTitle && window.getTitle());
|
||||
switch (windowKind) {
|
||||
case 'focused':
|
||||
return true;
|
||||
case 'editor':
|
||||
case 'simulator':
|
||||
case 'preview':
|
||||
return kind === windowKind;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const candidates = windows.filter((window) => kindMatches(window) && titleMatches(window));
|
||||
const target = (focusedWindow && candidates.includes(focusedWindow) && focusedWindow)
|
||||
|| candidates.find((window) => typeof window.isVisible === 'function' ? window.isVisible() : true)
|
||||
|| candidates[0]
|
||||
|| windows[0];
|
||||
|
||||
if (!target) {
|
||||
throw new Error(`No BrowserWindow matched windowKind='${windowKind}' titleContains='${titleContains}'.`);
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
async function executeJavaScript(window, script) {
|
||||
if (!window || !window.webContents || typeof window.webContents.executeJavaScript !== 'function') {
|
||||
throw new Error('Target window does not support webContents.executeJavaScript.');
|
||||
}
|
||||
return await window.webContents.executeJavaScript(script, true);
|
||||
}
|
||||
|
||||
function buildPanelBoundsScript(panelName) {
|
||||
const panel = JSON.stringify(String(panelName || 'scene'));
|
||||
return `
|
||||
(() => {
|
||||
const panelName = ${panel}.toLowerCase();
|
||||
const results = [];
|
||||
|
||||
const isVisible = (element) => {
|
||||
if (!element || typeof element.getBoundingClientRect !== 'function') return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 4 && rect.height > 4;
|
||||
};
|
||||
|
||||
const textOf = (element) => {
|
||||
const parts = [
|
||||
element.getAttribute && element.getAttribute('name'),
|
||||
element.getAttribute && element.getAttribute('title'),
|
||||
element.id,
|
||||
element.className,
|
||||
element.textContent,
|
||||
];
|
||||
return parts.filter(Boolean).join(' ').toLowerCase();
|
||||
};
|
||||
|
||||
const collectAll = (root, bucket) => {
|
||||
if (!root) return;
|
||||
const nodes = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
||||
for (const node of nodes) {
|
||||
bucket.push(node);
|
||||
if (node.shadowRoot) {
|
||||
collectAll(node.shadowRoot, bucket);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const all = [];
|
||||
collectAll(document, all);
|
||||
|
||||
const scoreElement = (element) => {
|
||||
const text = textOf(element);
|
||||
let score = 0;
|
||||
if (text.includes(panelName)) score += 100;
|
||||
if (text.includes('panel-frame')) score += 5;
|
||||
if (panelName === 'scene' && text.includes('game')) score -= 10;
|
||||
if (panelName === 'game' && text.includes('scene')) score -= 10;
|
||||
return score;
|
||||
};
|
||||
|
||||
const largestChildRect = (element) => {
|
||||
const bucket = [element];
|
||||
if (element.shadowRoot) collectAll(element.shadowRoot, bucket);
|
||||
const children = bucket
|
||||
.filter((node) => isVisible(node))
|
||||
.map((node) => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
const text = textOf(node);
|
||||
let score = rect.width * rect.height;
|
||||
if (node.tagName && node.tagName.toLowerCase() === 'canvas') score += 50000;
|
||||
if (text.includes(panelName)) score += 20000;
|
||||
if (text.includes('canvas') || text.includes('preview') || text.includes('viewport')) score += 10000;
|
||||
return { rect, score, tag: node.tagName || '', text };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
return children[0] || null;
|
||||
};
|
||||
|
||||
const candidates = all
|
||||
.filter((element) => isVisible(element))
|
||||
.map((element) => {
|
||||
const panelScore = scoreElement(element);
|
||||
if (panelScore <= 0) return null;
|
||||
const rectInfo = largestChildRect(element) || { rect: element.getBoundingClientRect(), score: 0, tag: element.tagName || '', text: textOf(element) };
|
||||
return {
|
||||
score: panelScore + rectInfo.score,
|
||||
elementTag: element.tagName || '',
|
||||
rect: rectInfo.rect,
|
||||
text: textOf(element),
|
||||
innerTag: rectInfo.tag,
|
||||
innerText: rectInfo.text,
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const fallbackCanvases = all
|
||||
.filter((element) => isVisible(element) && element.tagName && element.tagName.toLowerCase() === 'canvas')
|
||||
.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const text = textOf(element);
|
||||
let score = rect.width * rect.height;
|
||||
if (text.includes(panelName)) score += 50000;
|
||||
return { score, rect, elementTag: 'CANVAS', text, innerTag: 'CANVAS', innerText: text };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const target = candidates[0] || fallbackCanvases[0];
|
||||
if (!target) return null;
|
||||
|
||||
return {
|
||||
x: Math.max(0, Math.floor(target.rect.left)),
|
||||
y: Math.max(0, Math.floor(target.rect.top)),
|
||||
width: Math.max(1, Math.floor(target.rect.width)),
|
||||
height: Math.max(1, Math.floor(target.rect.height)),
|
||||
elementTag: target.elementTag,
|
||||
innerTag: target.innerTag,
|
||||
text: target.text,
|
||||
innerText: target.innerText,
|
||||
};
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function getPanelBounds(window, panelName) {
|
||||
const result = await executeJavaScript(window, buildPanelBoundsScript(panelName));
|
||||
if (!result || !result.width || !result.height) {
|
||||
throw new Error(`Could not locate a visible '${panelName}' panel in the target window.`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPanelFocusScript(panelName, offsetX, offsetY) {
|
||||
return `
|
||||
(() => {
|
||||
const panelName = ${JSON.stringify(String(panelName || 'scene').toLowerCase())};
|
||||
const offsetX = ${Number(offsetX || 0)};
|
||||
const offsetY = ${Number(offsetY || 0)};
|
||||
const isVisible = (element) => {
|
||||
if (!element || typeof element.getBoundingClientRect !== 'function') return false;
|
||||
const style = window.getComputedStyle(element);
|
||||
const rect = element.getBoundingClientRect();
|
||||
return style && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 4 && rect.height > 4;
|
||||
};
|
||||
const textOf = (element) => [
|
||||
element.getAttribute && element.getAttribute('name'),
|
||||
element.getAttribute && element.getAttribute('title'),
|
||||
element.id,
|
||||
element.className,
|
||||
element.textContent,
|
||||
].filter(Boolean).join(' ').toLowerCase();
|
||||
const collectAll = (root, bucket) => {
|
||||
if (!root) return;
|
||||
const nodes = root.querySelectorAll ? root.querySelectorAll('*') : [];
|
||||
for (const node of nodes) {
|
||||
bucket.push(node);
|
||||
if (node.shadowRoot) collectAll(node.shadowRoot, bucket);
|
||||
}
|
||||
};
|
||||
const all = [];
|
||||
collectAll(document, all);
|
||||
const target = all.find((element) => isVisible(element) && textOf(element).includes(panelName));
|
||||
const rect = target ? target.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
|
||||
const x = Math.floor(rect.left + rect.width / 2 + offsetX);
|
||||
const y = Math.floor(rect.top + rect.height / 2 + offsetY);
|
||||
const focusable = document.elementFromPoint(x, y) || target || document.body;
|
||||
if (focusable && typeof focusable.focus === 'function') focusable.focus();
|
||||
return { x, y };
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function getPanelPoint(window, panelName, offsetX, offsetY) {
|
||||
return await executeJavaScript(window, buildPanelFocusScript(panelName, offsetX, offsetY));
|
||||
}
|
||||
|
||||
function sleep(milliseconds) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
executeJavaScript,
|
||||
getAllWindows,
|
||||
getPanelBounds,
|
||||
getPanelPoint,
|
||||
listWindows,
|
||||
pickWindow,
|
||||
sleep,
|
||||
};
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
'use strict';
|
||||
|
||||
const { getPanelPoint, listWindows, pickWindow, sleep } = require('./electron-tools');
|
||||
|
||||
function normalizeButton(button) {
|
||||
const value = String(button || 'left').toLowerCase();
|
||||
return ['left', 'right', 'middle'].includes(value) ? value : 'left';
|
||||
}
|
||||
|
||||
function normalizeModifiers(modifiers) {
|
||||
return Array.isArray(modifiers) ? modifiers.map((item) => String(item)) : [];
|
||||
}
|
||||
|
||||
async function focusTarget(window, panel, x, y) {
|
||||
if (typeof window.focus === 'function') {
|
||||
window.focus();
|
||||
}
|
||||
if (panel) {
|
||||
return await getPanelPoint(window, panel, x, y);
|
||||
}
|
||||
return { x: Math.floor(x || 0), y: Math.floor(y || 0) };
|
||||
}
|
||||
|
||||
async function resolvePoint(window, panel, x, y) {
|
||||
if (panel) {
|
||||
return await getPanelPoint(window, panel, x, y);
|
||||
}
|
||||
return { x: Math.floor(x || 0), y: Math.floor(y || 0) };
|
||||
}
|
||||
|
||||
async function sendMouseClick(options = {}) {
|
||||
const window = pickWindow(options);
|
||||
const point = await focusTarget(window, options.panel, options.x, options.y);
|
||||
const button = normalizeButton(options.button);
|
||||
const clickCount = Number.isFinite(options.clickCount) ? Math.max(1, options.clickCount) : 1;
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'mouseMove',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
modifiers,
|
||||
});
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'mouseDown',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
window.webContents.sendInputEvent({
|
||||
type: 'mouseUp',
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
button,
|
||||
clickCount,
|
||||
modifiers,
|
||||
});
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
type: 'mouse_click',
|
||||
point,
|
||||
button,
|
||||
clickCount,
|
||||
windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendMouseDrag(options = {}) {
|
||||
const window = pickWindow(options);
|
||||
const start = await focusTarget(window, options.panel, options.startX, options.startY);
|
||||
const end = await resolvePoint(window, options.panel, options.endX ?? 0, options.endY ?? 0);
|
||||
const steps = Number.isFinite(options.steps) ? Math.max(1, Math.min(60, options.steps)) : 10;
|
||||
const button = normalizeButton(options.button);
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', x: start.x, y: start.y, button, modifiers });
|
||||
window.webContents.sendInputEvent({ type: 'mouseDown', x: start.x, y: start.y, button, clickCount: 1, modifiers });
|
||||
for (let step = 1; step <= steps; step += 1) {
|
||||
const x = Math.round(start.x + ((end.x - start.x) * step) / steps);
|
||||
const y = Math.round(start.y + ((end.y - start.y) * step) / steps);
|
||||
window.webContents.sendInputEvent({ type: 'mouseMove', x, y, button, modifiers });
|
||||
if (options.stepDelayMs) {
|
||||
await sleep(options.stepDelayMs);
|
||||
}
|
||||
}
|
||||
window.webContents.sendInputEvent({ type: 'mouseUp', x: end.x, y: end.y, button, clickCount: 1, modifiers });
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
type: 'mouse_drag',
|
||||
from: start,
|
||||
to: end,
|
||||
steps,
|
||||
windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendKeyPress(options = {}) {
|
||||
const window = pickWindow(options);
|
||||
if (typeof window.focus === 'function') {
|
||||
window.focus();
|
||||
}
|
||||
if (options.panel) {
|
||||
await getPanelPoint(window, options.panel, 0, 0);
|
||||
}
|
||||
|
||||
const keyCode = String(options.keyCode || '').trim();
|
||||
if (!keyCode) {
|
||||
throw new Error('keyCode is required.');
|
||||
}
|
||||
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers });
|
||||
if (options.text) {
|
||||
window.webContents.sendInputEvent({ type: 'char', keyCode: String(options.text), modifiers });
|
||||
}
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers });
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
type: 'key_press',
|
||||
keyCode,
|
||||
modifiers,
|
||||
windowTitle: typeof window.getTitle === 'function' ? window.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function sendKeyCombo(options = {}) {
|
||||
const modifiers = normalizeModifiers(options.modifiers);
|
||||
const keyCode = String(options.keyCode || '').trim();
|
||||
if (!keyCode) {
|
||||
throw new Error('keyCode is required.');
|
||||
}
|
||||
return await sendKeyPress({
|
||||
...options,
|
||||
keyCode,
|
||||
modifiers,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listWindows,
|
||||
sendKeyCombo,
|
||||
sendKeyPress,
|
||||
sendMouseClick,
|
||||
sendMouseDrag,
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
class InteractionLog {
|
||||
constructor(limit = 200) {
|
||||
this.limit = limit;
|
||||
this.entries = [];
|
||||
}
|
||||
|
||||
add(toolName, status, summary) {
|
||||
this.entries.unshift({
|
||||
toolName,
|
||||
status,
|
||||
summary,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (this.entries.length > this.limit) {
|
||||
this.entries.length = this.limit;
|
||||
}
|
||||
}
|
||||
|
||||
list(limit = 20) {
|
||||
return this.entries.slice(0, Math.max(1, limit));
|
||||
}
|
||||
|
||||
summary(limit = 20) {
|
||||
const items = this.list(limit);
|
||||
if (!items.length) {
|
||||
return 'No MCP interactions recorded yet.';
|
||||
}
|
||||
|
||||
return items
|
||||
.map((entry) => `[${entry.timestamp}] ${entry.status.toUpperCase()} ${entry.toolName}: ${entry.summary}`)
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InteractionLog,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
'use strict';
|
||||
|
||||
class PromptProvider {
|
||||
constructor(getRuntimeContext) {
|
||||
this.getRuntimeContext = getRuntimeContext;
|
||||
}
|
||||
|
||||
listPrompts() {
|
||||
const { projectName } = this.getRuntimeContext();
|
||||
return [
|
||||
this.createPrompt('fix_script_errors', `Use execute_javascript first to diagnose and repair current Cocos script problems in '${projectName}'.`),
|
||||
this.createPrompt('create_playable_prototype', `Use execute_javascript first to build a playable Cocos prototype in '${projectName}' from a short idea.`),
|
||||
this.createPrompt('scene_validation', `Use execute_javascript first to validate a scene change in '${projectName}' with hierarchy checks and focused inspection.`),
|
||||
this.createPrompt('auto_wire_scene', `Use execute_javascript first to inspect a target setup in '${projectName}' and wire missing scene relationships.`),
|
||||
];
|
||||
}
|
||||
|
||||
getPrompt(name) {
|
||||
const { projectName, projectPath } = this.getRuntimeContext();
|
||||
let text = '';
|
||||
|
||||
switch (name) {
|
||||
case 'fix_script_errors':
|
||||
text = 'Prefer `execute_javascript` first: use `context="editor"` for diagnostics, filesystem edits, and asset-db workflows, and `context="scene"` when runtime or scene validation is needed. Run script diagnostics, inspect source snippets for each error, patch the smallest safe regions with focused file edits, refresh assets, and verify the project returns to a healthy state.';
|
||||
break;
|
||||
case 'create_playable_prototype':
|
||||
text = 'Prefer `execute_javascript` as the primary tool: use `context="scene"` for node/component/runtime orchestration and `context="editor"` for editor-side automation. Create a playable Cocos prototype from the provided idea. Build scene nodes, scripts, prefabs, UI, camera, animation hooks, helper controls, and verify the result with runtime state and screenshots.';
|
||||
break;
|
||||
case 'scene_validation':
|
||||
text = 'Prefer `execute_javascript` as the first tool, usually with `context="scene"`. Inspect the active scene, verify hierarchy, nodes, components, prefab instances, cameras, animations, runtime state, screenshots, and targeted checks.';
|
||||
break;
|
||||
case 'auto_wire_scene':
|
||||
text = 'Prefer `execute_javascript` as the first tool, using `context="scene"` for hierarchy and component repair and `context="editor"` for file or asset-side repair. Inspect the target node structure, identify missing scene references, UI children, camera/animation setup, or expected children, and repair them with the smallest safe change.';
|
||||
break;
|
||||
default:
|
||||
text = `Prompt not found: ${name}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const fullText = `Target Cocos project: ${projectName}\nProject path: ${projectPath}\n\n${text}`;
|
||||
return {
|
||||
description: fullText,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: fullText,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
createPrompt(name, description) {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
arguments: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PromptProvider,
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
|
||||
const { runScriptDiagnostics } = require('./diagnostics');
|
||||
|
||||
function createResource(uri, name, description) {
|
||||
return { uri, name, description, mimeType: 'text/plain' };
|
||||
}
|
||||
|
||||
function createTemplate(uriTemplate, name, description) {
|
||||
return { uriTemplate, name, description, mimeType: 'text/plain' };
|
||||
}
|
||||
|
||||
function truncate(text, limit = 12000) {
|
||||
if (typeof text !== 'string') {
|
||||
return text;
|
||||
}
|
||||
if (text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return `${text.slice(0, limit)}\n... (truncated)`;
|
||||
}
|
||||
|
||||
function toText(value) {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function summarizeSelection() {
|
||||
if (!global.Editor || !Editor.Selection || typeof Editor.Selection.getSelected !== 'function') {
|
||||
return 'Selection API is unavailable in this Cocos environment.';
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedNode = Editor.Selection.getSelected('node');
|
||||
const selectedAsset = Editor.Selection.getSelected('asset');
|
||||
return [
|
||||
`Selected node: ${selectedNode || '(none)'}`,
|
||||
`Selected asset: ${selectedAsset || '(none)'}`,
|
||||
].join('\n');
|
||||
} catch (error) {
|
||||
return `Failed to inspect current selection: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceProvider {
|
||||
constructor(getRuntimeContext, sceneBridge, interactionLog) {
|
||||
this.getRuntimeContext = getRuntimeContext;
|
||||
this.sceneBridge = sceneBridge;
|
||||
this.interactionLog = interactionLog;
|
||||
}
|
||||
|
||||
listResources() {
|
||||
const { projectName } = this.getRuntimeContext();
|
||||
return [
|
||||
createResource('cocos://project/context', `${projectName} Project Context`, 'Live Cocos project context summary.'),
|
||||
createResource('cocos://project/summary', `${projectName} Project Summary`, 'Project path, folder summary, and asset overview.'),
|
||||
createResource('cocos://scene/active', `${projectName} Active Scene`, 'Summary of the active Cocos scene.'),
|
||||
createResource('cocos://scene/current', `${projectName} Current Scene`, 'Alias of the active Cocos scene summary.'),
|
||||
createResource('cocos://selection/current', `${projectName} Current Selection`, 'Summary of the current editor selection.'),
|
||||
createResource('cocos://selection/asset', `${projectName} Selected Asset`, 'Details for the currently selected asset.'),
|
||||
createResource('cocos://errors/scripts', `${projectName} Script Diagnostics`, 'Latest TypeScript diagnostic summary for the project.'),
|
||||
createResource('cocos://mcp/interactions', `${projectName} MCP Interactions`, 'Recent MCP tool interaction summaries.'),
|
||||
];
|
||||
}
|
||||
|
||||
listResourceTemplates() {
|
||||
return [
|
||||
createTemplate('cocos://scene/node/{path}', 'Scene Node', 'Inspect a scene node by hierarchy path.'),
|
||||
createTemplate('cocos://asset/path/{relative_path}', 'Asset By Path', 'Read a script or text asset by project-relative path.'),
|
||||
createTemplate('cocos://asset/info/{uuid_or_path}', 'Asset Info', 'Inspect an asset by uuid, db url, or path.'),
|
||||
];
|
||||
}
|
||||
|
||||
async readResource(uri) {
|
||||
const text = await this.resolveResourceText(uri);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
uri,
|
||||
mimeType: 'text/plain',
|
||||
text,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async resolveResourceText(uri) {
|
||||
const { projectName, projectPath, cocosVersion, version, config } = this.getRuntimeContext();
|
||||
|
||||
switch (uri) {
|
||||
case 'cocos://project/context':
|
||||
return [
|
||||
'Funplay Cocos MCP Project Context',
|
||||
`Project: ${projectName}`,
|
||||
`Project Path: ${projectPath}`,
|
||||
`Cocos Creator: ${cocosVersion}`,
|
||||
`Extension Version: ${version}`,
|
||||
`Tool Profile: ${config.toolProfile}`,
|
||||
`Server: http://${config.host}:${config.port}/`,
|
||||
'',
|
||||
'Selection',
|
||||
summarizeSelection(),
|
||||
'',
|
||||
'Active Scene',
|
||||
await this.safeSceneCall('getSceneInfo', { maxDepth: 2 }),
|
||||
].join('\n');
|
||||
case 'cocos://project/summary':
|
||||
return this.buildProjectSummary(projectPath, cocosVersion, version);
|
||||
case 'cocos://scene/active':
|
||||
case 'cocos://scene/current':
|
||||
return await this.safeSceneCall('getSceneInfo', { maxDepth: 3 });
|
||||
case 'cocos://selection/current':
|
||||
return summarizeSelection();
|
||||
case 'cocos://selection/asset':
|
||||
return await this.getSelectedAssetText();
|
||||
case 'cocos://errors/scripts':
|
||||
return await this.getScriptDiagnosticsText(projectPath);
|
||||
case 'cocos://mcp/interactions':
|
||||
return this.interactionLog.summary();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (uri.startsWith('cocos://scene/node/')) {
|
||||
const nodePath = decodeURIComponent(uri.slice('cocos://scene/node/'.length));
|
||||
return await this.safeSceneCall('inspectNode', { path: nodePath });
|
||||
}
|
||||
|
||||
if (uri.startsWith('cocos://asset/path/')) {
|
||||
const relativePath = decodeURIComponent(uri.slice('cocos://asset/path/'.length));
|
||||
return this.readAssetByPath(relativePath);
|
||||
}
|
||||
|
||||
if (uri.startsWith('cocos://asset/info/')) {
|
||||
const uuidOrPath = decodeURIComponent(uri.slice('cocos://asset/info/'.length));
|
||||
return await this.getAssetInfoText(uuidOrPath);
|
||||
}
|
||||
|
||||
return `Resource not found: ${uri}`;
|
||||
}
|
||||
|
||||
buildProjectSummary(projectPath, cocosVersion, version) {
|
||||
const topLevel = fs.existsSync(projectPath)
|
||||
? fs
|
||||
.readdirSync(projectPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
: [];
|
||||
|
||||
const assetsDir = path.join(projectPath, 'assets');
|
||||
const scriptCount = this.countFiles(assetsDir, ['.ts', '.js']);
|
||||
const prefabCount = this.countFiles(assetsDir, ['.prefab']);
|
||||
const sceneCount = this.countFiles(assetsDir, ['.scene']);
|
||||
|
||||
return [
|
||||
'Project Summary',
|
||||
`Project Root: ${projectPath}`,
|
||||
`Assets Path: ${assetsDir}`,
|
||||
`Cocos Creator: ${cocosVersion}`,
|
||||
`Extension Version: ${version}`,
|
||||
`Scripts: ${scriptCount}`,
|
||||
`Prefabs: ${prefabCount}`,
|
||||
`Scenes: ${sceneCount}`,
|
||||
'',
|
||||
`Top-Level Directories (${topLevel.length})`,
|
||||
...topLevel.map((name) => `- ${name}`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
countFiles(rootDir, extensions) {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
const stack = [rootDir];
|
||||
while (stack.length) {
|
||||
const current = stack.pop();
|
||||
const entries = fs.readdirSync(current, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (extensions.includes(path.extname(entry.name).toLowerCase())) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
readAssetByPath(relativePath) {
|
||||
const { projectPath } = this.getRuntimeContext();
|
||||
const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath);
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
return `Asset not found: ${relativePath}`;
|
||||
}
|
||||
return truncate(`[${relativePath}]\n${fs.readFileSync(fullPath, 'utf8')}`);
|
||||
}
|
||||
|
||||
async safeSceneCall(method, payload) {
|
||||
try {
|
||||
const result = await this.sceneBridge.call(method, payload);
|
||||
return toText(result);
|
||||
} catch (error) {
|
||||
return `Scene bridge error (${method}): ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getSelectedAssetText() {
|
||||
try {
|
||||
const selection = getCurrentSelection();
|
||||
if (!selection.asset) {
|
||||
return 'No asset is currently selected.';
|
||||
}
|
||||
|
||||
return await this.getAssetInfoText(selection.asset);
|
||||
} catch (error) {
|
||||
return `Selected asset lookup failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getAssetInfoText(uuidOrPath) {
|
||||
try {
|
||||
const info = await queryAssetInfo(uuidOrPath);
|
||||
const meta = await queryAssetMeta(uuidOrPath).catch(() => null);
|
||||
return JSON.stringify({ info, meta }, null, 2);
|
||||
} catch (error) {
|
||||
return `Asset lookup failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async getScriptDiagnosticsText(projectPath) {
|
||||
try {
|
||||
const result = await runScriptDiagnostics(projectPath);
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch (error) {
|
||||
return `Script diagnostics failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ResourceProvider,
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { getPanelBounds, pickWindow } = require('./electron-tools');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function exec(file, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, { maxBuffer: 8 * 1024 * 1024 }, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(stderr || stdout || error.message));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function captureDesktopScreenshot(projectPath, options = {}) {
|
||||
const outputDir = path.join(projectPath, 'temp', 'mcp-captures');
|
||||
ensureDir(outputDir);
|
||||
const filePath = path.join(outputDir, options.fileName || `desktop-${Date.now()}.png`);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
await exec('screencapture', ['-x', filePath]);
|
||||
} else if (process.platform === 'win32') {
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
||||
$bitmap = New-Object System.Drawing.Bitmap $bounds.Width, $bounds.Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size)
|
||||
$bitmap.Save('${filePath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
`;
|
||||
await exec('powershell', ['-NoProfile', '-Command', script]);
|
||||
} else {
|
||||
try {
|
||||
await exec('gnome-screenshot', ['-f', filePath]);
|
||||
} catch (error) {
|
||||
await exec('import', ['-window', 'root', filePath]);
|
||||
}
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(filePath).toString('base64');
|
||||
return {
|
||||
filePath,
|
||||
dataUri: `data:image/png;base64,${data}`,
|
||||
size: fs.statSync(filePath).size,
|
||||
platform: os.platform(),
|
||||
};
|
||||
}
|
||||
|
||||
async function captureEditorWindowScreenshot(projectPath, options = {}) {
|
||||
const outputDir = path.join(projectPath, 'temp', 'mcp-captures');
|
||||
ensureDir(outputDir);
|
||||
const filePath = path.join(outputDir, options.fileName || `editor-${Date.now()}.png`);
|
||||
|
||||
const target = pickWindow(options);
|
||||
|
||||
const image = await target.capturePage();
|
||||
const png = image.toPNG();
|
||||
fs.writeFileSync(filePath, png);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
dataUri: `data:image/png;base64,${png.toString('base64')}`,
|
||||
size: png.length,
|
||||
title: typeof target.getTitle === 'function' ? target.getTitle() : '',
|
||||
};
|
||||
}
|
||||
|
||||
async function capturePanelScreenshot(projectPath, options = {}) {
|
||||
const outputDir = path.join(projectPath, 'temp', 'mcp-captures');
|
||||
ensureDir(outputDir);
|
||||
const filePath = path.join(outputDir, options.fileName || `${options.panel || 'panel'}-${Date.now()}.png`);
|
||||
|
||||
const target = pickWindow(options);
|
||||
const bounds = await getPanelBounds(target, options.panel || 'scene');
|
||||
const image = await target.capturePage({
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
const png = image.toPNG();
|
||||
fs.writeFileSync(filePath, png);
|
||||
|
||||
return {
|
||||
filePath,
|
||||
dataUri: `data:image/png;base64,${png.toString('base64')}`,
|
||||
size: png.length,
|
||||
title: typeof target.getTitle === 'function' ? target.getTitle() : '',
|
||||
bounds,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureDesktopScreenshot,
|
||||
captureEditorWindowScreenshot,
|
||||
capturePanelScreenshot,
|
||||
};
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
|
||||
const LOG_PREFIX = '[Funplay Cocos MCP Server]';
|
||||
|
||||
function json(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function textContent(value) {
|
||||
if (typeof value === 'string' && value.startsWith(IMAGE_DATA_URI_PREFIX)) {
|
||||
return [
|
||||
{
|
||||
type: 'image',
|
||||
data: value.slice(IMAGE_DATA_URI_PREFIX.length),
|
||||
mimeType: 'image/png',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Screenshot captured successfully.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'text',
|
||||
text: typeof value === 'string' ? value : JSON.stringify(value, null, 2),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
class McpServer {
|
||||
constructor(options) {
|
||||
this.config = options.config;
|
||||
this.toolRegistry = options.toolRegistry;
|
||||
this.resourceProvider = options.resourceProvider;
|
||||
this.promptProvider = options.promptProvider;
|
||||
this.interactionLog = options.interactionLog;
|
||||
this.serverName = options.serverName;
|
||||
this.serverVersion = options.serverVersion;
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
isRunning() {
|
||||
return Boolean(this.server && this.server.listening);
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.isRunning()) {
|
||||
console.log(`${LOG_PREFIX} Start skipped: already running.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`);
|
||||
this.server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
if (request.method === 'GET' && request.url === '/health') {
|
||||
console.log(`${LOG_PREFIX} GET /health`);
|
||||
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion });
|
||||
}
|
||||
|
||||
if (request.method !== 'POST') {
|
||||
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
|
||||
return json(response, 405, { error: 'Method Not Allowed' });
|
||||
}
|
||||
|
||||
const body = await this.readBody(request);
|
||||
if (!body) {
|
||||
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'));
|
||||
}
|
||||
|
||||
const rpc = JSON.parse(body);
|
||||
if (rpc && rpc.method) {
|
||||
console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
|
||||
}
|
||||
const result = await this.handleRpcRequest(rpc);
|
||||
if (result == null) {
|
||||
response.writeHead(204);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
return json(response, 200, result);
|
||||
} catch (error) {
|
||||
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
|
||||
return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
this.server.once('error', reject);
|
||||
this.server.listen(this.config.port, this.config.host, () => {
|
||||
this.server.off('error', reject);
|
||||
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.server) {
|
||||
console.log(`${LOG_PREFIX} Stop skipped: server object is empty.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`${LOG_PREFIX} Closing HTTP server...`);
|
||||
const active = this.server;
|
||||
this.server = null;
|
||||
await new Promise((resolve, reject) => {
|
||||
active.close((error) => {
|
||||
if (error) {
|
||||
console.error(`${LOG_PREFIX} Close failed: ${error.message}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
console.log(`${LOG_PREFIX} HTTP server closed.`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
readBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async handleRpcRequest(request) {
|
||||
if (!request || request.jsonrpc !== '2.0') {
|
||||
return this.createError(request && request.id, -32600, 'Invalid Request');
|
||||
}
|
||||
|
||||
const method = request.method;
|
||||
if (typeof method !== 'string' || !method) {
|
||||
return this.createError(request.id, -32600, 'Invalid Request: method is required');
|
||||
}
|
||||
|
||||
if (method === 'initialize') {
|
||||
return this.createResult(request.id, {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: this.serverName,
|
||||
version: this.serverVersion,
|
||||
},
|
||||
capabilities: {
|
||||
tools: {},
|
||||
resources: {},
|
||||
prompts: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (method === 'notifications/initialized' || method === 'notifications/cancelled' || method.startsWith('notifications/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (method === 'tools/list') {
|
||||
return this.createResult(request.id, { tools: this.toolRegistry.listTools() });
|
||||
}
|
||||
|
||||
if (method === 'tools/call') {
|
||||
const params = request.params || {};
|
||||
if (typeof params.name !== 'string' || !params.name) {
|
||||
return this.createError(request.id, -32602, "Invalid params: 'name' is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const output = await this.toolRegistry.callTool(params.name, params.arguments || {});
|
||||
return this.createResult(request.id, { content: textContent(output) });
|
||||
} catch (error) {
|
||||
return this.createError(request.id, -32603, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'resources/list') {
|
||||
return this.createResult(request.id, { resources: this.resourceProvider.listResources() });
|
||||
}
|
||||
|
||||
if (method === 'resources/read') {
|
||||
const params = request.params || {};
|
||||
if (typeof params.uri !== 'string' || !params.uri) {
|
||||
return this.createError(request.id, -32602, "Invalid params: 'uri' is required");
|
||||
}
|
||||
return this.createResult(request.id, await this.resourceProvider.readResource(params.uri));
|
||||
}
|
||||
|
||||
if (method === 'resources/templates/list') {
|
||||
return this.createResult(request.id, { resourceTemplates: this.resourceProvider.listResourceTemplates() });
|
||||
}
|
||||
|
||||
if (method === 'prompts/list') {
|
||||
return this.createResult(request.id, { prompts: this.promptProvider.listPrompts() });
|
||||
}
|
||||
|
||||
if (method === 'prompts/get') {
|
||||
const params = request.params || {};
|
||||
if (typeof params.name !== 'string' || !params.name) {
|
||||
return this.createError(request.id, -32602, "Invalid params: 'name' is required");
|
||||
}
|
||||
return this.createResult(request.id, this.promptProvider.getPrompt(params.name, params.arguments || {}));
|
||||
}
|
||||
|
||||
return this.createError(request.id, -32601, `Method not found: ${method}`);
|
||||
}
|
||||
|
||||
createResult(id, result) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
createError(id, code, message) {
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id,
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
McpServer,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
'use strict';
|
||||
|
||||
function safeJsonParse(text, fallback = null) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeStringify(value) {
|
||||
const seen = new WeakSet();
|
||||
return JSON.stringify(
|
||||
value,
|
||||
(key, current) => {
|
||||
if (typeof current === 'object' && current !== null) {
|
||||
if (seen.has(current)) {
|
||||
return '[Circular]';
|
||||
}
|
||||
seen.add(current);
|
||||
}
|
||||
return current;
|
||||
},
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
safeJsonParse,
|
||||
safeStringify,
|
||||
};
|
||||
Reference in New Issue
Block a user