chore: 纳入项目全部源码进根仓库统一管理

删除 YouleNexus 内嵌 .git,统一由根仓库唯一管理。提交:
- projects/ 旧 H5 模板 Game_Surface_3 与各子游戏源码
- cocoscreator_projects/YouleNexus 工程 + cocos-creator-mcp 扩展源码
- CLAUDE.md、.mcp.json 等配置
library/temp/node_modules/dist 等缓存由 .gitignore 排除。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 11:55:13 +08:00
co-authored by Claude Opus 4.8
parent f5673c33c8
commit 7ab748919a
7317 changed files with 2810014 additions and 0 deletions
@@ -0,0 +1,44 @@
import fs from "fs";
import path from "path";
const HOURS_24 = 24 * 60 * 60 * 1000;
/**
* 24時間以上前のファイルを OLD_yyyyMM フォルダに移動する.
*/
export function archiveOldFiles(dir: string): void {
try {
let files: string[];
try {
files = fs.readdirSync(dir);
} catch {
return;
}
const now = Date.now();
let archived = 0;
for (const file of files) {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) continue;
if (now - stat.mtimeMs < HOURS_24) continue;
const fileDate = new Date(stat.mtimeMs);
const yyyy = fileDate.getFullYear();
const mm = String(fileDate.getMonth() + 1).padStart(2, "0");
const archiveDir = path.join(dir, `OLD_${yyyy}${mm}`);
fs.mkdirSync(archiveDir, { recursive: true });
fs.renameSync(filePath, path.join(archiveDir, file));
archived++;
}
if (archived > 0) {
console.log(`[cocos-creator-mcp] ${archived}件のファイルを月別フォルダにアーカイブ (${dir})`);
}
} catch (e) {
console.warn("[cocos-creator-mcp] アーカイブ失敗:", e);
}
}
@@ -0,0 +1,137 @@
import { McpServer } from "./mcp-server";
import { SceneTools } from "./tools/scene-tools";
import { NodeTools } from "./tools/node-tools";
import { ComponentTools } from "./tools/component-tools";
import { PrefabTools } from "./tools/prefab-tools";
import { ProjectTools } from "./tools/project-tools";
import { DebugTools } from "./tools/debug-tools";
import { SceneAdvancedTools } from "./tools/scene-advanced-tools";
import { SceneViewTools } from "./tools/scene-view-tools";
import { AssetTools } from "./tools/asset-tools";
import { PreferencesTools } from "./tools/preferences-tools";
import { ServerTools } from "./tools/server-tools";
import { BuilderTools } from "./tools/builder-tools";
import { ReferenceImageTools } from "./tools/reference-image-tools";
import { ServerConfig, DEFAULT_CONFIG } from "./types";
import path from "path";
import fs from "fs";
let server: McpServer | null = null;
function getSettingsPath(): string {
return path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
}
function loadConfig(): ServerConfig {
try {
const p = getSettingsPath();
if (fs.existsSync(p)) {
const data = JSON.parse(fs.readFileSync(p, "utf-8"));
return { ...DEFAULT_CONFIG, ...data };
}
} catch (e) {
console.warn("[cocos-creator-mcp] Failed to load settings, using defaults");
}
return { ...DEFAULT_CONFIG };
}
function saveConfig(config: ServerConfig): void {
try {
const p = getSettingsPath();
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(config, null, 2), "utf-8");
} catch (e) {
console.error("[cocos-creator-mcp] Failed to save settings:", e);
}
}
function createServer(config: ServerConfig): McpServer {
const s = new McpServer(config);
s.register(new SceneTools());
s.register(new NodeTools());
const componentTools = new ComponentTools();
s.register(componentTools);
s.register(new PrefabTools(componentTools));
s.register(new ProjectTools());
s.register(new DebugTools());
s.register(new SceneAdvancedTools());
s.register(new SceneViewTools());
s.register(new AssetTools());
s.register(new PreferencesTools());
s.register(new ServerTools());
s.register(new BuilderTools());
s.register(new ReferenceImageTools());
return s;
}
export const methods: Record<string, (...args: any[]) => any> = {
openPanel() {
Editor.Panel.open("cocos-creator-mcp");
},
openRecorder() {
Editor.Panel.open("cocos-creator-mcp.recorder");
},
async startServer() {
if (server?.isRunning) return { running: true, port: server.port };
const config = loadConfig();
server = createServer(config);
await server.start();
return { running: true, port: server.port };
},
async stopServer() {
if (server) {
await server.stop();
server = null;
}
return { running: false };
},
async updatePort(_port: number) {
const config = loadConfig();
config.port = _port;
saveConfig(config);
// Restart server if running
if (server?.isRunning) {
await server.stop();
server = createServer(config);
await server.start();
}
return { port: _port, running: server?.isRunning ?? false };
},
getServerStatus() {
return {
running: server?.isRunning ?? false,
port: server?.port ?? loadConfig().port,
toolCount: server?.getAllTools().length ?? 0,
toolNames: server?.getAllTools().map((t) => t.name).sort() ?? [],
};
},
};
export async function load() {
console.log("[cocos-creator-mcp] Extension loaded");
const config = loadConfig();
server = createServer(config);
if (config.autoStart) {
try {
await server.start();
console.log(`[cocos-creator-mcp] Auto-started on port ${config.port}`);
} catch (e) {
console.error("[cocos-creator-mcp] Auto-start failed:", e);
}
}
}
export async function unload() {
if (server) {
await server.stop();
server = null;
}
console.log("[cocos-creator-mcp] Extension unloaded");
}
@@ -0,0 +1,542 @@
import http from "http";
import { ToolCategory, ToolDefinition, JsonRpcRequest, JsonRpcResponse, ServerConfig, DEFAULT_CONFIG } from "./types";
import { archiveOldFiles } from "./archive";
const MCP_PROTOCOL_VERSION = "2024-11-05";
const SESSION_ID = `cocos-mcp-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
/** ビルド時にコードベースのSHA256ハッシュが埋め込まれる */
export const BUILD_HASH = "__BUILD_HASH__";
// ─── Game Preview Log Buffer ───
interface GameLogEntry {
timestamp: string;
level: "log" | "warn" | "error";
message: string;
}
const MAX_GAME_LOG_BUFFER = 500;
const _gameLogs: GameLogEntry[] = [];
/** Access game preview log buffer from debug-tools */
export function getGameLogs(count: number, level?: string): { logs: GameLogEntry[]; total: number } {
let logs = _gameLogs;
if (level) {
logs = logs.filter(l => l.level === level);
}
return { logs: logs.slice(-count), total: _gameLogs.length };
}
export function clearGameLogs(): void {
_gameLogs.length = 0;
}
// ─── Game Debug Command Queue ───
interface GameCommand {
id: string;
type: string;
args?: any;
timestamp: string;
}
interface GameCommandResult {
id: string;
success: boolean;
data?: any;
error?: string;
timestamp: string;
}
let _pendingCommand: GameCommand | null = null;
let _commandResult: GameCommandResult | null = null;
let _commandIdCounter = 0;
/** Queue a command for the game to execute */
export function queueGameCommand(type: string, args?: any): string {
const id = `cmd_${++_commandIdCounter}_${Date.now()}`;
_pendingCommand = { id, type, args, timestamp: new Date().toISOString() };
_commandResult = null;
return id;
}
/** Get the result of the last command (poll until available) */
export function getCommandResult(): GameCommandResult | null {
return _commandResult;
}
/** Clear command state */
export function clearCommandState(): void {
_pendingCommand = null;
_commandResult = null;
}
// ─── Recording Storage ───
interface RecordingInfo {
path: string;
size: number;
createdAt: string;
}
const _recordings = new Map<string, RecordingInfo>();
/** Get completed recording info by id */
export function getRecording(id: string): RecordingInfo | undefined {
return _recordings.get(id);
}
export function setRecording(id: string, info: RecordingInfo): void {
_recordings.set(id, info);
}
export class McpServer {
private server: http.Server | null = null;
private tools: Map<string, ToolCategory> = new Map();
private toolIndex: Map<string, ToolCategory> = new Map(); // toolName -> category
private config: ServerConfig;
constructor(config?: Partial<ServerConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/** Register a tool category */
register(category: ToolCategory): void {
this.tools.set(category.categoryName, category);
for (const tool of category.getTools()) {
this.toolIndex.set(tool.name, category);
}
}
/** Get all tool definitions */
getAllTools(): ToolDefinition[] {
const all: ToolDefinition[] = [];
for (const cat of this.tools.values()) {
all.push(...cat.getTools());
}
return all;
}
/** Start HTTP server */
start(): Promise<void> {
return new Promise((resolve, reject) => {
if (this.server) {
resolve();
return;
}
this.server = http.createServer((req, res) => this.handleRequest(req, res));
this.server.listen(this.config.port, "127.0.0.1", () => {
console.log(`[cocos-creator-mcp] Server started on http://127.0.0.1:${this.config.port}/mcp`);
resolve();
});
this.server.on("error", (e) => {
console.error(`[cocos-creator-mcp] Server error:`, e);
reject(e);
});
});
}
/** Stop HTTP server */
stop(): Promise<void> {
return new Promise((resolve) => {
if (!this.server) {
resolve();
return;
}
this.server.close(() => {
this.server = null;
console.log("[cocos-creator-mcp] Server stopped");
resolve();
});
});
}
get isRunning(): boolean {
return this.server !== null;
}
get port(): number {
return this.config.port;
}
private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
// CORS
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = req.url || "/";
const origin = `http://127.0.0.1:${this.config.port}`;
// ─── OAuth endpoints (MCP spec 2025-06-18 / RFC 9728 / RFC 8414 / RFC 7591) ───
//
// Claude Code の VSCode 拡張は HTTP トランスポートの MCP サーバーに対して
// 無条件で OAuth discovery / DCR を試みる (#26917 等の既知バグ)。
// cocos-creator-mcp は localhost-only のローカル開発ツールで本物の認証は不要だが、
// クライアントを満足させるため OAuth エンドポイント群をダミー実装して常時許可する。
//
// TODO: 以下のいずれかが発生したら削除する
// 1. anthropics/claude-code #26917 / #38102 等の HTTP OAuth バグが修正される
// 2. 本物の認証機構を実装する必要が出る(偽 OAuth と衝突するため)
// 3. MCP spec が PKCE 検証・トークンローテーション必須等に更新される
// 4. stdio ブリッジが十分定着して HTTP transport 自体を deprecate する
// RFC 9728 Protected Resource Metadata
if (url === "/.well-known/oauth-protected-resource" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
resource: `${origin}/mcp`,
authorization_servers: [origin],
bearer_methods_supported: ["header"],
scopes_supported: ["mcp"],
}));
return;
}
// RFC 8414 Authorization Server Metadata
if (url === "/.well-known/oauth-authorization-server" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
issuer: origin,
authorization_endpoint: `${origin}/oauth/authorize`,
token_endpoint: `${origin}/oauth/token`,
registration_endpoint: `${origin}/oauth/register`,
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256", "plain"],
token_endpoint_auth_methods_supported: ["none"],
scopes_supported: ["mcp"],
}));
return;
}
// RFC 7591 Dynamic Client Registration — accept anything, return dummy client
if (url === "/oauth/register" && req.method === "POST") {
const body = await readBody(req);
let reg: any = {};
try { reg = JSON.parse(body); } catch { /* ignore */ }
const clientId = `cocos-mcp-client-${Date.now()}`;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
client_id: clientId,
client_id_issued_at: Math.floor(Date.now() / 1000),
client_name: reg.client_name || "cocos-creator-mcp client",
redirect_uris: reg.redirect_uris || [],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code"],
response_types: ["code"],
}));
return;
}
// OAuth authorization endpoint — auto-consent, redirect immediately with code
if (url.startsWith("/oauth/authorize") && req.method === "GET") {
const parsed = new URL(url, origin);
const redirectUri = parsed.searchParams.get("redirect_uri") || "";
const state = parsed.searchParams.get("state") || "";
if (!redirectUri) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "invalid_request", error_description: "redirect_uri required" }));
return;
}
const code = `cocos-mcp-code-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const location = `${redirectUri}${redirectUri.includes("?") ? "&" : "?"}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
res.writeHead(302, { Location: location });
res.end();
return;
}
// OAuth token endpoint — always issue a dummy token
if (url === "/oauth/token" && req.method === "POST") {
await readBody(req); // drain
res.writeHead(200, {
"Content-Type": "application/json",
"Cache-Control": "no-store",
});
res.end(JSON.stringify({
access_token: "cocos-mcp-public-token",
token_type: "Bearer",
expires_in: 86400,
scope: "mcp",
}));
return;
}
// Health check
if (url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", tools: this.getAllTools().length }));
return;
}
// Game debug command queue — game polls for commands
if (url === "/game/command" && req.method === "GET") {
const cmd = _pendingCommand;
_pendingCommand = null; // consume
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(cmd));
return;
}
// Game debug command result — game posts result
if (url === "/game/result" && req.method === "POST") {
const body = await readBody(req);
try {
_commandResult = JSON.parse(body);
} catch { /* ignore */ }
res.writeHead(204);
res.end();
return;
}
// Game preview recording receiver
if (url === "/game/recording" && req.method === "POST") {
const body = await readBody(req);
try {
const { id, base64, mimeType, savePath } = JSON.parse(body);
if (!id || !base64) throw new Error("id/base64 required");
const fs = require("fs");
const path = require("path");
const buffer = Buffer.from(base64, "base64");
// savePath指定があればそこに保存(絶対パスまたはプロジェクト相対パス)
const projectPath = (global as any).Editor?.Project?.path
|| process.cwd();
let dir: string;
if (savePath) {
dir = path.isAbsolute(savePath) ? savePath : path.join(projectPath, savePath);
} else {
dir = path.join(projectPath, "temp", "recordings");
}
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const mt = (mimeType || "").toLowerCase();
const ext = mt.includes("webm") ? "webm"
: mt.includes("mp4") ? "mp4"
: "bin";
const fileName = `${id}.${ext}`;
const filePath = path.join(dir, fileName);
fs.writeFileSync(filePath, buffer);
setRecording(id, {
path: filePath,
size: buffer.length,
createdAt: new Date().toISOString(),
});
if (this.config.autoArchiveRecordings) {
archiveOldFiles(dir);
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true, path: filePath, size: buffer.length }));
} catch (e: any) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: false, error: e.message }));
}
return;
}
// Game preview log receiver
if (url === "/log" && req.method === "POST") {
const body = await readBody(req);
try {
const entries: GameLogEntry[] = JSON.parse(body);
for (const entry of (Array.isArray(entries) ? entries : [entries])) {
_gameLogs.push({
timestamp: entry.timestamp || new Date().toISOString(),
level: entry.level || "log",
message: entry.message || "",
});
// __debug_state__ ログから userId を debug-menu.json に保存
try {
const msg = JSON.parse(entry.message || "");
if (msg.__debug_state__ && msg.userId) {
const _fs = require("fs");
const _path = require("path");
const projectPath = (global as any).Editor?.Project?.path || process.cwd();
const settingsPath = _path.join(projectPath, "settings", "debug-menu.json");
_fs.writeFileSync(settingsPath, JSON.stringify({ userId: msg.userId }, null, 2), "utf-8");
}
} catch { /* not debug_state */ }
}
if (_gameLogs.length > MAX_GAME_LOG_BUFFER) {
_gameLogs.splice(0, _gameLogs.length - MAX_GAME_LOG_BUFFER);
}
} catch { /* ignore malformed */ }
res.writeHead(204);
res.end();
return;
}
// MCP endpoint
if (url === "/mcp") {
if (req.method === "GET") {
// SSE keepalive stream
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
// Send initial comment to keep connection alive
res.write(": connected\n\n");
return;
}
if (req.method === "POST") {
await this.handleMcpPost(req, res);
return;
}
if (req.method === "DELETE") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
}
// 404
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found" }));
}
private async handleMcpPost(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
const body = await readBody(req);
let rpc: JsonRpcRequest;
try {
rpc = JSON.parse(body);
} catch {
this.sendJsonRpc(res, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } });
return;
}
const accept = req.headers["accept"] || "";
const wantSse = accept.includes("text/event-stream");
let response: JsonRpcResponse;
switch (rpc.method) {
case "initialize":
response = {
jsonrpc: "2.0",
id: rpc.id,
result: {
protocolVersion: MCP_PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: {
name: "cocos-creator-mcp",
version: "1.0.0",
},
},
};
break;
case "notifications/initialized":
// No response needed for notification
res.writeHead(204, { "Mcp-Session-Id": SESSION_ID });
res.end();
return;
case "tools/list":
response = {
jsonrpc: "2.0",
id: rpc.id,
result: { tools: this.getAllTools() },
};
break;
case "tools/call": {
const toolName = rpc.params?.name;
const args = rpc.params?.arguments || {};
const category = this.toolIndex.get(toolName);
if (!category) {
response = {
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32602, message: `Unknown tool: ${toolName}` },
};
} else {
try {
const start = Date.now();
console.log(`[cocos-creator-mcp] ▶ ${toolName}`, Object.keys(args).length > 0 ? JSON.stringify(args).substring(0, 200) : "");
const timeoutMs = (toolName.startsWith("prefab_") || toolName === "scene_open") ? 120000 : 30000;
const result = await withTimeout(category.execute(toolName, args), timeoutMs, `Tool ${toolName} timed out`);
console.log(`[cocos-creator-mcp] ✓ ${toolName} (${Date.now() - start}ms)`);
response = {
jsonrpc: "2.0",
id: rpc.id,
result,
};
} catch (e: any) {
console.error(`[cocos-creator-mcp] ✗ ${toolName}:`, e.message || String(e));
response = {
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32603, message: e.message || String(e) },
};
}
}
break;
}
default:
response = {
jsonrpc: "2.0",
id: rpc.id,
error: { code: -32601, message: `Method not found: ${rpc.method}` },
};
}
if (wantSse) {
this.sendSse(res, [response]);
} else {
this.sendJsonRpc(res, response);
}
}
private sendJsonRpc(res: http.ServerResponse, data: JsonRpcResponse): void {
res.writeHead(200, {
"Content-Type": "application/json",
"Mcp-Session-Id": SESSION_ID,
});
res.end(JSON.stringify(data));
}
private sendSse(res: http.ServerResponse, messages: JsonRpcResponse[]): void {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Mcp-Session-Id": SESSION_ID,
});
for (const msg of messages) {
res.write(`event: message\ndata: ${JSON.stringify(msg)}\n\n`);
}
res.end();
}
}
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(message)), ms);
promise.then(
(v) => { clearTimeout(timer); resolve(v); },
(e) => { clearTimeout(timer); reject(e); },
);
});
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
req.on("error", reject);
});
}
@@ -0,0 +1,36 @@
/**
* Shared node UUID resolution — resolves nodeName to UUID via scene script.
*/
const EXT_NAME = "cocos-creator-mcp";
export interface ResolvedNode {
uuid: string;
name: string;
}
/**
* Resolve a node UUID from either `uuid` or `nodeName` parameter.
* If `uuid` is provided, it is returned as-is.
* If `nodeName` is provided, searches the scene for a matching node.
* Throws if neither is provided or no node is found.
*/
export async function resolveNodeUuid(args: { uuid?: string; nodeName?: string }): Promise<ResolvedNode> {
if (args.uuid) {
return { uuid: args.uuid, name: "" };
}
if (!args.nodeName) {
throw new Error("Either 'uuid' or 'nodeName' is required");
}
const result = await Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method: "findNodesByName",
args: [args.nodeName],
});
if (!result?.success || !result?.data?.length) {
throw new Error(`Node not found: "${args.nodeName}"`);
}
// 最初のマッチを使用
const node = result.data[0];
return { uuid: node.uuid, name: node.name || args.nodeName };
}
@@ -0,0 +1,117 @@
const { createApp } = require("vue");
const panelDataMap = new WeakMap<any, any>();
module.exports = Editor.Panel.define({
template: `
<div id="app">
<h2>Cocos Creator MCP</h2>
<div class="status">
<span>Status: <strong :class="running ? 'on' : 'off'">{{ running ? 'Running' : 'Stopped' }}</strong></span>
</div>
<div class="port-row">
<label>Port:</label>
<input type="number" v-model.number="editPort" :disabled="running" min="1024" max="65535" />
<ui-button v-if="!running && editPort !== port" @confirm="applyPort" class="small-btn">Apply</ui-button>
</div>
<div class="actions">
<ui-button v-if="!running" @confirm="start">Start Server</ui-button>
<ui-button v-if="running" @confirm="stop">Stop Server</ui-button>
</div>
<div v-if="running" class="info">
<p>Endpoint: <code>http://127.0.0.1:{{ port }}/mcp</code></p>
<p>Tools: <strong>{{ toolCount }}</strong></p>
</div>
<div v-if="error" class="error">{{ error }}</div>
</div>
`,
style: `
#app { padding: 12px; font-family: sans-serif; color: #ccc; }
h2 { margin: 0 0 8px 0; font-size: 16px; }
.status { margin: 8px 0; }
.on { color: #4f4; }
.off { color: #f66; }
.port-row { margin: 8px 0; display: flex; align-items: center; gap: 8px; }
.port-row label { font-size: 12px; }
.port-row input { width: 80px; padding: 3px 6px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; font-size: 12px; }
.port-row input:disabled { opacity: 0.5; }
.actions { margin: 8px 0; }
.info { margin: 8px 0; padding: 8px; background: var(--color-normal-fill-emphasis); border-radius: 4px; }
.info p { margin: 4px 0; font-size: 12px; }
.info code { background: #333; padding: 2px 6px; border-radius: 3px; font-size: 11px; }
.error { margin: 8px 0; color: #f66; font-size: 12px; }
`,
$: { app: "#app" },
ready() {
if (!this.$.app) return;
const app = createApp({
data() {
return {
running: false,
port: 3000,
editPort: 3000,
toolCount: 0,
error: "",
};
},
methods: {
async start(this: any) {
try {
this.error = "";
const result = await Editor.Message.request("cocos-creator-mcp", "start-server");
this.running = result.running;
this.port = result.port;
this.editPort = result.port;
await this.refresh();
} catch (e: any) {
this.error = e.message || String(e);
}
},
async stop(this: any) {
try {
await Editor.Message.request("cocos-creator-mcp", "stop-server");
this.running = false;
this.toolCount = 0;
} catch (e: any) {
this.error = e.message || String(e);
}
},
async applyPort(this: any) {
try {
this.error = "";
const result = await Editor.Message.request("cocos-creator-mcp", "update-port", this.editPort);
this.port = result.port;
this.running = result.running;
if (this.running) await this.refresh();
} catch (e: any) {
this.error = e.message || String(e);
}
},
async refresh(this: any) {
try {
const status = await Editor.Message.request("cocos-creator-mcp", "get-server-status");
this.running = status.running;
this.port = status.port;
this.editPort = status.port;
this.toolCount = status.toolCount;
} catch (e: any) {
console.warn("[cocos-creator-mcp] refresh failed:", e);
}
},
},
async mounted(this: any) {
try {
await this.refresh();
} catch (e) {
console.warn("[cocos-creator-mcp] mounted refresh failed:", e);
}
},
});
app.mount(this.$.app);
panelDataMap.set(this, app);
},
close() {
const app = panelDataMap.get(this);
if (app) app.unmount();
},
});
@@ -0,0 +1,540 @@
const { createApp: createAppRec } = require("vue");
const panelDataMapRec = new WeakMap<any, any>();
module.exports = Editor.Panel.define({
template: `
<div id="app">
<h2>Preview Recorder</h2>
<div class="controls">
<button v-if="!recording" @click="start" class="btn btn-start">● 録画開始</button>
<button v-else @click="stop" class="btn btn-stop" :disabled="stopping">■ 録画停止{{ stopping ? '中...' : '' }}</button>
<button @click="screenshot" class="btn btn-shot" :disabled="shooting">📸 スクショ{{ shooting ? '中...' : '' }}</button>
</div>
<div v-if="recording" class="status-row">
<span class="rec-dot">●</span> REC <strong>{{ elapsed }}s</strong>
<span class="info">{{ recordingInfo }}</span>
</div>
<div class="section-title">録画設定</div>
<div class="row">
<label>FPS:</label>
<input type="number" v-model.number="fps" :disabled="recording" min="10" max="60" />
<label>形式:</label>
<select v-model="format" :disabled="recording">
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
</div>
<div class="row">
<label>品質:</label>
<select v-model="quality" @change="onQualityChange" :disabled="recording">
<option value="low">低</option>
<option value="medium">中</option>
<option value="high">高</option>
<option value="ultra">最高</option>
<option value="custom">カスタム</option>
</select>
<label title="ビットレート = 幅 × 高さ × FPS × 品質係数">品質係数:</label>
<input type="number" v-model.number="coefficient" @input="onCoefChange"
:disabled="recording" min="0.01" max="2" step="0.01" class="custom-bitrate"
title="ビットレート = 幅 × 高さ × FPS × 品質係数" />
<button @click="resetQuality" class="btn btn-small" :disabled="recording" title="録画設定を初期値に戻す">↺</button>
</div>
<div class="section-title">スクショ設定</div>
<div class="row">
<label>形式:</label>
<select v-model="shotFormat" :disabled="shooting">
<option value="png">PNG</option>
<option value="webp">WebP</option>
</select>
</div>
<div class="section-title">保存先</div>
<div class="row">
<input type="text" v-model="savePath" :disabled="recording" class="path-input" placeholder="temp/recordings" />
<button @click="selectSaveFolder" class="btn btn-small" :disabled="recording">📁 選択</button>
<button @click="resetSavePath" class="btn btn-small" :disabled="recording" title="保存先を初期値に戻す">↺</button>
</div>
<div class="row">
<button @click="openSaveFolder" class="btn btn-small">📂 保存フォルダを開く</button>
<label class="checkbox-label" title="ONにすると、録画/スクショ保存時に24時間以上前のファイルを OLD_yyyyMM フォルダに自動移動します"><input type="checkbox" v-model="autoArchive" @change="onAutoArchiveChange" /> 古いファイルを自動整理</label>
<button class="btn btn-help" @click="showArchiveHelp = !showArchiveHelp" title="自動整理の説明">?</button>
</div>
<div v-if="showArchiveHelp" class="help-box">
<strong>自動整理について</strong><br>
ONにすると、録画やスクショを保存するたびに、24時間以上前の古いファイルを<br>
<code>OLD_yyyyMM/</code> フォルダ(例: OLD_202604/)に自動で移動します。<br>
保存フォルダ直下には直近のファイルだけが残り、整理された状態を保てます。
</div>
<div v-if="lastResult" class="result" :class="lastError ? 'error' : 'success'">
<div v-if="!lastError">
<strong>✓ {{ lastResult.kind === 'shot' ? 'スクショ保存' : '録画完了' }}</strong><br>
<code>{{ lastResult.path }}</code><br>
{{ (lastResult.size / 1024).toFixed(1) }} KB
</div>
<div v-else>
<strong>✗ エラー:</strong> {{ lastResult.error || lastResult.message || 'unknown' }}
</div>
</div>
<div class="note">
※ ゲームプレビュー実行中に使ってください(GameDebugClientが必要)
</div>
</div>
`,
style: `
#app { padding: 16px; font-family: sans-serif; color: #ccc; font-size: 12px; }
h2 { margin: 0 0 12px 0; font-size: 18px; }
.controls { margin: 12px 0; }
.btn {
padding: 10px 20px;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
}
.btn:disabled { background: #555; color: #999; cursor: not-allowed; }
.btn-start { background: #d44; }
.btn-start:hover { background: #e55; }
.btn-stop { background: #888; }
.btn-stop:hover { background: #999; }
.btn-shot { background: #468; margin-left: 8px; }
.btn-shot:hover { background: #579; }
.section-title {
margin-top: 14px;
margin-bottom: 4px;
padding: 4px 0 3px 0;
font-size: 11px;
font-weight: bold;
color: #8af;
border-bottom: 1px solid #333;
}
.btn-small {
padding: 4px 10px;
background: #4a8;
font-size: 11px;
margin-left: 8px;
font-weight: normal;
}
.btn-small:hover { background: #5b9; }
.status-row { margin: 10px 0; font-size: 14px; }
.rec-dot { color: #f44; animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: 0.3; } }
.info { color: #888; font-size: 11px; margin-left: 8px; }
.row { margin: 10px 0; display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
.row label { font-size: 12px; }
.row input { width: 60px; padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
.row select { padding: 4px 8px; background: #222; color: #ccc; border: 1px solid #444; border-radius: 3px; }
.path-input { flex: 1; min-width: 200px; width: auto !important; font-family: monospace; }
.custom-bitrate { width: 70px !important; }
.unit { font-size: 11px; color: #888; }
.result { margin: 12px 0; padding: 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; }
.result.success { background: #1a3a1a; color: #afa; }
.result.error { background: #3a1a1a; color: #faa; }
.result code { font-size: 10px; word-break: break-all; background: #000; padding: 2px 4px; border-radius: 2px; }
.checkbox-label { font-size: 12px; display: flex; align-items: center; gap: 4px; margin-left: auto; cursor: pointer; }
.checkbox-label input[type="checkbox"] { cursor: pointer; }
.btn-help { padding: 2px 7px; background: #555; font-size: 11px; font-weight: bold; border-radius: 50%; min-width: 20px; margin-left: 4px; }
.btn-help:hover { background: #777; }
.help-box { margin: 8px 0; padding: 10px; background: #1a2a3a; border: 1px solid #345; border-radius: 4px; font-size: 11px; line-height: 1.6; color: #bcd; }
.help-box code { background: #000; padding: 1px 4px; border-radius: 2px; font-size: 10px; }
.note { margin-top: 16px; padding-top: 8px; border-top: 1px solid #333; font-size: 10px; color: #888; }
`,
$: { app: "#app" },
ready() {
if (!this.$.app) return;
const MCP_BASE = "http://127.0.0.1:3000";
const STORAGE_KEY = "cocos-mcp-recorder-settings";
const PERSISTED_KEYS = ["fps", "quality", "coefficient", "format", "savePath", "shotFormat"];
// localStorage から設定を読み込み
const loadSettings = () => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch { return {}; }
};
const saved = loadSettings();
// プロジェクト設定から autoArchiveRecordings を読み込み
const loadProjectConfig = () => {
try {
const fs = require("fs");
const path = require("path");
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8"));
} catch { /* ignore */ }
return {};
};
const projectCfg = loadProjectConfig();
const app = createAppRec({
data() {
return {
recording: false,
stopping: false,
shooting: false,
elapsed: "0.0",
recordingInfo: "",
fps: saved.fps ?? 30,
quality: saved.quality ?? "medium",
coefficient: saved.coefficient ?? 0.25,
format: saved.format ?? "mp4",
savePath: saved.savePath ?? "temp/recordings",
shotFormat: saved.shotFormat ?? "png",
autoArchive: projectCfg.autoArchiveRecordings ?? false,
showArchiveHelp: false,
lastResult: null as any,
lastError: false,
_startTime: 0,
_timer: null as any,
_aliveCheckTimer: null as any,
};
},
watch: {
fps(this: any) { this.saveSettings(); },
quality(this: any) { this.saveSettings(); },
coefficient(this: any) { this.saveSettings(); },
format(this: any) { this.saveSettings(); },
savePath(this: any) { this.saveSettings(); },
shotFormat(this: any) { this.saveSettings(); },
},
methods: {
saveSettings(this: any) {
const data: any = {};
for (const key of PERSISTED_KEYS) data[key] = this[key];
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch { /* ignore */ }
},
async isPreviewRunning(this: any): Promise<boolean> {
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 98, method: "tools/call",
params: {
name: "debug_game_command",
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
return !!parsed?.success;
} catch {
return false;
}
},
async start(this: any) {
this.lastResult = null;
if (!await this.isPreviewRunning()) {
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してから録画してください。" };
this.lastError = true;
return;
}
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "debug_record_start",
arguments: {
fps: this.fps,
coefficient: this.coefficient,
format: this.format,
savePath: this.savePath,
},
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success && parsed.data?.id) {
this.recording = true;
const d = parsed.data;
const mbps = d?.videoBitsPerSecond ? (d.videoBitsPerSecond / 1_000_000).toFixed(2) : "?";
this.recordingInfo = `${d?.canvasWidth || "?"}x${d?.canvasHeight || "?"} @ ${d?.fps || "?"}fps / ${mbps}Mbps / ${d?.mimeType || ""}`;
this._startTime = Date.now();
this._timer = setInterval(() => {
this.elapsed = ((Date.now() - this._startTime) / 1000).toFixed(1);
}, 100);
// プレビュー停止検知用ポーリング(2秒毎)
this._aliveCheckTimer = setInterval(() => {
this.checkPreviewAlive();
}, 2000);
} else {
// 可能な限り詳細なエラー情報を表示
const errDetail = parsed?.data?.error
|| parsed?.error
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|| (parsed ? JSON.stringify(parsed).substring(0, 200) : "no response")
|| "録画開始失敗";
this.lastResult = { error: errDetail };
this.lastError = true;
}
} catch (e: any) {
this.lastResult = { error: `通信エラー: ${e.message}` };
this.lastError = true;
}
},
async stop(this: any) {
this.stopping = true;
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: {
name: "debug_record_stop",
arguments: { timeout: 60000 },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success && parsed.data?.path) {
this.lastResult = { path: parsed.data.path, size: parsed.data.size };
this.lastError = false;
} else {
const errDetail = parsed?.data?.error
|| parsed?.error
|| (parsed?.data ? JSON.stringify(parsed.data) : null)
|| "録画停止失敗";
this.lastResult = { error: errDetail };
this.lastError = true;
}
} catch (e: any) {
this.lastResult = { error: `通信エラー: ${e.message}` };
this.lastError = true;
} finally {
this.recording = false;
this.stopping = false;
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
}
},
onQualityChange(this: any) {
const map: Record<string, number> = { low: 0.15, medium: 0.25, high: 0.40, ultra: 0.60 };
if (this.quality !== "custom") this.coefficient = map[this.quality];
},
onCoefChange(this: any) {
const map: Record<number, string> = { 0.15: "low", 0.25: "medium", 0.40: "high", 0.60: "ultra" };
this.quality = map[this.coefficient] || "custom";
},
resetQuality(this: any) {
this.fps = 30;
this.quality = "medium";
this.coefficient = 0.25;
this.format = "mp4";
},
resetSavePath(this: any) {
this.savePath = "temp/recordings";
},
onAutoArchiveChange(this: any) {
try {
const fs = require("fs");
const path = require("path");
const p = path.join(Editor.Project.path, "settings", "cocos-creator-mcp.json");
let cfg: any = {};
if (fs.existsSync(p)) cfg = JSON.parse(fs.readFileSync(p, "utf-8"));
cfg.autoArchiveRecordings = this.autoArchive;
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(cfg, null, 2), "utf-8");
} catch (e) {
console.warn("[recorder] 設定保存失敗:", e);
}
},
async screenshot(this: any) {
this.shooting = true;
if (!await this.isPreviewRunning()) {
this.lastResult = { error: "ゲームプレビューが実行されていません。プレビューを開始してからスクショしてください。" };
this.lastError = true;
this.shooting = false;
return;
}
try {
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 3,
method: "tools/call",
params: {
name: "debug_game_command",
arguments: { type: "screenshot", args: {}, timeout: 5000, maxWidth: 0, imageFormat: this.shotFormat },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success && parsed.path) {
// savePath 配下にコピー
const fs = require("fs");
const path = require("path");
const projectPath = Editor.Project.path;
let destDir = this.savePath || "temp/recordings";
if (!path.isAbsolute(destDir)) destDir = path.join(projectPath, destDir);
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const ext = path.extname(parsed.path) || ".png";
const destPath = path.join(destDir, `screenshot_${ts}${ext}`);
fs.copyFileSync(parsed.path, destPath);
// 設定に応じて古いファイルをアーカイブ
try {
const settingsPath = path.join(projectPath, "settings", "cocos-creator-mcp.json");
if (fs.existsSync(settingsPath)) {
const cfg = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
if (cfg.autoArchiveRecordings) {
const { archiveOldFiles } = require("../../archive");
archiveOldFiles(destDir);
}
}
} catch { /* ignore */ }
this.lastResult = { kind: "shot", path: destPath, size: parsed.size };
this.lastError = false;
} else {
const errDetail = parsed?.error || parsed?.message || "スクショ失敗";
this.lastResult = { error: errDetail };
this.lastError = true;
}
} catch (e: any) {
this.lastResult = { error: `通信エラー: ${e.message}` };
this.lastError = true;
} finally {
this.shooting = false;
}
},
async checkPreviewAlive(this: any) {
if (!this.recording) return;
try {
// MCP経由で軽量なinspectコマンドを送る
const res = await fetch(`${MCP_BASE}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0", id: 99, method: "tools/call",
params: {
name: "debug_game_command",
arguments: { type: "inspect", args: { name: "Canvas" }, timeout: 1500 },
},
}),
});
const json = await res.json();
const content = json.result?.content?.[0]?.text;
const parsed = content ? JSON.parse(content) : null;
if (parsed?.success) {
// プレビュー生存
return;
}
// 応答なし → プレビュー停止とみなして録画停止状態に
console.warn("[PreviewRecorder] preview not responding, stopping recording state");
this.recording = false;
if (this._timer) { clearInterval(this._timer); this._timer = null; }
if (this._aliveCheckTimer) { clearInterval(this._aliveCheckTimer); this._aliveCheckTimer = null; }
this.lastResult = { error: "プレビューが停止したため録画を中断しました(動画は保存されません)" };
this.lastError = true;
} catch (e) {
// ネットワークエラーは通信エラーとして無視(一時的かもしれない)
}
},
async selectSaveFolder(this: any) {
try {
const result = await (Editor.Dialog as any).select({
title: "保存先フォルダを選択",
type: "directory",
multi: false,
});
if (result?.filePaths?.length) {
const path = require("path");
const projectPath = Editor.Project.path;
const absPath = result.filePaths[0];
// プロジェクト配下なら相対パスで保持
const relPath = path.relative(projectPath, absPath);
if (!relPath.startsWith("..") && !path.isAbsolute(relPath)) {
this.savePath = relPath.replace(/\\/g, "/");
} else {
this.savePath = absPath;
}
}
} catch (e: any) {
console.error("[PreviewRecorder] selectSaveFolder failed:", e);
}
},
openSaveFolder(this: any) {
const path = require("path");
const fs = require("fs");
const projectPath = Editor.Project.path;
let dir = this.savePath || "temp/recordings";
if (!path.isAbsolute(dir)) dir = path.join(projectPath, dir);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
try {
const { spawn } = require("child_process");
const platform = process.platform;
const [cmd, ...args] = platform === "win32"
? ["explorer.exe", dir.replace(/\//g, "\\")]
: platform === "darwin"
? ["open", dir]
: ["xdg-open", dir];
const p = spawn(cmd, args, { detached: true, stdio: "ignore" });
p.unref();
} catch (e: any) {
console.error("[PreviewRecorder] openSaveFolder failed:", e);
this.lastResult = { error: `フォルダを開けませんでした: ${e.message}` };
this.lastError = true;
}
},
openFolder(this: any) {
if (!this.lastResult?.path) return;
const filePath = this.lastResult.path;
try {
const { shell } = require("electron");
if (shell?.showItemInFolder) {
shell.showItemInFolder(filePath);
return;
}
} catch (e) { /* fallback */ }
// フォールバック: OS別コマンド
try {
const { exec } = require("child_process");
const platform = process.platform;
if (platform === "win32") {
exec(`explorer.exe /select,"${filePath.replace(/\//g, "\\")}"`);
} else if (platform === "darwin") {
exec(`open -R "${filePath}"`);
} else {
const dir = require("path").dirname(filePath);
exec(`xdg-open "${dir}"`);
}
} catch (e: any) {
console.error("[PreviewRecorder] openFolder failed:", e);
}
},
},
});
app.mount(this.$.app);
panelDataMapRec.set(this, app);
},
beforeClose() { },
close() {
const app = panelDataMapRec.get(this);
if (app) app.unmount();
panelDataMapRec.delete(this);
},
});
@@ -0,0 +1,509 @@
/**
* Scene script — runs inside CocosCreator's scene renderer process.
* These methods are called via Editor.Message.request('scene', 'execute-scene-script', ...)
* and have access to the `cc` module (engine runtime).
*/
import { join } from "path";
module.paths.push(join(Editor.App.path, "node_modules"));
// ─── Console Log Buffer ───
interface ConsoleLogEntry {
timestamp: string;
level: "log" | "warn" | "error";
message: string;
}
const MAX_LOG_BUFFER = 500;
const _consoleLogs: ConsoleLogEntry[] = [];
const _originalLog = console.log;
const _originalWarn = console.warn;
const _originalError = console.error;
function formatArgs(args: any[]): string {
return args.map(a => {
if (typeof a === "string") return a;
try { return JSON.stringify(a); } catch { return String(a); }
}).join(" ");
}
function pushLog(level: ConsoleLogEntry["level"], args: any[]): void {
_consoleLogs.push({
timestamp: new Date().toISOString(),
level,
message: formatArgs(args),
});
if (_consoleLogs.length > MAX_LOG_BUFFER) {
_consoleLogs.splice(0, _consoleLogs.length - MAX_LOG_BUFFER);
}
}
console.log = function (...args: any[]) {
_originalLog.apply(console, args);
pushLog("log", args);
};
console.warn = function (...args: any[]) {
_originalWarn.apply(console, args);
pushLog("warn", args);
};
console.error = function (...args: any[]) {
_originalError.apply(console, args);
pushLog("error", args);
};
function getScene() {
const { director } = require("cc");
return director.getScene();
}
function findNode(uuid: string) {
const scene = getScene();
if (!scene) return null;
// Recursive search — getChildByUuid is not recursive in cc
const queue = [...scene.children];
while (queue.length > 0) {
const node = queue.shift()!;
if (node.uuid === uuid) return node;
if (node.children) queue.push(...node.children);
}
return null;
}
/**
* Recursively build a node tree from a JSON spec.
* Returns { uuid, name, children: [...] }
*/
function buildNodeRecursive(parent: any, spec: any): any {
const { Node, js, Vec3 } = require("cc");
const node = new Node(spec.name || "Node");
parent.addChild(node);
// Add components
if (spec.components && Array.isArray(spec.components)) {
const { Sprite, Label } = require("cc");
for (const compName of spec.components) {
const CompClass = js.getClassByName(compName);
if (CompClass) {
if (!node.getComponent(CompClass)) {
const comp = node.addComponent(CompClass);
// Sprite: sizeMode=CUSTOM でUITransformサイズの上書きを防ぐ
if (comp instanceof Sprite) {
comp.sizeMode = 0; // SizeMode.CUSTOM
}
// Label: useSystemFont + 色を黒に(白パネル上で見えるように)
if (comp instanceof Label) {
comp.useSystemFont = true;
const { Color } = require("cc");
comp.color = new Color(51, 51, 51, 255);
}
}
}
}
}
// Set component properties
// Format: { "cc.UITransform.contentSize": {width:720, height:1280} }
if (spec.properties) {
for (const [key, value] of Object.entries(spec.properties)) {
const dotIdx = key.lastIndexOf(".");
if (dotIdx < 0) continue;
const compName = key.substring(0, dotIdx);
const propName = key.substring(dotIdx + 1);
const CompClass = js.getClassByName(compName);
if (!CompClass) continue;
const comp = node.getComponent(CompClass);
if (!comp) continue;
try {
// contentSize needs special handling (Size type)
if (propName === "contentSize" && value && typeof value === "object") {
comp.setContentSize((value as any).width ?? 0, (value as any).height ?? 0);
} else {
comp[propName] = value;
}
} catch { /* skip invalid property */ }
}
}
// Set UITransform anchorPoint if specified
if (spec.anchorPoint) {
const { UITransform } = require("cc");
const ut = node.getComponent(UITransform);
if (ut) ut.setAnchorPoint(spec.anchorPoint.x ?? 0.5, spec.anchorPoint.y ?? 0.5);
}
// Set node properties (position, scale, active)
if (spec.active === false) node.active = false;
if (spec.position) node.setPosition(spec.position.x || 0, spec.position.y || 0, spec.position.z || 0);
if (spec.scale) node.setScale(spec.scale.x ?? 1, spec.scale.y ?? 1, spec.scale.z ?? 1);
// Set Widget properties if specified
// Format: { top: 0, bottom: 0, left: 0, right: 0 } — each field enables the corresponding alignment
if (spec.widget) {
const { Widget } = require("cc");
let w = node.getComponent(Widget);
if (!w) w = node.addComponent(Widget);
const wSpec = spec.widget;
if (wSpec.top !== undefined) { w.isAlignTop = true; w.top = wSpec.top; }
if (wSpec.bottom !== undefined) { w.isAlignBottom = true; w.bottom = wSpec.bottom; }
if (wSpec.left !== undefined) { w.isAlignLeft = true; w.left = wSpec.left; }
if (wSpec.right !== undefined) { w.isAlignRight = true; w.right = wSpec.right; }
if (wSpec.horizontalCenter !== undefined) { w.isAlignHorizontalCenter = true; w.horizontalCenter = wSpec.horizontalCenter; }
if (wSpec.verticalCenter !== undefined) { w.isAlignVerticalCenter = true; w.verticalCenter = wSpec.verticalCenter; }
}
// Build children
const childResults: any[] = [];
if (spec.children && Array.isArray(spec.children)) {
for (const childSpec of spec.children) {
childResults.push(buildNodeRecursive(node, childSpec));
}
}
return { uuid: node.uuid, name: node.name, children: childResults };
}
function collectNodeInfo(node: any, includeComponents: boolean = false): any {
const info: any = {
uuid: node.uuid,
name: node.name,
active: node.active,
position: { x: node.position.x, y: node.position.y, z: node.position.z },
scale: { x: node.scale.x, y: node.scale.y, z: node.scale.z },
parent: node.parent?.uuid || null,
childCount: node.children?.length || 0,
};
if (includeComponents && node.components) {
info.components = node.components.map((c: any) => ({
type: c.constructor.name,
uuid: c.uuid,
enabled: c.enabled,
}));
}
return info;
}
export const methods: Record<string, (...args: any[]) => any> = {
getSceneHierarchy(includeComponents: boolean = false) {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const walk = (node: any): any => {
const item: any = {
uuid: node.uuid,
name: node.name,
active: node.active,
children: [],
};
if (includeComponents && node.components) {
item.components = node.components.map((c: any) => ({
type: c.constructor.name,
uuid: c.uuid,
enabled: c.enabled,
}));
}
if (node.children) {
item.children = node.children.map((ch: any) => walk(ch));
}
return item;
};
return {
success: true,
sceneName: scene.name,
sceneUuid: scene.uuid,
hierarchy: scene.children.map((ch: any) => walk(ch)),
};
} catch (e: any) {
return { success: false, error: e.message };
}
},
getNodeInfo(uuid: string) {
try {
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
return { success: true, data: collectNodeInfo(node, true) };
} catch (e: any) {
return { success: false, error: e.message };
}
},
getAllNodes() {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const nodes: any[] = [];
const walk = (node: any) => {
nodes.push(collectNodeInfo(node));
if (node.children) node.children.forEach(walk);
};
scene.children.forEach(walk);
return { success: true, data: nodes };
} catch (e: any) {
return { success: false, error: e.message };
}
},
/**
* 指定ノードの子孫から名前で検索する。auto_bind 用。
* rootUuid が空の場合はシーン全体を検索(後方互換)。
*/
findDescendantsByName(rootUuid: string, name: string) {
try {
let root: any;
if (rootUuid) {
root = findNode(rootUuid);
if (!root) return { success: false, error: `Root node ${rootUuid} not found` };
} else {
root = getScene();
if (!root) return { success: false, error: "No active scene" };
}
const results: any[] = [];
const walk = (node: any) => {
if (node.name === name) results.push(collectNodeInfo(node));
if (node.children) node.children.forEach(walk);
};
if (root.children) root.children.forEach(walk);
return { success: true, data: results };
} catch (e: any) {
return { success: false, error: e.message };
}
},
/**
* 指定ノードの全子孫を depth 付きで返す。auto_bind の一括検索用。
*/
getAllDescendants(rootUuid: string) {
try {
const root = findNode(rootUuid);
if (!root) return { success: false, error: `Node ${rootUuid} not found` };
const results: Array<{uuid: string, name: string, depth: number}> = [];
const walk = (node: any, depth: number) => {
results.push({ uuid: node.uuid, name: node.name, depth });
if (node.children) node.children.forEach((c: any) => walk(c, depth + 1));
};
if (root.children) root.children.forEach((c: any) => walk(c, 1));
return { success: true, data: results };
} catch (e: any) {
return { success: false, error: e.message };
}
},
findNodesByName(name: string) {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const results: any[] = [];
const walk = (node: any) => {
if (node.name === name) results.push(collectNodeInfo(node));
if (node.children) node.children.forEach(walk);
};
scene.children.forEach(walk);
return { success: true, data: results };
} catch (e: any) {
return { success: false, error: e.message };
}
},
findNodeByPath(path: string) {
try {
const scene = getScene();
if (!scene) return { success: false, error: "No active scene" };
const parts = path.split("/");
let current: any = null;
const walk = (node: any): any => {
if (node.name === parts[0]) return node;
if (node.children) {
for (const child of node.children) {
const found = walk(child);
if (found) return found;
}
}
return null;
};
for (const child of scene.children) {
current = walk(child);
if (current) break;
}
if (!current) return { success: false, error: `Node "${parts[0]}" not found` };
for (let i = 1; i < parts.length; i++) {
const child = current.children?.find((c: any) => c.name === parts[i]);
if (!child) return { success: false, error: `Child "${parts[i]}" not found in "${current.name}"` };
current = child;
}
return { success: true, data: collectNodeInfo(current) };
} catch (e: any) {
return { success: false, error: e.message };
}
},
setNodeProperty(uuid: string, property: string, value: any) {
try {
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
switch (property) {
case "position":
node.setPosition(value.x ?? 0, value.y ?? 0, value.z ?? 0);
break;
case "rotation":
node.setRotationFromEuler(value.x ?? 0, value.y ?? 0, value.z ?? 0);
break;
case "scale":
node.setScale(value.x ?? 1, value.y ?? 1, value.z ?? 1);
break;
case "active":
node.active = !!value;
break;
case "name":
node.name = String(value);
break;
default:
(node as any)[property] = value;
}
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
setComponentProperty(uuid: string, componentType: string, property: string, value: any) {
try {
const { js } = require("cc");
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const CompClass = js.getClassByName(componentType);
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
const comp = node.getComponent(CompClass);
if (!comp) return { success: false, error: `Component ${componentType} not on node ${uuid}` };
comp[property] = value;
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
addComponentToNode(uuid: string, componentType: string) {
try {
const { js } = require("cc");
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const CompClass = js.getClassByName(componentType);
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
const comp = node.addComponent(CompClass);
return { success: true, data: { uuid: comp.uuid, type: componentType } };
} catch (e: any) {
return { success: false, error: e.message };
}
},
moveNode(uuid: string, parentUuid: string) {
try {
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const parent = findNode(parentUuid);
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
node.setParent(parent);
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
/**
* Build a node tree from a JSON spec in one call.
* Spec format:
* {
* name: string,
* components?: string[], // e.g. ["cc.UITransform", "cc.Layout"]
* properties?: Record<string, any>, // e.g. { "cc.UITransform.contentSize": {width:720,height:1280} }
* children?: NodeSpec[]
* }
*/
buildNodeTree(parentUuid: string, spec: any) {
try {
const { Node, js, UITransform } = require("cc");
const parent = findNode(parentUuid);
if (!parent) return { success: false, error: `Parent ${parentUuid} not found` };
const result = buildNodeRecursive(parent, spec);
return { success: true, data: result };
} catch (e: any) {
return { success: false, error: e.message };
}
},
testLog(message: string = "test message") {
console.log("[testLog]", message);
const methods = Object.keys(exports.methods || {});
return { success: true, bufferSize: _consoleLogs.length, methods };
},
getConsoleLogs(count: number = 50, level?: string) {
let logs = _consoleLogs;
if (level) {
logs = logs.filter(l => l.level === level);
}
return { success: true, logs: logs.slice(-count), total: _consoleLogs.length };
},
clearConsoleLogs() {
_consoleLogs.length = 0;
return { success: true };
},
async setPropertyViaEditor(nodeUuid: string, path: string, dump: any) {
try {
// scene:set-property API
// uuid: ノードUUID(コンポーネントUUIDではない)
// path: __comps__.{index}.{property} 形式
// dump: { value, type } 形式
const opts = { uuid: nodeUuid, path, dump };
const result = await (Editor as any).Message.request("scene", "set-property", opts);
return { success: true, result };
} catch (e: any) {
return { success: false, error: e.message || String(e) };
}
},
removeComponentFromNode(uuid: string, componentType: string) {
try {
const { js } = require("cc");
const node = findNode(uuid);
if (!node) return { success: false, error: `Node ${uuid} not found` };
const CompClass = js.getClassByName(componentType);
if (!CompClass) return { success: false, error: `Component class ${componentType} not found` };
const comp = node.getComponent(CompClass);
if (!comp) return { success: false, error: `Component ${componentType} not on node` };
node.removeComponent(comp);
return { success: true };
} catch (e: any) {
return { success: false, error: e.message };
}
},
};
@@ -0,0 +1,100 @@
/**
* Shared screenshot utility — used by debug-tools and other tools that need screenshots.
*/
export interface ProcessedImage {
buffer: Buffer;
width: number;
height: number;
format: string;
}
export interface ScreenshotResult {
success: boolean;
path: string;
size: number;
format: string;
originalSize: string;
savedSize: string;
}
export async function processImage(pngBuffer: Buffer, maxWidth: number, desiredFormat?: string): Promise<ProcessedImage> {
try {
const Vips = require("wasm-vips");
const vips = await Vips();
let image = vips.Image.newFromBuffer(pngBuffer);
if (maxWidth > 0 && image.width > maxWidth) {
image = image.thumbnailImage(maxWidth);
}
if (desiredFormat === "png") {
const pngOut = image.pngsaveBuffer();
const result = { buffer: Buffer.from(pngOut), width: image.width, height: image.height, format: "png" };
image.delete();
return result;
}
const outBuf = image.webpsaveBuffer({ Q: 85 });
const result = { buffer: Buffer.from(outBuf), width: image.width, height: image.height, format: "webp" };
image.delete();
return result;
} catch {
// Fallback: NativeImage resize + JPEG
const electron = require("electron");
let image = electron.nativeImage.createFromBuffer(pngBuffer);
if (maxWidth > 0) {
const size = image.getSize();
if (size.width > maxWidth) {
const ratio = maxWidth / size.width;
image = image.resize({ width: Math.round(size.width * ratio), height: Math.round(size.height * ratio) });
}
}
const size = image.getSize();
const buffer = image.toJPEG(85);
return { buffer, width: size.width, height: size.height, format: "jpeg" };
}
}
export async function takeEditorScreenshot(savePath?: string | undefined, maxWidth?: number): Promise<ScreenshotResult> {
const fs = require("fs");
const path = require("path");
const electron = require("electron");
const windows = electron.BrowserWindow.getAllWindows();
if (!windows || windows.length === 0) {
throw new Error("No editor window found");
}
// Find the main (largest) window
let win = windows[0];
let maxArea = 0;
for (const w of windows) {
const bounds = w.getBounds();
const area = bounds.width * bounds.height;
if (area > maxArea) {
maxArea = area;
win = w;
}
}
// Bring to front and wait for render
win.show();
await new Promise(r => setTimeout(r, 300));
const nativeImage = await win.webContents.capturePage();
const originalSize = nativeImage.getSize();
const pngBuffer = nativeImage.toPNG();
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth);
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
if (!savePath) {
const dir = path.join(Editor.Project.tmpDir, "screenshots");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
savePath = path.join(dir, `screenshot_${timestamp}.${ext}`);
}
const finalPath = savePath!;
fs.writeFileSync(finalPath, buffer);
return {
success: true, path: finalPath, size: buffer.length, format,
originalSize: `${originalSize.width}x${originalSize.height}`,
savedSize: `${width}x${height}`,
};
}
@@ -0,0 +1,38 @@
import { ToolResult } from "./types";
/** Helper to create a successful text result */
export function ok(data: any): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
/** Helper to create an error result */
export function err(message: string): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify({ error: message }) }],
isError: true,
};
}
/** Validate that a string looks like a CocosCreator UUID (not empty, reasonable format) */
export function validateUuid(uuid: string, label: string = "uuid"): string | null {
if (!uuid || typeof uuid !== "string") {
return `${label} is required`;
}
if (uuid.trim().length === 0) {
return `${label} cannot be empty`;
}
// CocosCreator UUIDs: either standard format (8-4-4-4-12) or compressed (22 chars with +/=)
// Be permissive — just reject obviously wrong values
if (uuid.length < 10) {
return `${label} "${uuid}" is too short to be a valid UUID`;
}
return null; // valid
}
/** Validate UUID and return err() if invalid, null if valid */
export function checkUuid(uuid: string, label: string = "uuid"): ToolResult | null {
const error = validateUuid(uuid, label);
return error ? err(error) : null;
}
@@ -0,0 +1,290 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class AssetTools implements ToolCategory {
readonly categoryName = "asset";
getTools(): ToolDefinition[] {
return [
{
name: "asset_create",
description: "Create a new asset file in the project.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path for the new asset" },
content: { type: "string", description: "File content (for text-based assets)" },
},
required: ["path"],
},
},
{
name: "asset_delete",
description: "Delete an asset from the project.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset to delete" },
},
required: ["path"],
},
},
{
name: "asset_move",
description: "Move/rename an asset.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Current db:// path" },
destination: { type: "string", description: "New db:// path" },
},
required: ["source", "destination"],
},
},
{
name: "asset_copy",
description: "Copy an asset to a new location.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Source db:// path" },
destination: { type: "string", description: "Destination db:// path" },
},
required: ["source", "destination"],
},
},
{
name: "asset_save",
description: "Save an asset by UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_reimport",
description: "Re-import an asset to refresh it.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset" },
},
required: ["path"],
},
},
{
name: "asset_query_path",
description: "Get the file path for an asset UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_query_uuid",
description: "Get the UUID for an asset path.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset" },
},
required: ["path"],
},
},
{
name: "asset_query_url",
description: "Get the URL for an asset UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_get_details",
description: "Get detailed metadata for an asset (type, size, importer, etc.).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_get_dependencies",
description: "Get all assets that a given asset depends on.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "asset_open_external",
description: "Open an asset in the system's default external editor.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path of the asset" },
},
required: ["path"],
},
},
{
name: "asset_import",
description: "Import an external file into the project assets.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Source file path on disk" },
target: { type: "string", description: "Target db:// path in project" },
},
required: ["source", "target"],
},
},
{
name: "asset_save_meta",
description: "Save asset meta information (importer settings, etc.).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
meta: { type: "string", description: "Meta JSON string" },
},
required: ["uuid", "meta"],
},
},
{
name: "asset_generate_available_url",
description: "Generate a non-conflicting asset URL (avoids name collisions).",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "Desired db:// path" },
},
required: ["url"],
},
},
{
name: "asset_query_ready",
description: "Check if the asset database is ready.",
inputSchema: { type: "object", properties: {} },
},
// ── 以下、既存MCP未対応のEditor API ──
{
name: "asset_query_users",
description: "Find all assets that use/reference a given asset.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Asset UUID" } },
required: ["uuid"],
},
},
{
name: "asset_query_missing",
description: "Check if an asset has missing references.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Asset UUID" } },
required: ["uuid"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "asset_create":
await (Editor.Message.request as any)("asset-db", "create-asset", args.path, args.content || null);
return ok({ success: true, path: args.path });
case "asset_delete":
await (Editor.Message.request as any)("asset-db", "delete-asset", args.path);
return ok({ success: true, path: args.path });
case "asset_move":
await (Editor.Message.request as any)("asset-db", "move-asset", args.source, args.destination);
return ok({ success: true, source: args.source, destination: args.destination });
case "asset_copy":
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
return ok({ success: true, source: args.source, destination: args.destination });
case "asset_save":
await (Editor.Message.request as any)("asset-db", "save-asset", args.uuid);
return ok({ success: true, uuid: args.uuid });
case "asset_reimport":
await (Editor.Message.request as any)("asset-db", "reimport-asset", args.path);
return ok({ success: true, path: args.path });
case "asset_query_path": {
const path = await (Editor.Message.request as any)("asset-db", "query-path", args.uuid);
return ok({ success: true, uuid: args.uuid, path });
}
case "asset_query_uuid": {
const uuid = await (Editor.Message.request as any)("asset-db", "query-uuid", args.path);
return ok({ success: true, path: args.path, uuid });
}
case "asset_query_url": {
const url = await (Editor.Message.request as any)("asset-db", "query-url", args.uuid);
return ok({ success: true, uuid: args.uuid, url });
}
case "asset_get_details": {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
const meta = await (Editor.Message.request as any)("asset-db", "query-asset-meta", args.uuid).catch(() => null);
return ok({ success: true, info, meta });
}
case "asset_get_dependencies": {
// Try multiple API names as they vary by CocosCreator version
let deps;
try {
deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid);
} catch {
try {
deps = await (Editor.Message.request as any)("asset-db", "query-asset-depends", args.uuid);
} catch {
deps = [];
}
}
return ok({ success: true, uuid: args.uuid, dependencies: deps });
}
case "asset_open_external":
await (Editor.Message.request as any)("asset-db", "open-asset", args.path);
return ok({ success: true, path: args.path });
case "asset_import":
await (Editor.Message.request as any)("asset-db", "import-asset", args.source, args.target);
return ok({ success: true, source: args.source, target: args.target });
case "asset_save_meta":
await (Editor.Message.request as any)("asset-db", "save-asset-meta", args.uuid, args.meta);
return ok({ success: true, uuid: args.uuid });
case "asset_generate_available_url": {
const url = await (Editor.Message.request as any)("asset-db", "generate-available-url", args.url);
return ok({ success: true, url });
}
case "asset_query_ready": {
const ready = await (Editor.Message.request as any)("asset-db", "query-ready");
return ok({ success: true, ready });
}
case "asset_query_users": {
const users = await (Editor.Message.request as any)("asset-db", "query-asset-users", args.uuid).catch(() => []);
return ok({ success: true, uuid: args.uuid, users });
}
case "asset_query_missing": {
const missing = await (Editor.Message.request as any)("asset-db", "query-missing-asset-info", args.uuid).catch(() => null);
return ok({ success: true, uuid: args.uuid, missing, hasMissing: !!missing });
}
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,64 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class BuilderTools implements ToolCategory {
readonly categoryName = "builder";
getTools(): ToolDefinition[] {
return [
{
name: "builder_open_panel",
description: "Open the Build panel in the editor.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_get_settings",
description: "Get the current build settings/configuration.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_query_tasks",
description: "Query active build tasks.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_run_preview",
description: "Start the preview server for browser testing.",
inputSchema: { type: "object", properties: {} },
},
{
name: "builder_stop_preview",
description: "Stop the preview server.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "builder_open_panel":
Editor.Panel.open("builder");
return ok({ success: true });
case "builder_get_settings": {
const settings = await (Editor.Message.request as any)("builder", "query-build-options").catch(() => null);
return ok({ success: true, settings });
}
case "builder_query_tasks": {
const tasks = await (Editor.Message.request as any)("builder", "query-tasks").catch(() => []);
return ok({ success: true, tasks });
}
case "builder_run_preview":
await (Editor.Message.request as any)("preview", "start");
return ok({ success: true, message: "Preview started" });
case "builder_stop_preview":
await (Editor.Message.request as any)("preview", "stop");
return ok({ success: true, message: "Preview stopped" });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,687 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { parseMaybeJson } from "../utils";
import { resolveNodeUuid } from "../node-resolve";
import { takeEditorScreenshot } from "../screenshot";
const EXT_NAME = "cocos-creator-mcp";
export class ComponentTools implements ToolCategory {
readonly categoryName = "component";
getTools(): ToolDefinition[] {
return [
{
name: "component_add",
description: "Add a component to a node. Use cc.XXX format (e.g. 'cc.Label', 'cc.Sprite', 'cc.Button').",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
},
required: ["uuid", "componentType"],
},
},
{
name: "component_remove",
description: "Remove a component from a node.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
componentType: { type: "string", description: "Component class name to remove" },
},
required: ["uuid", "componentType"],
},
},
{
name: "component_get_components",
description: "Get all components on a node with their properties.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
},
},
},
{
name: "component_set_property",
description: "Set one or more properties on a component. For single: use property+value. For batch: use properties array. Use nodeName instead of uuid to find node by name. Set screenshot=true to capture editor screenshot after changes. Examples: Label.string, Label.fontSize, Sprite.color, UITransform.contentSize.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid — avoids UUID lookup)" },
componentType: { type: "string", description: "Component class name (e.g. 'cc.Label')" },
property: { type: "string", description: "Property name (single mode)" },
value: { description: "Value to set (single mode)" },
properties: {
type: "array",
description: "Batch mode: array of {property, value} objects to set multiple properties at once",
items: {
type: "object",
properties: {
property: { type: "string", description: "Property name" },
value: { description: "Value to set" },
},
required: ["property", "value"],
},
},
screenshot: { type: "boolean", description: "If true, capture editor screenshot after setting properties and return the file path (default: false)" },
},
required: ["componentType"],
},
},
{
name: "component_get_info",
description: "Get detailed dump of a specific component by its UUID.",
inputSchema: {
type: "object",
properties: {
componentUuid: { type: "string", description: "Component UUID (not node UUID)" },
},
required: ["componentUuid"],
},
},
{
name: "component_get_available",
description: "List all available component classes that can be added to nodes.",
inputSchema: { type: "object", properties: {} },
},
{
name: "component_auto_bind",
description: "Automatically bind @property references by matching property names to descendant node names. Searches only descendants of the target node. Validates component type existence. Supports array properties (Slot_0, Slot_1...). Mode: 'fuzzy' (default) tries exact match first, then case-insensitive; 'strict' requires exact match only.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
componentType: { type: "string", description: "Script component class name (e.g. 'QuestReadyPageView')" },
force: { type: "boolean", description: "If true, rebind even already-bound properties (default: false)" },
mode: { type: "string", enum: ["fuzzy", "strict"], description: "Matching mode: 'fuzzy' (default) or 'strict'" },
},
required: ["componentType"],
},
},
{
name: "component_query_enum",
description: "Get enum values for a component property. Useful for knowing what values Layout.type, Layout.resizeMode, etc. accept.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
componentType: { type: "string", description: "Component class (e.g. 'cc.Layout')" },
property: { type: "string", description: "Property name (e.g. 'type', 'resizeMode')" },
},
required: ["uuid", "componentType", "property"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
// パラメータエイリアス: component → componentType
const compType = args.componentType || args.component;
// nodeName → uuid 解決(対応ツールのみ)
const needsResolve = ["component_set_property", "component_get_components", "component_auto_bind"];
if (needsResolve.includes(toolName) && !args.uuid && args.nodeName) {
try {
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
args.uuid = resolved.uuid;
} catch (e: any) {
return err(e.message || String(e));
}
}
switch (toolName) {
case "component_add":
return this.addComponent(args.uuid, compType);
case "component_remove":
return this.removeComponent(args.uuid, compType);
case "component_get_components":
return this.getComponents(args.uuid);
case "component_set_property": {
const properties = parseMaybeJson(args.properties);
let result: ToolResult;
if (properties && Array.isArray(properties)) {
const parsed = properties.map((p: any) => ({ ...p, value: parseMaybeJson(p.value) }));
result = await this.setProperties(args.uuid, compType, parsed);
} else {
result = await this.setProperty(args.uuid, compType, args.property, parseMaybeJson(args.value));
}
// screenshot オプション
if (args.screenshot) {
try {
const ss = await takeEditorScreenshot();
const data = JSON.parse(result.content[0].text);
data.screenshot = { path: ss.path, size: ss.savedSize };
return ok(data);
} catch (ssErr: any) {
// スクショ失敗してもプロパティ設定結果は返す
const data = JSON.parse(result.content[0].text);
data.screenshotError = ssErr.message || String(ssErr);
return ok(data);
}
}
return result;
}
case "component_get_info": {
try {
const dump = await (Editor.Message.request as any)("scene", "query-component", args.componentUuid);
return ok({ success: true, component: dump });
} catch (e: any) { return err(e.message || String(e)); }
}
case "component_get_available": {
try {
const classes = await (Editor.Message.request as any)("scene", "query-classes");
return ok({ success: true, classes });
} catch (e: any) { return err(e.message || String(e)); }
}
case "component_auto_bind":
return this.autoBind(args.uuid, compType, args.force ?? false, args.mode ?? "fuzzy");
case "component_query_enum":
return this.queryEnum(args.uuid, compType, args.property);
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async addComponent(uuid: string, componentType: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("addComponentToNode", [uuid, componentType]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async queryEnum(nodeUuid: string, componentType: string, property: string): Promise<ToolResult> {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
if (!nodeDump) return err("Node not found");
const comps = nodeDump.__comps__ || [];
for (const comp of comps) {
const compType = comp.type;
if (!compType) continue;
// Match by cc.XXX format
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
if (compType !== `cc.${normalizedType}` && compType !== componentType) continue;
const propDump = comp.value?.[property];
if (!propDump) return err(`Property '${property}' not found on ${componentType}`);
if (propDump.type !== "Enum") {
return ok({ success: true, property, type: propDump.type, note: "Not an enum property", currentValue: propDump.value });
}
return ok({
success: true,
property,
currentValue: propDump.value,
enumList: propDump.enumList,
});
}
return err(`Component ${componentType} not found on node`);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async removeComponent(uuid: string, componentType: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("removeComponentFromNode", [uuid, componentType]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getComponents(uuid: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("getNodeInfo", [uuid]);
if (!result.success) return ok(result);
return ok({
success: true,
uuid,
name: result.data?.name,
components: result.data?.components || [],
});
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* @property 名とノード名を自動マッチングしてバインドする。
*
* - 検索スコープ: 対象ノードの子孫のみ
* - 複数ヒット時: 階層の浅いノード(直接の子)を優先
* - 型検証: Component 参照型の場合、該当コンポーネントの存在を確認
* - 配列対応: @property([Node]) → 連番ノード名 (Slots_0, Slots_1...)
* - mode:
* - "fuzzy" (default): 完全一致 → case-insensitive → not_found+候補
* - "strict": 完全一致のみ → not_found+候補
*/
private async autoBind(nodeUuid: string, componentType: string, force: boolean, mode: string): Promise<ToolResult> {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
if (!nodeDump) return err("Node not found");
const comps = nodeDump.__comps__ || [];
const compName = componentType.replace("cc.", "");
const compIndex = comps.findIndex((c: any) => {
const t = c.type || "";
return t === compName || t === `cc.${compName}`;
});
if (compIndex < 0) return err(`Component ${componentType} not found on node`);
// 子孫ノード一覧を一括取得(検索効率化)
const allDescendants = await this.sceneScript("getAllDescendants", [nodeUuid]);
const descendantList: Array<{uuid: string, name: string, depth: number}> =
allDescendants?.success ? allDescendants.data : [];
const compDump = comps[compIndex];
const properties = compDump.value || {};
const skipKeys = new Set(["uuid", "name", "enabled", "node", "__scriptAsset", "__prefab", "_name", "_objFlags", "_enabled"]);
const results: any[] = [];
for (const [propName, propDumpRaw] of Object.entries(properties)) {
if (skipKeys.has(propName) || propName.startsWith("_")) continue;
const propDump = propDumpRaw as any;
const propType = propDump.type as string;
if (!propType) continue;
const extendsArr = (propDump.extends || []) as string[];
// 配列型の判定
const isArray = propType === "Array" || Array.isArray(propDump.value);
if (isArray) {
const arrayResult = await this.autoBindArray(nodeUuid, compIndex, propName, propDump, descendantList, mode);
results.push(arrayResult);
continue;
}
const isNodeRef = propType === "cc.Node";
const isComponentRef = extendsArr.includes("cc.Component");
if (!isNodeRef && !isComponentRef) continue;
// 既にバインド済みならスキップ
const currentValue = propDump.value;
if (!force && currentValue?.uuid) {
results.push({ property: propName, status: "already_bound" });
continue;
}
// 名前マッチ: 完全一致 → fuzzy時は case-insensitive
const matchResult = this.findMatchingNode(propName, descendantList, mode);
if (matchResult && isComponentRef) {
// 型検証: コンポーネントが存在するか
const hasComp = await this.nodeHasComponent(matchResult.uuid, propType);
if (!hasComp) {
results.push({ property: propName, type: propType, status: "type_mismatch",
nodeName: matchResult.name, message: `Node "${matchResult.name}" has no ${propType} component` });
continue;
}
}
if (!matchResult) {
// 候補サジェスト
const suggestions = this.getSuggestions(propName, descendantList);
results.push({ property: propName, type: propType, status: "not_found", suggestions });
continue;
}
const path = `__comps__.${compIndex}.${propName}`;
const dump = await this.buildDumpWithTypeInfo(nodeUuid, path, matchResult.uuid);
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, path, dump]);
const status = matchResult.exact ? "bound" : "fuzzy_bound";
results.push({ property: propName, status, nodeName: matchResult.name, success: setResult?.success !== false });
}
const boundCount = results.filter(r => r.status === "bound" || r.status === "fuzzy_bound").length;
const fuzzyCount = results.filter(r => r.status === "fuzzy_bound").length;
const notFoundCount = results.filter(r => r.status === "not_found").length;
return ok({ success: true, boundCount, fuzzyCount, notFoundCount, results });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* 子孫リストからプロパティ名にマッチするノードを検索。
* 完全一致を優先、fuzzy モードでは case-insensitive もフォールバック。
* 複数ヒット時は階層の浅い(depth が小さい)ものを優先。
*/
private findMatchingNode(
propName: string, descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
): { uuid: string; name: string; exact: boolean } | null {
const candidates = this.propertyNameToNodeNames(propName);
// 1. 完全一致
for (const candidate of candidates) {
const matches = descendants
.filter(d => d.name === candidate)
.sort((a, b) => a.depth - b.depth);
if (matches.length > 0) {
return { uuid: matches[0].uuid, name: matches[0].name, exact: true };
}
}
// 2. fuzzy: case-insensitive
if (mode === "fuzzy") {
const lowerCandidates = candidates.map(c => c.toLowerCase());
const matches = descendants
.filter(d => lowerCandidates.includes(d.name.toLowerCase()))
.sort((a, b) => a.depth - b.depth);
if (matches.length > 0) {
return { uuid: matches[0].uuid, name: matches[0].name, exact: false };
}
}
return null;
}
/**
* not_found 時に似た名前のノードをサジェストする。
*/
private getSuggestions(propName: string, descendants: Array<{uuid: string, name: string, depth: number}>): string[] {
const lower = propName.toLowerCase();
return descendants
.filter(d => d.name.toLowerCase().includes(lower) || lower.includes(d.name.toLowerCase()))
.map(d => d.name)
.slice(0, 5);
}
/**
* ノードに指定型のコンポーネントが存在するか確認。
*/
private async nodeHasComponent(nodeUuid: string, propType: string): Promise<boolean> {
const typeName = propType.replace("cc.", "");
const info = await this.sceneScript("getNodeInfo", [nodeUuid]);
if (!info?.success || !info?.data?.components) return false;
return info.data.components.some((c: any) => c.type === typeName);
}
/**
* 配列 @property の自動バインド。
* プロパティ名 "slots" → "Slots_0", "Slots_1", ... の連番ノードを検索。
*/
private async autoBindArray(
nodeUuid: string, compIndex: number, propName: string, propDump: any,
descendants: Array<{uuid: string, name: string, depth: number}>, mode: string
): Promise<any> {
const elementType = propDump.value?.[0]?.type as string | undefined;
if (!elementType) {
return { property: propName, status: "skip", reason: "empty array or unknown element type" };
}
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
const foundElements: any[] = [];
let index = 0;
while (true) {
const candidateName = `${pascal}_${index}`;
// 完全一致 or case-insensitive
let match = descendants.find(d => d.name === candidateName);
if (!match && mode === "fuzzy") {
const lower = candidateName.toLowerCase();
match = descendants.find(d => d.name.toLowerCase() === lower);
}
if (!match) break;
const elementPath = `__comps__.${compIndex}.${propName}.${index}`;
const dump = await this.buildDumpWithTypeInfo(nodeUuid, elementPath, match.uuid);
const setResult = await this.sceneScript("setPropertyViaEditor", [nodeUuid, elementPath, dump]);
const exact = match.name === candidateName;
foundElements.push({ index, nodeName: match.name, exact, success: setResult?.success !== false });
index++;
}
if (foundElements.length === 0) {
return { property: propName, status: "not_found", type: "Array", candidates: [`${pascal}_0`, `${pascal}_1`, "..."] };
}
const hasFuzzy = foundElements.some(e => !e.exact);
return { property: propName, status: hasFuzzy ? "fuzzy_bound" : "bound", type: "Array", count: foundElements.length, elements: foundElements };
}
/**
* camelCase プロパティ名からノード名の候補を生成。
* closeButton → ["CloseButton", "closeButton"]
*/
private propertyNameToNodeNames(propName: string): string[] {
const pascal = propName.charAt(0).toUpperCase() + propName.slice(1);
const names = [pascal];
if (pascal !== propName) names.push(propName);
return names;
}
private async setProperty(uuid: string, componentType: string, property: string, value: any): Promise<ToolResult> {
try {
// コンポーネントのインデックスを取得
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
return err(`Node ${uuid} not found or has no components`);
}
const compName = componentType.replace("cc.", "");
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
if (compIndex < 0) {
return err(`Component ${componentType} not found on node ${uuid}`);
}
// scene:set-property でプロパティ変更(Prefab保存時にも反映される)
// パス形式: __comps__.{index}.{property}
const path = `__comps__.${compIndex}.${property}`;
// プロパティの型情報をquery-nodeから取得して、適切なdump形式を構築
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
if (componentType === "cc.Widget" && property.startsWith("isAlign")) {
await this.recalcWidgetAlignFlags(uuid, compIndex);
}
return ok({ success: true, path, dump, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async setProperties(uuid: string, componentType: string, properties: Array<{property: string, value: any}>): Promise<ToolResult> {
try {
if (!componentType) return err("componentType is required");
if (!properties.length) return err("properties array is empty");
// コンポーネントのインデックスを取得(1回だけ)
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success || !nodeInfo?.data?.components) {
return err(`Node ${uuid} not found or has no components`);
}
const compName = componentType.replace("cc.", "");
const compIndex = nodeInfo.data.components.findIndex((c: any) => c.type === compName);
if (compIndex < 0) {
return err(`Component ${componentType} not found on node ${uuid}`);
}
const results: any[] = [];
for (const { property, value } of properties) {
const path = `__comps__.${compIndex}.${property}`;
const dump = await this.buildDumpWithTypeInfo(uuid, path, value);
const result = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property, success: result?.success !== false, path });
}
const allOk = results.every(r => r.success);
// cc.Widget の isAlign* 設定後は _alignFlags を再計算する
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
if (componentType === "cc.Widget" && properties.some(p => p.property.startsWith("isAlign"))) {
await this.recalcWidgetAlignFlags(uuid, compIndex);
}
return ok({ success: allOk, results });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* cc.Widget の isAlign* プロパティ現在値から _alignFlags ビットマスクを再計算して設定する。
*
* CocosCreator Editor は isAlign* を setPropertyViaEditor で変更しても
* _alignFlags を自動更新しないバグがある。このヘルパーで明示的に同期する。
*
* _alignFlags ビット定義:
* isAlignLeft=1, isAlignRight=2, isAlignTop=4, isAlignBottom=8,
* isAlignHorizontalCenter=16, isAlignVerticalCenter=32
*/
private async recalcWidgetAlignFlags(uuid: string, wIdx: number): Promise<void> {
const ALIGN_BITS: Record<string, number> = {
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
};
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
if (!nodeDump) return;
const wCompDump = nodeDump.__comps__?.[wIdx];
if (!wCompDump) return;
let alignFlags = 0;
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
}
const path = `__comps__.${wIdx}._alignFlags`;
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: alignFlags, type: "Number" }]);
} catch (_e) {
// _alignFlags 再計算の失敗は致命的でないため無視
}
}
/**
* プロパティの型情報をEditor APIから取得し、適切なdump形式を構築する。
*
* UUID文字列が渡された場合、プロパティの型に応じて:
* - Node/Component参照型 → {type: propType, value: {uuid: nodeUuid}}
* - Asset参照型(cc.Prefab等) → {type: propType, value: {uuid: assetUuid}}
* - String型 → {value, type: "String"}
*/
private async buildDumpWithTypeInfo(nodeUuid: string, path: string, value: any): Promise<any> {
// プリミティブ型はそのまま
if (typeof value === "number") return { value, type: "Number" };
if (typeof value === "boolean") return { value, type: "Boolean" };
// オブジェクト形式 {uuid: "xxx", type: "cc.Node"} はそのまま
// type 指定なしの {uuid: "xxx"} はプロパティの実際の型を解決するため文字列扱いに変換する
if (value !== null && typeof value === "object" && typeof value.uuid === "string") {
if (typeof value.type === "string") {
return { type: value.type, value: { uuid: value.uuid } };
}
// type 未指定: 文字列として処理してプロパティ型から解決
value = value.uuid;
}
// @path: プレフィックスの場合: パスからノードUUIDを解決
if (typeof value === "string" && value.startsWith("@path:")) {
const nodePath = value.slice(6);
const result = await this.sceneScript("findNodeByPath", [nodePath]);
if (result?.success && result.data?.uuid) {
value = result.data.uuid;
} else {
throw new Error(`Node not found at path: ${nodePath}`);
}
}
// 文字列の場合: プロパティの型情報を取得して判定
if (typeof value === "string") {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
if (nodeDump) {
const propDump = this.resolveDumpPath(nodeDump, path);
if (propDump?.type) {
const propType = propDump.type as string;
const extendsArr = (propDump.extends || []) as string[];
const isComponentRef = extendsArr.includes("cc.Component");
const isNodeRef = propType === "cc.Node";
const isAssetRef = extendsArr.includes("cc.Asset");
if (isComponentRef) {
// コンポーネント参照: ノードUUIDからコンポーネントUUIDを解決
const compUuid = await this.resolveComponentUuid(value, propType);
return { type: propType, value: { uuid: compUuid || value } };
}
if (isNodeRef) {
return { type: propType, value: { uuid: value } };
}
if (isAssetRef) {
return { type: propType, value: { uuid: value } };
}
}
}
} catch (_e) {
// query-node失敗時はフォールバック
}
return { value, type: "String" };
}
// その他のオブジェクト(contentSize, color等の構造体)
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
const wrapped: any = {};
for (const [k, v] of Object.entries(value)) {
wrapped[k] = { value: v };
}
return { value: wrapped };
}
return { value };
}
/**
* query-nodeのdumpからドットパスでプロパティを解決する。
* 例: "__comps__.2.scrollView" → nodeDump.__comps__[2].value.scrollView
*/
private resolveDumpPath(nodeDump: any, path: string): any {
const parts = path.split(".");
let current = nodeDump;
for (const part of parts) {
if (!current) return null;
if (part === "__comps__") {
current = current.__comps__;
} else if (/^\d+$/.test(part)) {
current = current[parseInt(part)]?.value;
} else {
current = current?.[part];
}
}
return current;
}
/**
* ノードUUIDからコンポーネントUUIDを解決する。
* propType(例: "cc.ScrollView", "MissionListPanel")に一致するコンポーネントを探す。
*/
private async resolveComponentUuid(nodeUuid: string, propType: string): Promise<string | null> {
try {
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
if (!nodeInfo?.success || !nodeInfo?.data?.components) return null;
const typeName = propType.replace("cc.", "");
const comp = nodeInfo.data.components.find((c: any) => c.type === typeName);
return comp?.uuid || null;
} catch (_e) {
return null;
}
}
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method,
args,
});
}
}
@@ -0,0 +1,804 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { getGameLogs, clearGameLogs, queueGameCommand, getCommandResult } from "../mcp-server";
import { parseMaybeJson } from "../utils";
import { ensureSceneSafeToSwitch } from "./scene-tools";
import { processImage, takeEditorScreenshot } from "../screenshot";
export class DebugTools implements ToolCategory {
readonly categoryName = "debug";
getTools(): ToolDefinition[] {
return [
{
name: "debug_get_editor_info",
description: "Get Cocos Creator editor information (version, platform, language).",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_list_messages",
description: "List available Editor messages for a given extension or built-in module.",
inputSchema: {
type: "object",
properties: {
target: { type: "string", description: "Message target (e.g. 'scene', 'asset-db', 'extension')" },
},
required: ["target"],
},
},
{
name: "debug_execute_script",
description: "Execute a custom scene script method. The method must be registered in scene.ts.",
inputSchema: {
type: "object",
properties: {
method: { type: "string", description: "Method name from scene.ts" },
args: { type: "array", description: "Arguments to pass", items: {} },
},
required: ["method"],
},
},
{
name: "debug_get_console_logs",
description: "Get recent console log entries. Automatically captures scene process logs (console.log/warn/error in scene scripts). Game preview logs can also be captured by sending POST requests to /log endpoint — see README for setup.",
inputSchema: {
type: "object",
properties: {
count: { type: "number", description: "Max number of entries (default 50)" },
level: { type: "string", description: "Filter by level: 'log', 'warn', or 'error'" },
source: { type: "string", description: "Filter by source: 'scene' or 'game'. Returns both if omitted." },
},
},
},
{
name: "debug_clear_console",
description: "Clear the editor console.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_list_extensions",
description: "List all installed extensions.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_get_project_logs",
description: "Read recent project log entries from the log file.",
inputSchema: {
type: "object",
properties: {
lines: { type: "number", description: "Number of lines to read (default 100)" },
},
},
},
{
name: "debug_search_project_logs",
description: "Search for a pattern in project logs.",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Search pattern (regex supported)" },
},
required: ["pattern"],
},
},
{
name: "debug_get_log_file_info",
description: "Get information about the project log file (size, path, last modified).",
inputSchema: { type: "object", properties: {} },
},
// ── 以下、既存MCP未対応のEditor API ──
{
name: "debug_query_devices",
description: "List connected devices (for native debugging).",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_open_url",
description: "Open a URL in the system browser from the editor.",
inputSchema: {
type: "object",
properties: { url: { type: "string", description: "URL to open" } },
required: ["url"],
},
},
{
name: "debug_validate_scene",
description: "Validate the current scene for common issues.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_game_command",
description: "Send a command to the running game preview. Requires GameDebugClient in the game. Commands: 'screenshot' (capture game canvas), 'state' (dump GameDb), 'navigate' (go to a page), 'click' (click a node by name), 'inspect' (get runtime node info: UITransform sizes, Widget, Layout, position). Returns the result from the game.",
inputSchema: {
type: "object",
properties: {
type: { type: "string", description: "Command type: 'screenshot', 'state', 'navigate', 'click', 'inspect'" },
args: { type: "object", description: "Command arguments (e.g. {page: 'HomePageView'} for navigate, {name: 'ButtonName'} for click)" },
timeout: { type: "number", description: "Max wait time in ms (default 5000)" },
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960, 0 = no resize)" },
imageFormat: { type: "string", description: "Screenshot output format: 'webp' (default, Q=85) or 'png' (lossless)" },
},
required: ["type"],
},
},
{
name: "debug_screenshot",
description: "Take a screenshot of the editor window and save to a file. Returns the file path of the saved PNG.",
inputSchema: {
type: "object",
properties: {
savePath: { type: "string", description: "File path to save the PNG (default: temp/screenshots/screenshot_<timestamp>.png)" },
maxWidth: { type: "number", description: "Max width in pixels for resize (default: 960, 0 = no resize). Aspect ratio is preserved." },
},
},
},
{
name: "debug_preview",
description: "Start or stop the game preview. Uses Preview in Editor (auto-opens MainScene if needed). Falls back to browser preview if editor preview fails.",
inputSchema: {
type: "object",
properties: {
action: { type: "string", description: "'start' (default) or 'stop'" },
waitForReady: { type: "boolean", description: "If true, wait until GameDebugClient connects after start (default: false)" },
waitTimeout: { type: "number", description: "Max wait time in ms for waitForReady (default: 15000)" },
},
},
},
{
name: "debug_clear_code_cache",
description: "Clear the code cache (equivalent to Developer > Cache > Clear code cache) and soft-reload the scene.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_reload_extension",
description: "Reload the MCP extension itself. Use after npm run build to apply code changes without restarting CocosCreator. Response is sent before reload starts.",
inputSchema: { type: "object", properties: {} },
},
{
name: "debug_get_extension_info",
description: "Get detailed information about a specific extension.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Extension name" },
},
required: ["name"],
},
},
{
name: "debug_record_start",
description: "Start recording the game preview canvas to a video file. Uses MediaRecorder on the game side. Bitrate is auto-calculated from canvas resolution × fps × quality coefficient unless videoBitsPerSecond is set explicitly.",
inputSchema: {
type: "object",
properties: {
fps: { type: "number", description: "Frames per second (default: 30)" },
quality: { type: "string", description: "'low'/'medium'/'high'/'ultra' (default: medium). Coefficients: 0.15/0.25/0.40/0.60" },
coefficient: { type: "number", description: "Custom bitrate coefficient (width × height × fps × coefficient). Overrides quality." },
videoBitsPerSecond: { type: "number", description: "Explicit bitrate in bps. Overrides quality-based calculation." },
format: { type: "string", description: "'mp4' (default) or 'webm'. mp4 falls back to webm if not supported." },
savePath: { type: "string", description: "Save directory (project-relative or absolute). Default: temp/recordings" },
},
},
},
{
name: "debug_record_stop",
description: "Stop recording started by debug_record_start. Returns the saved WebM file path and size. Video is saved to project's temp/recordings/rec_<datetime>.webm.",
inputSchema: {
type: "object",
properties: {
timeout: { type: "number", description: "Max wait time in ms for file upload (default: 30000)" },
},
},
},
{
name: "debug_batch_screenshot",
description: "Navigate to multiple pages and take a screenshot of each. Requires game preview running with GameDebugClient. Returns an array of screenshot file paths.",
inputSchema: {
type: "object",
properties: {
pages: {
type: "array",
items: { type: "string" },
description: "List of page names to screenshot (e.g. ['HomePageView', 'ShopPageView'])",
},
delay: { type: "number", description: "Delay in ms between navigate and screenshot (default: 1000)" },
maxWidth: { type: "number", description: "Max width for screenshot resize (default: 960)" },
},
required: ["pages"],
},
},
{
name: "debug_wait_compile",
description: "Wait for TypeScript compilation to complete. Monitors the packer-driver debug log for 'Target(editor) ends' message. Use after modifying .ts files to ensure changes are compiled before operating on Prefabs. With clean=true, deletes compiled output first to force a fresh recompile (slower but guaranteed).",
inputSchema: {
type: "object",
properties: {
timeout: { type: "number", description: "Max wait time in ms (default: 15000)" },
clean: { type: "boolean", description: "If true, delete compiled output first to force fresh recompile (default: false)" },
},
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "debug_get_editor_info":
return this.getEditorInfo();
case "debug_list_messages":
return this.listMessages(args.target);
case "debug_execute_script":
return this.executeScript(args.method, args.args || []);
case "debug_get_console_logs":
return this.getConsoleLogs(args.count || 50, args.level, args.source);
case "debug_clear_console":
Editor.Message.send("console", "clear");
// Clear scene process log buffer
await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method: "clearConsoleLogs",
args: [],
}).catch(() => {});
// Clear game preview log buffer
clearGameLogs();
return ok({ success: true });
case "debug_list_extensions":
return this.listExtensions();
case "debug_get_project_logs":
return this.getProjectLogs(args.lines || 100);
case "debug_search_project_logs":
return this.searchProjectLogs(args.pattern);
case "debug_get_log_file_info":
return this.getLogFileInfo();
case "debug_query_devices": {
const devices = await (Editor.Message.request as any)("device", "query").catch(() => []);
return ok({ success: true, devices });
}
case "debug_open_url":
await (Editor.Message.request as any)("program", "open-url", args.url);
return ok({ success: true, url: args.url });
case "debug_game_command":
return this.gameCommand(args.type || args.command, parseMaybeJson(args.args), args.timeout || 5000, args.maxWidth, args.imageFormat);
case "debug_screenshot":
return this.takeScreenshot(args.savePath, args.maxWidth);
case "debug_preview":
return this.handlePreview(args.action || "start", args.waitForReady, args.waitTimeout || 15000);
case "debug_clear_code_cache":
return this.clearCodeCache();
case "debug_reload_extension":
return this.reloadExtension();
case "debug_validate_scene":
return this.validateScene();
case "debug_get_extension_info":
return this.getExtensionInfo(args.name);
case "debug_batch_screenshot":
return this.batchScreenshot(args.pages, args.delay || 1000, args.maxWidth);
case "debug_record_start":
return this.gameCommand("record_start", { fps: args.fps, quality: args.quality, coefficient: args.coefficient, videoBitsPerSecond: args.videoBitsPerSecond, format: args.format, savePath: args.savePath }, 5000);
case "debug_record_stop":
return this.gameCommand("record_stop", undefined, args.timeout || 30000);
case "debug_wait_compile":
return this.waitCompile(args.timeout || 15000, args.clean ?? false);
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getEditorInfo(): Promise<ToolResult> {
return ok({
success: true,
version: Editor.App.version,
path: Editor.App.path,
home: Editor.App.home,
language: Editor.I18n?.getLanguage?.() || "unknown",
});
}
private async listMessages(target: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("extension", "query-info", target);
return ok({ success: true, target, info });
} catch (e: any) {
const knownMessages: Record<string, string[]> = {
"scene": [
"query-node-tree", "create-node", "remove-node", "duplicate-node",
"set-property", "create-prefab", "save-scene", "execute-scene-script",
"query-is-dirty", "query-classes", "soft-reload", "snapshot",
"change-gizmo-tool", "query-gizmo-tool-name", "focus-camera-on-nodes",
],
"asset-db": [
"query-assets", "query-asset-info", "query-asset-meta",
"refresh-asset", "save-asset", "create-asset", "delete-asset",
"move-asset", "copy-asset", "open-asset", "reimport-asset",
"query-path", "query-uuid", "query-url", "query-asset-depends",
],
};
const messages = knownMessages[target];
if (messages) {
return ok({ success: true, target, messages, note: "Static list (query failed)" });
}
return err(e.message || String(e));
}
}
private async executeScript(method: string, args: any[]): Promise<ToolResult> {
const result = await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method,
args,
});
return ok(result);
}
private async getConsoleLogs(count: number, level?: string, source?: string): Promise<ToolResult> {
// 1. Try Editor's native console API first (may be supported in future CocosCreator versions)
if (!source) {
try {
const logs = await (Editor.Message.request as any)("console", "query-last-logs", count);
if (Array.isArray(logs) && logs.length > 0) {
return ok({ success: true, logs, source: "editor-api", note: "Using native Editor console API" });
}
} catch { /* Not supported in this version — use fallback */ }
}
// 2. Fallback: collect from scene process buffer + game preview buffer
let sceneLogs: any[] = [];
let gameLogs: any[] = [];
// 2a. Scene process logs (console wrapper in scene.ts)
if (!source || source === "scene") {
try {
const result = await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method: "getConsoleLogs",
args: [count * 2, level], // request more, will trim after merge
});
if (result?.logs) {
sceneLogs = result.logs.map((l: any) => ({ ...l, source: "scene" }));
}
} catch { /* scene not available */ }
}
// 2b. Game preview logs (received via POST /log endpoint)
if (!source || source === "game") {
const gameResult = getGameLogs(count * 2, level);
gameLogs = gameResult.logs.map((l: any) => ({ ...l, source: "game" }));
}
// Merge and sort by timestamp, take last `count`
const merged = [...sceneLogs, ...gameLogs]
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
.slice(-count);
return ok({
success: true,
logs: merged,
total: { scene: sceneLogs.length, game: (source === "scene" ? 0 : gameLogs.length) },
});
}
private async listExtensions(): Promise<ToolResult> {
try {
const list = await (Editor.Message.request as any)("extension", "query-all");
return ok({ success: true, extensions: list });
} catch {
return ok({ success: true, extensions: [], note: "Extension query not supported" });
}
}
private async getExtensionInfo(name: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("extension", "query-info", name);
return ok({ success: true, name, info });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getProjectLogs(lines: number): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
if (!fs.existsSync(logPath)) return ok({ success: true, logs: [], note: "Log file not found" });
const content = fs.readFileSync(logPath, "utf-8");
const allLines = content.split("\n");
const recent = allLines.slice(-lines);
return ok({ success: true, lines: recent.length, logs: recent });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async searchProjectLogs(pattern: string): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
if (!fs.existsSync(logPath)) return ok({ success: true, matches: [] });
const content = fs.readFileSync(logPath, "utf-8");
const regex = new RegExp(pattern, "gi");
const matches = content.split("\n").filter((line: string) => regex.test(line));
return ok({ success: true, pattern, count: matches.length, matches: matches.slice(0, 100) });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getLogFileInfo(): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.tmpDir, "logs", "project.log");
if (!fs.existsSync(logPath)) return ok({ success: true, exists: false });
const stat = fs.statSync(logPath);
return ok({ success: true, exists: true, path: logPath, size: stat.size, modified: stat.mtime });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async handlePreview(action: string, waitForReady?: boolean, waitTimeout?: number): Promise<ToolResult> {
if (action === "stop") {
return this.stopPreview();
}
const result = await this.startPreview();
if (waitForReady) {
const resultData = JSON.parse(result.content[0].text);
if (resultData.success) {
const ready = await this.waitForGameReady(waitTimeout || 15000);
resultData.gameReady = ready;
if (!ready) {
resultData.note = (resultData.note || "") + " GameDebugClient did not connect within timeout.";
}
return ok(resultData);
}
}
return result;
}
private async waitForGameReady(timeout: number): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
// Check if game has sent any log or command result recently
const gameResult = getGameLogs(1);
if (gameResult.total > 0) return true;
await new Promise(r => setTimeout(r, 500));
}
return false;
}
private async startPreview(): Promise<ToolResult> {
try {
await this.ensureMainSceneOpen();
// ツールバーのVueインスタンス経由でplay()を呼ぶ(UI状態も同期される)
const played = await this.executeOnToolbar("start");
if (played) {
return ok({ success: true, action: "start", mode: "editor" });
}
// フォールバック: 直接API
const isPlaying = await (Editor.Message.request as any)("scene", "editor-preview-set-play", true);
return ok({ success: true, isPlaying, action: "start", mode: "editor", note: "direct API (toolbar UI may not sync)" });
} catch (e: any) {
try {
const electron = require("electron");
await electron.shell.openExternal("http://127.0.0.1:7456");
return ok({ success: true, action: "start", mode: "browser" });
} catch (e2: any) {
return err(e2.message || String(e2));
}
}
}
private async stopPreview(): Promise<ToolResult> {
try {
// ツールバー経由で停止(UI同期)
const stopped = await this.executeOnToolbar("stop");
if (!stopped) {
// フォールバック: 直接API
await (Editor.Message.request as any)("scene", "editor-preview-set-play", false);
}
// scene:preview-stop ブロードキャストでツールバーUI状態をリセット
Editor.Message.broadcast("scene:preview-stop");
// シーンビューに戻す
await new Promise(r => setTimeout(r, 500));
await this.ensureMainSceneOpen();
return ok({ success: true, action: "stop" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async executeOnToolbar(action: "start" | "stop"): Promise<boolean> {
try {
const electron = require("electron");
const allContents = electron.webContents.getAllWebContents();
for (const wc of allContents) {
try {
// play()をawaitしない — プレビュー完了を待つとタイムアウトするため
if (action === "start") {
const result = await wc.executeJavaScript(
`(function() { if (window.xxx && window.xxx.play && !window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
);
if (result) return true;
} else {
const result = await wc.executeJavaScript(
`(function() { if (window.xxx && window.xxx.gameView.isPlay) { window.xxx.play(); return true; } return false; })()`
);
if (result) return true;
}
} catch { /* not the toolbar webContents */ }
}
} catch { /* electron API not available */ }
return false;
}
private async ensureMainSceneOpen(): Promise<void> {
const hierarchy = await Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method: "getSceneHierarchy",
args: [false],
}).catch(() => null);
if (!hierarchy?.sceneName || hierarchy.sceneName === "scene-2d") {
// プロジェクト設定のStart Sceneを参照
let sceneUuid: string | null = null;
try {
sceneUuid = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
} catch { /* ignore */ }
// Start Sceneが未設定 or "current_scene" の場合、最初のシーンを使う
if (!sceneUuid || sceneUuid === "current_scene") {
const scenes = await Editor.Message.request("asset-db", "query-assets", {
ccType: "cc.SceneAsset",
pattern: "db://assets/**/*",
});
if (Array.isArray(scenes) && scenes.length > 0) {
sceneUuid = scenes[0].uuid;
}
}
if (sceneUuid) {
// debug_preview 内部の自動遷移は preview を優先して force=true
// (dialog 出るより preview 開始を優先する運用)
await ensureSceneSafeToSwitch(true);
await (Editor.Message.request as any)("scene", "open-scene", sceneUuid);
await new Promise(r => setTimeout(r, 1500));
}
}
}
private async clearCodeCache(): Promise<ToolResult> {
try {
const electron = require("electron");
const menu = electron.Menu.getApplicationMenu();
if (!menu) return err("Application menu not found");
const findMenuItem = (items: any[], path: string[]): any => {
for (const item of items) {
if (item.label === path[0]) {
if (path.length === 1) return item;
if (item.submenu?.items) return findMenuItem(item.submenu.items, path.slice(1));
}
}
return null;
};
const cacheItem = findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]);
if (!cacheItem) return err("Menu item 'Developer > Cache > Clear code cache' not found");
cacheItem.click();
await new Promise(r => setTimeout(r, 1000));
return ok({ success: true, note: "Code cache cleared via menu" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async gameCommand(type: string, args: any, timeout: number, maxWidth?: number, imageFormat?: string): Promise<ToolResult> {
const cmdId = queueGameCommand(type, args);
// Poll for result
const start = Date.now();
while (Date.now() - start < timeout) {
const result = getCommandResult();
if (result && result.id === cmdId) {
// If screenshot, save to file and return path
if (type === "screenshot" && result.success && result.data?.dataUrl) {
try {
const fs = require("fs");
const path = require("path");
const dir = path.join(Editor.Project.tmpDir, "screenshots");
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const base64 = result.data.dataUrl.replace(/^data:image\/png;base64,/, "");
const pngBuffer = Buffer.from(base64, "base64");
const effectiveMaxWidth = maxWidth !== undefined ? maxWidth : 960;
const electron = require("electron");
const origImage = electron.nativeImage.createFromBuffer(pngBuffer);
const originalSize = origImage.getSize();
const { buffer, width, height, format } = await processImage(pngBuffer, effectiveMaxWidth, imageFormat);
const ext = format === "webp" ? "webp" : format === "jpeg" ? "jpg" : "png";
const filePath = path.join(dir, `game_${timestamp}.${ext}`);
fs.writeFileSync(filePath, buffer);
return ok({
success: true, path: filePath, size: buffer.length, format,
originalSize: `${originalSize.width}x${originalSize.height}`,
savedSize: `${width}x${height}`,
});
} catch (e: any) {
return ok({ success: true, note: "Screenshot captured but file save failed", error: e.message });
}
}
return ok(result);
}
await new Promise(r => setTimeout(r, 200));
}
return err(`Game did not respond within ${timeout}ms. Is GameDebugClient running in the preview?`);
}
private async takeScreenshot(savePath?: string, maxWidth?: number): Promise<ToolResult> {
try {
const result = await takeEditorScreenshot(savePath, maxWidth);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async reloadExtension(): Promise<ToolResult> {
// Schedule reload after response is sent
setTimeout(async () => {
try {
await (Editor.Message.request as any)("extension", "reload", "cocos-creator-mcp");
} catch (e: any) {
console.error("[MCP] Extension reload failed:", e.message);
}
}, 500);
return ok({ success: true, note: "Extension reload scheduled. MCP server will restart in ~1s. NOTE: Adding new tool definitions or modifying scene.ts requires a full CocosCreator restart (reload is not sufficient)." });
}
private async batchScreenshot(pages: string[], delay: number, maxWidth?: number): Promise<ToolResult> {
const results: any[] = [];
const timeout = 10000;
for (const page of pages) {
// Navigate
const navResult = await this.gameCommand("navigate", { page }, timeout, maxWidth);
const navData = JSON.parse(navResult.content[0].text);
if (!navData.success) {
results.push({ page, success: false, error: "navigate failed" });
continue;
}
// Wait for page to render
await new Promise(r => setTimeout(r, delay));
// Screenshot
const ssResult = await this.gameCommand("screenshot", {}, timeout, maxWidth);
const ssData = JSON.parse(ssResult.content[0].text);
results.push({
page,
success: ssData.success || false,
path: ssData.path,
error: ssData.success ? undefined : (ssData.error || ssData.message),
});
}
const succeeded = results.filter(r => r.success).length;
return ok({
success: true,
total: pages.length,
succeeded,
failed: pages.length - succeeded,
results,
});
}
private async validateScene(): Promise<ToolResult> {
try {
const tree = await Editor.Message.request("scene", "query-node-tree");
const issues: string[] = [];
const checkNodes = (nodes: any[]) => {
if (!nodes) return;
for (const node of nodes) {
if (!node.name) issues.push(`Node ${node.uuid} has no name`);
if (node.children) checkNodes(node.children);
}
};
if (Array.isArray(tree)) checkNodes(tree);
return ok({ success: true, issueCount: issues.length, issues });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* TypeScript コンパイル完了を待つ。
* packer-driver の debug.log に "Target(editor) ends" が現れるのを監視する。
* 既にコンパイル済み(直近数秒以内に完了ログあり)なら即座に返す。
*/
private async waitCompile(timeout: number, clean: boolean): Promise<ToolResult> {
try {
const fs = require("fs");
const path = require("path");
const logPath = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "logs", "debug.log");
const chunksDir = path.join(Editor.Project.path, "temp", "programming", "packer-driver", "targets", "editor", "chunks");
if (!fs.existsSync(logPath)) {
return err(`Compile log not found: ${logPath}`);
}
const MARKER = "Target(editor) ends";
// clean モード: コードキャッシュクリア + soft-reload で再コンパイルを強制
if (clean) {
// Developer > Cache > Clear code cache をクリック
try {
const electron = require("electron");
const menu = electron.Menu.getApplicationMenu();
const findMenuItem = (items: any[], labels: string[]): any => {
for (const item of items) {
if (item.label === labels[0]) {
if (labels.length === 1) return item;
if (item.submenu?.items) return findMenuItem(item.submenu.items, labels.slice(1));
}
}
return null;
};
const cacheItem = menu ? findMenuItem(menu.items, ["Developer", "Cache", "Clear code cache"]) : null;
if (cacheItem) cacheItem.click();
} catch (_e) { /* ignore */ }
await new Promise(r => setTimeout(r, 500));
// soft-reload でシーンを再読み込み → コンパイルトリガー
await (Editor.Message.request as any)("scene", "soft-reload").catch(() => {});
}
// refresh-asset でファイル変更を CC に通知してコンパイルをトリガー
await (Editor.Message.request as any)("asset-db", "refresh-asset", "db://assets").catch(() => {});
const initialSize = fs.statSync(logPath).size;
const startTime = Date.now();
const POLL_INTERVAL = 200;
const DETECT_GRACE_MS = 2000; // CC がファイル変更を検知するまでの猶予
while (Date.now() - startTime < timeout) {
await new Promise(r => setTimeout(r, POLL_INTERVAL));
const currentSize = fs.statSync(logPath).size;
// ログが成長していない
if (currentSize <= initialSize) {
// clean モードでは必ずコンパイルが走るので猶予判定しない
if (clean) continue;
// 猶予期間内はまだ待つ (CC の検知が遅い可能性)
if (Date.now() - startTime < DETECT_GRACE_MS) continue;
// 猶予期間を過ぎてもログが成長しない → コンパイル不要
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime, note: "No compilation triggered (no changes detected)" });
}
// ログが成長した → 新しい部分にマーカーがあるか確認
const fd = fs.openSync(logPath, "r");
const newBytes = currentSize - initialSize;
const buffer = Buffer.alloc(newBytes);
fs.readSync(fd, buffer, 0, newBytes, initialSize);
fs.closeSync(fd);
const newContent = buffer.toString("utf8");
if (newContent.includes(MARKER)) {
return ok({ success: true, compiled: true, waitedMs: Date.now() - startTime });
}
}
return ok({ success: true, compiled: false, timeout: true, waitedMs: timeout });
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,624 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { parseMaybeJson } from "../utils";
import { resolveNodeUuid } from "../node-resolve";
import { takeEditorScreenshot } from "../screenshot";
const EXT_NAME = "cocos-creator-mcp";
export class NodeTools implements ToolCategory {
readonly categoryName = "node";
getTools(): ToolDefinition[] {
return [
{
name: "node_create",
description: "Create a new node in the scene.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Node name" },
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
components: {
type: "array",
items: { type: "string" },
description: "Component class names to add (e.g. ['cc.Label', 'cc.Sprite'])",
},
},
required: ["name"],
},
},
{
name: "node_get_info",
description: "Get detailed information about a node by UUID, including components.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "node_find_by_name",
description: "Find all nodes matching a given name.",
inputSchema: {
type: "object",
properties: {
name: { type: "string", description: "Node name to search" },
},
required: ["name"],
},
},
{
name: "node_set_property",
description: "Set a property on a node (name, active, position, rotation, scale, etc.).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
property: { type: "string", description: "Property name (e.g. 'name', 'active', 'position')" },
value: { description: "Value to set. For position/rotation/scale use {x,y,z}." },
},
required: ["uuid", "property", "value"],
},
},
{
name: "node_set_transform",
description: "Set position, rotation, and/or scale of a node at once.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
position: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
description: "Position {x,y,z}",
},
rotation: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
description: "Euler rotation {x,y,z}",
},
scale: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" }, z: { type: "number" } },
description: "Scale {x,y,z}",
},
},
required: ["uuid"],
},
},
{
name: "node_delete",
description: "Delete a node by UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "node_move",
description: "Move a node to a new parent.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
parentUuid: { type: "string", description: "New parent node UUID" },
},
required: ["uuid", "parentUuid"],
},
},
{
name: "node_duplicate",
description: "Duplicate a node.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID to duplicate" },
},
required: ["uuid"],
},
},
{
name: "node_get_all",
description: "Get a flat list of all nodes in the current scene.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "node_set_active",
description: "Set a node's active (visible) state.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
active: { type: "boolean", description: "Whether the node is active" },
},
required: ["uuid", "active"],
},
},
{
name: "node_set_layer",
description: "Set a node's layer.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
layer: { type: "number", description: "Layer value" },
},
required: ["uuid", "layer"],
},
},
{
name: "node_detect_type",
description: "Detect node type (2D, 3D, or regular Node) based on its components.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "node_create_tree",
description: "Create a full node tree from a JSON spec in one call. Much faster than creating nodes one by one. Spec format: { name, components?: ['cc.UITransform'], properties?: {'cc.UITransform.contentSize': {width:720,height:1280}}, widget?: {top:0, bottom:0, left:0, right:0}, active?: bool, position?: {x,y,z}, children?: [...] }",
inputSchema: {
type: "object",
properties: {
parent: { type: "string", description: "Parent node UUID" },
spec: { description: "Node tree specification (JSON object with name, components, properties, children)" },
},
required: ["parent", "spec"],
},
},
{
name: "node_set_layout",
description: "Set UITransform (contentSize, anchorPoint) and Widget (margins) on a node in one call. Much faster than calling component_set_property multiple times for layout adjustments. Set screenshot=true to capture the editor after changes.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID (either uuid or nodeName required)" },
nodeName: { type: "string", description: "Node name to find (alternative to uuid)" },
contentSize: {
type: "object",
properties: { width: { type: "number" }, height: { type: "number" } },
description: "UITransform contentSize {width, height}",
},
anchorPoint: {
type: "object",
properties: { x: { type: "number" }, y: { type: "number" } },
description: "UITransform anchorPoint {x, y} (0-1)",
},
widget: {
type: "object",
properties: {
top: { type: "number" }, bottom: { type: "number" },
left: { type: "number" }, right: { type: "number" },
horizontalCenter: { type: "number" }, verticalCenter: { type: "number" },
isAlignTop: { type: "boolean" }, isAlignBottom: { type: "boolean" },
isAlignLeft: { type: "boolean" }, isAlignRight: { type: "boolean" },
isAlignHorizontalCenter: { type: "boolean" }, isAlignVerticalCenter: { type: "boolean" },
},
description: "Widget alignment margins. Setting a value (e.g. top:0) automatically enables the corresponding alignment (isAlignTop:true).",
},
color: {
type: "object",
properties: { r: { type: "number" }, g: { type: "number" }, b: { type: "number" }, a: { type: "number" } },
description: "Node color {r,g,b,a} (0-255)",
},
opacity: { type: "number", description: "Node opacity (0-255)" },
screenshot: { type: "boolean", description: "If true, capture editor screenshot after changes (default: false)" },
},
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
const rejected = await this.rejectIfPreviewRunning(toolName);
if (rejected) return rejected;
switch (toolName) {
case "node_create":
return this.createNode(args.name, args.parent, args.components);
case "node_get_info":
return this.getNodeInfo(args.uuid);
case "node_find_by_name":
return this.findByName(args.name);
case "node_set_property":
return this.setProperty(args.uuid, args.property, parseMaybeJson(args.value));
case "node_set_transform":
return this.setTransform(args.uuid, args.position, args.rotation, args.scale);
case "node_delete":
return this.deleteNode(args.uuid);
case "node_move":
return this.moveNode(args.uuid, args.parentUuid);
case "node_duplicate":
return this.duplicateNode(args.uuid);
case "node_get_all":
return this.getAllNodes();
case "node_set_active":
return this.setProperty(args.uuid, "active", args.active);
case "node_set_layer":
return this.setProperty(args.uuid, "layer", args.layer);
case "node_create_tree":
return this.createNodeTree(args.parent, parseMaybeJson(args.spec));
case "node_set_layout":
return this.setLayout(args);
case "node_detect_type": {
try {
const info = await this.sceneScript("getNodeInfo", [args.uuid]);
if (!info.success) return ok(info);
const comps = info.data?.components || [];
const compTypes = comps.map((c: any) => c.type);
let nodeType = "Node";
if (compTypes.includes("UITransform")) nodeType = "2D";
else if (compTypes.includes("MeshRenderer") || compTypes.includes("Camera")) nodeType = "3D";
return ok({ success: true, uuid: args.uuid, nodeType, components: compTypes });
} catch (e: any) { return err(e.message || String(e)); }
}
default:
return err(`Unknown tool: ${toolName}`);
}
}
/** Scene editing tools that must not run during preview */
private static readonly SCENE_EDIT_TOOLS = new Set([
"node_create", "node_delete", "node_move", "node_duplicate",
"node_set_property", "node_set_transform", "node_set_active", "node_set_layer",
"node_create_tree", "node_set_layout",
]);
private async rejectIfPreviewRunning(toolName: string): Promise<ToolResult | null> {
if (!NodeTools.SCENE_EDIT_TOOLS.has(toolName)) return null;
try {
const state = await Editor.Message.request("preview", "query-info");
if (state && (state as any).running) {
return err(`"${toolName}" はプレビュー中に実行できません。先にプレビューを停止してください。`);
}
} catch { /* query failed — allow execution */ }
return null;
}
private async createNode(name: string, parent?: string, components?: string[]): Promise<ToolResult> {
try {
// Use Editor API to create node
const uuid = await Editor.Message.request("scene", "create-node", {
parent: parent || undefined,
name,
assetUuid: undefined,
});
// Wait until the node is queryable in the scene process
await this.waitForNode(uuid);
// Add components if specified
if (components && components.length > 0) {
for (const comp of components) {
await this.sceneScript("addComponentToNode", [uuid, comp]);
// Wait until the component is reflected in query-node
await this.waitForComponent(uuid, comp);
}
}
return ok({ success: true, uuid, name });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* Wait until a node becomes queryable in the scene process.
* Editor.Message.request("scene", "create-node") returns before the node
* is fully registered in the scene hierarchy, so subsequent scene script
* calls (findNode) may fail without this wait.
*/
private async waitForNode(uuid: string, maxRetries = 10, intervalMs = 100): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
const result = await this.sceneScript("getNodeInfo", [uuid]);
if (result?.success) return;
} catch { /* not ready yet */ }
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
// Don't throw — let the caller proceed and get a more specific error if needed
}
/**
* Wait until a component added via addComponentToNode is reflected in query-node.
* sceneScript returns before the Editor API (query-node) reflects the change,
* so polling is needed to avoid race conditions in subsequent tool calls.
*/
private async waitForComponent(nodeUuid: string, componentType: string, maxRetries = 10, intervalMs = 100): Promise<void> {
const normalizedType = componentType.startsWith("cc.") ? componentType.substring(3) : componentType;
for (let i = 0; i < maxRetries; i++) {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", nodeUuid);
const comps: any[] = nodeDump?.__comps__ || [];
const found = comps.some((c) =>
c.type === componentType || c.type === `cc.${normalizedType}` || c.type === normalizedType
);
if (found) return;
} catch { /* not ready yet */ }
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
// Don't throw — component may still work; let caller get a specific error if needed
}
private async createNodeTree(parentUuid: string, spec: any): Promise<ToolResult> {
try {
const result = await this.sceneScript("buildNodeTree", [parentUuid, spec]);
if (!result?.success) return err(result?.error || "buildNodeTree failed");
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getNodeInfo(uuid: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("getNodeInfo", [uuid]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async findByName(name: string): Promise<ToolResult> {
try {
const result = await this.sceneScript("findNodesByName", [name]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async setProperty(uuid: string, property: string, value: any): Promise<ToolResult> {
try {
const result = await this.sceneScript("setNodeProperty", [uuid, property, value]);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
private async setTransform(uuid: string, position?: any, rotation?: any, scale?: any): Promise<ToolResult> {
try {
const results: any[] = [];
if (position) {
results.push(await this.sceneScript("setNodeProperty", [uuid, "position", position]));
}
if (rotation) {
results.push(await this.sceneScript("setNodeProperty", [uuid, "rotation", rotation]));
}
if (scale) {
results.push(await this.sceneScript("setNodeProperty", [uuid, "scale", scale]));
}
const anyFailed = results.find((r) => !r.success);
if (anyFailed) return ok(anyFailed);
return ok({ success: true, uuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async deleteNode(uuid: string): Promise<ToolResult> {
try {
await Editor.Message.request("scene", "remove-node", { uuid });
return ok({ success: true, uuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async moveNode(uuid: string, parentUuid: string): Promise<ToolResult> {
try {
await (Editor.Message.request as any)("scene", "set-property", {
uuid,
path: "parent",
dump: { type: "cc.Node", value: { uuid: parentUuid } },
});
return ok({ success: true, uuid, parentUuid });
} catch (e: any) {
// Fallback: try scene script
try {
const result = await this.sceneScript("moveNode", [uuid, parentUuid]);
return ok(result);
} catch (e2: any) {
return err(e.message || String(e));
}
}
}
private async duplicateNode(uuid: string): Promise<ToolResult> {
try {
const result = await Editor.Message.request("scene", "duplicate-node", uuid);
// duplicate-node returns an array of UUIDs
const newUuid = Array.isArray(result) ? result[0] : result;
return ok({ success: true, sourceUuid: uuid, newUuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getAllNodes(): Promise<ToolResult> {
try {
const result = await this.sceneScript("getAllNodes", []);
return ok(result);
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* UITransform + Widget + color/opacity をまとめて設定する。
* Widget の値を指定すると、対応する isAlign* フラグを自動で true にする。
*/
private async setLayout(args: Record<string, any>): Promise<ToolResult> {
try {
// nodeName → uuid 解決
let uuid = args.uuid;
if (!uuid && args.nodeName) {
const resolved = await resolveNodeUuid({ nodeName: args.nodeName });
uuid = resolved.uuid;
}
if (!uuid) return err("Either 'uuid' or 'nodeName' is required");
const results: any[] = [];
// UITransform の設定
const contentSize = parseMaybeJson(args.contentSize);
const anchorPoint = parseMaybeJson(args.anchorPoint);
if (contentSize || anchorPoint) {
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
const comps = nodeInfo.data?.components || [];
const utIdx = comps.findIndex((c: any) => c.type === "UITransform");
if (utIdx < 0) return err("Node has no UITransform component");
if (contentSize) {
const path = `__comps__.${utIdx}.contentSize`;
const dump = { value: { width: { value: contentSize.width }, height: { value: contentSize.height } } };
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: "contentSize", success: r?.success !== false });
}
if (anchorPoint) {
const path = `__comps__.${utIdx}.anchorPoint`;
const dump = { value: { x: { value: anchorPoint.x }, y: { value: anchorPoint.y } } };
const r = await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: "anchorPoint", success: r?.success !== false });
}
}
// Widget の設定
const widget = parseMaybeJson(args.widget);
if (widget) {
// Widget コンポーネントを探す(なければ追加)
let nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
if (!nodeInfo?.success) return err(`Node ${uuid} not found`);
let comps = nodeInfo.data?.components || [];
let wIdx = comps.findIndex((c: any) => c.type === "Widget");
if (wIdx < 0) {
await this.sceneScript("addComponentToNode", [uuid, "cc.Widget"]);
// 再取得
nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
comps = nodeInfo.data?.components || [];
wIdx = comps.findIndex((c: any) => c.type === "Widget");
if (wIdx < 0) return err("Failed to add Widget component");
results.push({ property: "Widget", action: "added" });
}
// isAlign* を自動設定(値があれば true にする)
const alignMap: Record<string, string> = {
top: "isAlignTop", bottom: "isAlignBottom",
left: "isAlignLeft", right: "isAlignRight",
horizontalCenter: "isAlignHorizontalCenter",
verticalCenter: "isAlignVerticalCenter",
};
for (const [key, value] of Object.entries(widget)) {
// isAlign* を明示指定した場合はそのまま設定
const path = `__comps__.${wIdx}.${key}`;
if (typeof value === "boolean") {
const dump = { value, type: "Boolean" };
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: `Widget.${key}`, success: true });
} else if (typeof value === "number") {
// まず対応する isAlign* を true にする
const alignKey = alignMap[key];
if (alignKey && widget[alignKey] === undefined) {
const alignPath = `__comps__.${wIdx}.${alignKey}`;
await this.sceneScript("setPropertyViaEditor", [uuid, alignPath, { value: true, type: "Boolean" }]);
}
const dump = { value, type: "Number" };
await this.sceneScript("setPropertyViaEditor", [uuid, path, dump]);
results.push({ property: `Widget.${key}`, success: true });
}
}
// _alignFlags を isAlign* 現在値から再計算して設定
// (Editor が isAlign* 変更時に _alignFlags を自動更新しないバグの対処)
try {
const ALIGN_BITS: Record<string, number> = {
isAlignLeft: 1, isAlignRight: 2, isAlignTop: 4, isAlignBottom: 8,
isAlignHorizontalCenter: 16, isAlignVerticalCenter: 32,
};
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", uuid);
if (nodeDump) {
const wCompDump = nodeDump.__comps__?.[wIdx];
if (wCompDump) {
let alignFlags = 0;
for (const [key, bit] of Object.entries(ALIGN_BITS)) {
if (wCompDump.value?.[key]?.value === true) alignFlags |= bit;
}
const flagPath = `__comps__.${wIdx}._alignFlags`;
await this.sceneScript("setPropertyViaEditor", [uuid, flagPath, { value: alignFlags, type: "Number" }]);
results.push({ property: "Widget._alignFlags", value: alignFlags });
}
}
} catch (_e) {
// _alignFlags 再計算の失敗は致命的でないため無視
}
}
// color
const color = parseMaybeJson(args.color);
if (color) {
const r = await this.sceneScript("setNodeProperty", [uuid, "color", color]);
results.push({ property: "color", success: r?.success !== false });
}
// opacity
if (args.opacity !== undefined) {
// cc.UIOpacity を使う(なければ color.a で設定)
const nodeInfo = await this.sceneScript("getNodeInfo", [uuid]);
const comps = nodeInfo?.data?.components || [];
const opIdx = comps.findIndex((c: any) => c.type === "UIOpacity");
if (opIdx >= 0) {
const path = `__comps__.${opIdx}.opacity`;
await this.sceneScript("setPropertyViaEditor", [uuid, path, { value: args.opacity, type: "Number" }]);
results.push({ property: "UIOpacity.opacity", success: true });
} else {
// UIOpacity がない場合は color.a を直接設定
const currentColor = nodeInfo?.data?.color || { r: 255, g: 255, b: 255, a: 255 };
currentColor.a = args.opacity;
const r = await this.sceneScript("setNodeProperty", [uuid, "color", currentColor]);
results.push({ property: "color.a", success: r?.success !== false });
}
}
const allOk = results.every(r => r.success !== false);
let response: any = { success: allOk, uuid, results };
// screenshot
if (args.screenshot) {
try {
const ss = await takeEditorScreenshot();
response.screenshot = { path: ss.path, size: ss.savedSize };
} catch (ssErr: any) {
response.screenshotError = ssErr.message || String(ssErr);
}
}
return ok(response);
} catch (e: any) {
return err(e.message || String(e));
}
}
/** Call a scene script method */
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method,
args,
});
}
}
@@ -0,0 +1,680 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { ensureSceneSafeToSwitch, safeSaveScene } from "./scene-tools";
import { parseMaybeJson } from "../utils";
import type { ComponentTools } from "./component-tools";
import fs from "fs";
import path from "path";
import crypto from "crypto";
const EXT_NAME = "cocos-creator-mcp";
/** prefab_instantiate で配置したネスト Prefab 情報を記憶 */
interface NestedPrefabEntry {
nodeUuid: string;
prefabAssetUuid: string;
parentUuid: string;
}
export class PrefabTools implements ToolCategory {
readonly categoryName = "prefab";
private _pendingNestedPrefabs: NestedPrefabEntry[] = [];
/** prefab_open で開いた Prefab アセット UUID */
private _currentPrefabUuid: string | null = null;
private _componentTools: ComponentTools | null;
constructor(componentTools?: ComponentTools) {
this._componentTools = componentTools ?? null;
}
getTools(): ToolDefinition[] {
return [
{
name: "prefab_list",
description: "List all prefab files in the project.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "prefab_create",
description: "Create a prefab from an existing node in the scene. The node remains in the scene.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID to create prefab from" },
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
},
required: ["uuid", "path"],
},
},
{
name: "prefab_instantiate",
description: "Instantiate a prefab into the scene.",
inputSchema: {
type: "object",
properties: {
prefabUuid: { type: "string", description: "Prefab asset UUID" },
parent: { type: "string", description: "Parent node UUID (optional, defaults to scene root)" },
},
required: ["prefabUuid"],
},
},
{
name: "prefab_get_info",
description: "Get information about a prefab asset (name, path, UUID).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Prefab asset UUID" },
},
required: ["uuid"],
},
},
{
name: "prefab_update",
description: "Update (re-save) a prefab from its instance node in the scene.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID of the prefab instance in the scene" },
},
required: ["uuid"],
},
},
{
name: "prefab_duplicate",
description: "Duplicate a prefab asset to a new path.",
inputSchema: {
type: "object",
properties: {
source: { type: "string", description: "Source prefab db:// path" },
destination: { type: "string", description: "Destination db:// path" },
},
required: ["source", "destination"],
},
},
{
name: "prefab_validate",
description: "Validate a prefab for missing references or broken links.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Prefab asset UUID" },
},
required: ["uuid"],
},
},
{
name: "prefab_revert",
description: "Revert a prefab instance node to its original prefab state.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID of the prefab instance" },
},
required: ["uuid"],
},
},
{
name: "prefab_create_and_replace",
description: "Create a prefab from a node AND replace the original node with a prefab instance. This is the recommended way to extract a nested prefab — one command instead of create → delete → instantiate.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID to create prefab from" },
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
},
required: ["uuid", "path"],
},
},
{
name: "prefab_open",
description: "Open a prefab in editing mode. Equivalent to double-clicking the prefab in CocosCreator. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Prefab asset UUID" },
path: { type: "string", description: "Prefab db:// path (alternative to uuid)" },
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
},
},
},
{
name: "prefab_close",
description: "Save and close the current prefab editing mode, then return to the main scene. Returns an error if the current prefab is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
save: { type: "boolean", description: "Save prefab before closing (default true)" },
sceneUuid: { type: "string", description: "Scene UUID to return to (default: project's start scene or first scene)" },
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
},
},
},
{
name: "prefab_create_from_spec",
description: "Create a prefab from a JSON spec in one call. Combines node_create_tree + component_auto_bind + prefab_create into a single operation. Spec extends node_create_tree format with optional autoBind field. Example: { name: 'MyPopup', components: ['cc.UITransform', 'MyPopupView'], autoBind: 'MyPopupView', children: [{ name: 'CloseButton', components: ['cc.Button'] }] }",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "db:// path for the prefab (e.g. 'db://assets/prefabs/MyPrefab.prefab')" },
spec: { description: "Node tree specification with optional autoBind field (string for component type to auto-bind)" },
autoBindMode: { type: "string", enum: ["fuzzy", "strict"], description: "Auto-bind matching mode (default: fuzzy)" },
},
required: ["path", "spec"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
switch (toolName) {
case "prefab_list":
return this.listPrefabs();
case "prefab_create":
return this.createPrefab(args.uuid, args.path);
case "prefab_instantiate":
return this.instantiatePrefab(args.prefabUuid, args.parent);
case "prefab_get_info":
return this.getPrefabInfo(args.uuid);
case "prefab_update":
return this.updatePrefab(args.uuid);
case "prefab_duplicate": {
try {
await (Editor.Message.request as any)("asset-db", "copy-asset", args.source, args.destination);
return ok({ success: true, source: args.source, destination: args.destination });
} catch (e: any) { return err(e.message || String(e)); }
}
case "prefab_validate": {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", args.uuid);
const deps = await (Editor.Message.request as any)("asset-db", "query-depends", args.uuid).catch(() => []);
return ok({ success: true, uuid: args.uuid, info, dependencies: deps, valid: !!info });
} catch (e: any) { return err(e.message || String(e)); }
}
case "prefab_revert":
return this.revertPrefab(args.uuid);
case "prefab_create_and_replace":
return this.createAndReplace(args.uuid, args.path);
case "prefab_open":
return this.openPrefab(args.uuid, args.path, !!args.force);
case "prefab_close":
return this.closePrefab(args.save !== false, args.sceneUuid, !!args.force);
case "prefab_create_from_spec":
return this.createFromSpec(args.path, parseMaybeJson(args.spec), args.autoBindMode ?? "fuzzy");
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async listPrefabs(): Promise<ToolResult> {
try {
const results = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/**/*.prefab",
});
const prefabs = (results || []).map((a: any) => ({
uuid: a.uuid,
path: a.path || a.url,
name: a.name,
}));
return ok({ success: true, prefabs });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async createPrefab(nodeUuid: string, path: string): Promise<ToolResult> {
try {
// 既存Prefabがある場合は警告を返す(上書きダイアログでタイムアウトするため)
const existing = await this.assetExists(path);
if (existing) {
return err(
`Prefab already exists at "${path}". Use prefab_update instead to update an existing prefab. ` +
`Workflow: 1) prefab_instantiate to place in scene, 2) modify properties, 3) prefab_update to save.`
);
}
const result = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
return ok({ success: true, nodeUuid, path, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async assetExists(path: string): Promise<boolean> {
try {
const pattern = path.replace(/\.prefab$/, "") + ".*";
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
return (results || []).length > 0;
} catch {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
return !!info;
} catch {
return false;
}
}
}
private async instantiatePrefab(prefabUuid: string, parent?: string): Promise<ToolResult> {
try {
const nodeUuid = await Editor.Message.request("scene", "create-node", {
parent: parent || undefined,
assetUuid: prefabUuid,
});
// Prefab 編集モード中の場合、ネスト Prefab 情報を記憶
// prefab_update 時に JSON 後処理で asset/instance/nestedPrefabInstanceRoots を設定
if (parent) {
this._pendingNestedPrefabs.push({
nodeUuid,
prefabAssetUuid: prefabUuid,
parentUuid: parent,
});
}
return ok({ success: true, nodeUuid, prefabUuid });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async updatePrefab(nodeUuid: string): Promise<ToolResult> {
try {
const result = await (Editor.Message.request as any)("scene", "apply-prefab", nodeUuid);
// ネスト Prefab の JSON 後処理
if (this._pendingNestedPrefabs.length > 0) {
await this._fixNestedPrefabJson(nodeUuid);
}
return ok({ success: true, nodeUuid, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* prefab_update 後に Prefab JSON を後処理して、ネスト Prefab 参照を正しく設定する.
*/
private async _fixNestedPrefabJson(_rootNodeUuid: string): Promise<void> {
if (this._pendingNestedPrefabs.length === 0) return;
try {
// シーンを保存して Prefab JSON を書き出す
// 現在シーンが untitled (scene-2d) の場合、save-scene はダイアログを出すので
// safeSaveScene でスキップする。untitled でシーンにインスタンスが居るケースは
// 本来 prefab_open モードでのみ発生するので save が効く想定だが、
// テスト等で直接呼ばれた場合の保護として skip する。
const saved = await safeSaveScene();
if (!saved) {
console.warn(
"[PrefabTools] _fixNestedPrefabJson: save-scene skipped (untitled scene). " +
"Nested prefab JSON post-processing may be incomplete."
);
return;
}
await new Promise(r => setTimeout(r, 1500));
// prefab_open で記憶した UUID からファイルパスを取得
if (!this._currentPrefabUuid) return;
const prefabPath = await (Editor.Message.request as any)(
"asset-db", "query-path", this._currentPrefabUuid
);
if (!prefabPath) return;
if (!fs.existsSync(prefabPath)) return;
const data = JSON.parse(fs.readFileSync(prefabPath, "utf-8"));
// 各ネスト Prefab エントリを処理
for (const entry of this._pendingNestedPrefabs) {
// fileId でノードを検索(nodeUuid はシーン内 UUID、Prefab JSON 内では fileId)
let flpNodeIdx = -1;
for (let i = 0; i < data.length; i++) {
if (data[i].__type__ === "cc.PrefabInfo" && data[i].fileId === entry.nodeUuid) {
// この PrefabInfo を持つノードを探す
for (let j = 0; j < data.length; j++) {
if (data[j]._prefab?.__id__ === i) {
flpNodeIdx = j;
break;
}
}
break;
}
}
// fileId で見つからない場合、ノード名で検索
if (flpNodeIdx < 0) {
// Prefab アセット名を取得
const assetInfo = await (Editor.Message.request as any)("asset-db", "query-asset-info", entry.prefabAssetUuid);
const assetName = assetInfo?.name?.replace(".prefab", "") || "";
for (let i = 0; i < data.length; i++) {
if (data[i].__type__ === "cc.Node" && (data[i]._name === assetName || data[i]._name === undefined)) {
const prefabIdx = data[i]._prefab?.__id__;
if (prefabIdx != null && data[prefabIdx]?.asset?.__id__ === 0 && !data[prefabIdx]?.instance) {
flpNodeIdx = i;
break;
}
}
}
}
if (flpNodeIdx < 0) continue;
const prefabInfoIdx = data[flpNodeIdx]._prefab?.__id__;
if (prefabInfoIdx == null) continue;
// PrefabInfo を修正
const prefabInfo = data[prefabInfoIdx];
prefabInfo.root = { __id__: flpNodeIdx };
prefabInfo.asset = {
__uuid__: entry.prefabAssetUuid,
__expectedType__: "cc.Prefab",
};
// PrefabInstance を追加
if (!prefabInfo.instance) {
const instanceIdx = data.length;
data.push({
__type__: "cc.PrefabInstance",
fileId: crypto.randomBytes(16).toString("base64").replace(/[+/=]/g, "").substring(0, 22),
prefabRootNode: { __id__: 1 }, // Prefab 編集モードのルート
mountedChildren: [],
mountedComponents: [],
propertyOverrides: [],
removedComponents: [],
});
prefabInfo.instance = { __id__: instanceIdx };
}
// 子ノード・コンポーネントをクリア(Prefab アセットから復元される)
data[flpNodeIdx]._children = [];
data[flpNodeIdx]._components = [];
// ルートの nestedPrefabInstanceRoots に追加
const rootPrefabIdx = data[1]._prefab?.__id__;
if (rootPrefabIdx != null) {
const rootPrefab = data[rootPrefabIdx];
if (!rootPrefab.nestedPrefabInstanceRoots) {
rootPrefab.nestedPrefabInstanceRoots = [];
}
const alreadyNested = rootPrefab.nestedPrefabInstanceRoots.some(
(r: any) => r?.__id__ === flpNodeIdx
);
if (!alreadyNested) {
rootPrefab.nestedPrefabInstanceRoots.push({ __id__: flpNodeIdx });
}
}
}
fs.writeFileSync(prefabPath, JSON.stringify(data, null, 2), "utf-8");
this._pendingNestedPrefabs = [];
} catch (e: any) {
console.warn("[PrefabTools] _fixNestedPrefabJson failed:", e.message);
}
}
private async revertPrefab(nodeUuid: string): Promise<ToolResult> {
try {
const result = await (Editor.Message.request as any)("scene", "revert-prefab", nodeUuid);
return ok({ success: true, nodeUuid, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getPrefabInfo(uuid: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
return ok({ success: true, info });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async createAndReplace(nodeUuid: string, path: string): Promise<ToolResult> {
try {
// 1. Check if prefab already exists
const existing = await this.assetExists(path);
if (existing) {
return err(
`Prefab already exists at "${path}". Delete it first or use a different path.`
);
}
// 2. Get node info (parent, sibling index, transform) before creating prefab
const nodeInfo = await this.sceneScript("getNodeInfo", [nodeUuid]);
if (!nodeInfo?.success) {
return err(`Node ${nodeUuid} not found`);
}
const parentUuid = nodeInfo.data?.parent;
// 3. Create prefab from the node
const prefabAssetUuid = await (Editor.Message.request as any)("scene", "create-prefab", nodeUuid, path);
if (!prefabAssetUuid) {
return err("create-prefab returned no asset UUID");
}
// 4. Delete the original node
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
// 5. Instantiate the prefab at the same parent
const newNodeUuid = await Editor.Message.request("scene", "create-node", {
parent: parentUuid || undefined,
assetUuid: prefabAssetUuid,
});
return ok({
success: true,
prefabAssetUuid,
prefabPath: path,
originalNodeUuid: nodeUuid,
newInstanceUuid: newNodeUuid,
});
} catch (e: any) {
return err(e.message || String(e));
}
}
private async openPrefab(uuid?: string, path?: string, force: boolean = false): Promise<ToolResult> {
try {
// Prefab を開くときも内部的にシーン切替が発生するので dirty untitled チェック
await ensureSceneSafeToSwitch(force);
// Resolve UUID from path if needed
let assetUuid = uuid;
if (!assetUuid && path) {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", path);
assetUuid = info?.uuid;
}
if (!assetUuid) {
return err("Either uuid or path is required");
}
// Open prefab in editing mode (equivalent to double-click)
await (Editor.Message.request as any)("asset-db", "open-asset", assetUuid);
// Wait for prefab editing mode to initialize
await new Promise(r => setTimeout(r, 1000));
this._currentPrefabUuid = assetUuid;
this._pendingNestedPrefabs = [];
return ok({ success: true, uuid: assetUuid, mode: "prefab-edit" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async closePrefab(save: boolean, sceneUuid?: string, force: boolean = false): Promise<ToolResult> {
try {
// 1. Save prefab if requested
// prefab edit モード中は "current scene" = 編集中 Prefab なので save-scene は Prefab を保存する。
// ただし untitled フォールバックに当たった場合はダイアログ回避のためスキップする。
if (save) {
await safeSaveScene();
await new Promise(r => setTimeout(r, 500));
}
// 2. Determine which scene to return to
let targetScene = sceneUuid;
if (!targetScene) {
// Try project's start scene
try {
targetScene = await (Editor as any).Profile.getConfig("preview", "general.start_scene", "local");
} catch { /* ignore */ }
// Fallback to first scene
if (!targetScene || targetScene === "current_scene") {
const scenes = await Editor.Message.request("asset-db", "query-assets", {
ccType: "cc.SceneAsset",
pattern: "db://assets/**/*",
});
if (Array.isArray(scenes) && scenes.length > 0) {
targetScene = scenes[0].uuid;
}
}
}
// 3. Open the scene
if (targetScene) {
// prefab edit モードから戻る遷移もダイアログが出うる
await ensureSceneSafeToSwitch(force);
await (Editor.Message.request as any)("scene", "open-scene", targetScene);
await new Promise(r => setTimeout(r, 1000));
}
return ok({ success: true, returnedToScene: targetScene });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async createFromSpec(prefabPath: string, spec: any, autoBindMode: string): Promise<ToolResult> {
try {
// 1. 既存 Prefab チェック
const existing = await this.assetExists(prefabPath);
if (existing) {
return err(
`Prefab already exists at "${prefabPath}". Delete it first or use a different path.`
);
}
// 2. シーンの最初の cc.Node UUID を取得(Scene UUID ではなく Canvas 等)
const hier = await this.sceneScript("getSceneHierarchy", [false]);
const hierarchy = hier?.hierarchy || [];
const firstNode = hierarchy[0];
if (!firstNode?.uuid) return err("Could not find a node in the current scene to use as parent");
const parentUuid = firstNode.uuid;
// 3. ノードツリーを構築
const autoBind = spec.autoBind;
const cleanSpec = { ...spec };
delete cleanSpec.autoBind;
const treeResult = await this.sceneScript("buildNodeTree", [parentUuid, cleanSpec]);
if (!treeResult?.success) return err(treeResult?.error || "buildNodeTree failed");
const nodeUuid = treeResult.data?.uuid;
if (!nodeUuid) return err("buildNodeTree returned no root node UUID");
// 4. フォント・SpriteFrame を Editor API 経由で設定(アセット依存追跡のため)
await this._applyDefaultAssets(nodeUuid);
// 5. autoBind 実行 (旧4)
let autoBindResult: any = null;
if (autoBind) {
if (!this._componentTools) {
return err("autoBind requires ComponentTools dependency (internal configuration error)");
}
const bindToolResult = await this._componentTools.execute("component_auto_bind", {
uuid: nodeUuid,
componentType: autoBind,
force: false,
mode: autoBindMode,
});
try {
autoBindResult = JSON.parse(bindToolResult.content[0].text);
} catch { autoBindResult = bindToolResult; }
}
// 6. Prefab 作成
const prefabAssetUuid = await (Editor.Message.request as any)(
"scene", "create-prefab", nodeUuid, prefabPath
);
if (!prefabAssetUuid) {
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
return err("create-prefab returned no asset UUID");
}
// 7. 一時ノードを削除
await (Editor.Message.request as any)("scene", "remove-node", { uuid: nodeUuid });
return ok({
success: true,
prefabAssetUuid,
path: prefabPath,
nodeTree: treeResult.data,
autoBind: autoBindResult,
});
} catch (e: any) {
return err(e.message || String(e));
}
}
/**
* buildNodeTree で作成したノードツリーの Label にプロジェクトフォントを設定する。
* Editor API (scene:set-property) 経由で設定することでアセット依存が正しく追跡される。
*/
private async _applyDefaultAssets(rootUuid: string): Promise<void> {
// プロジェクトのデフォルトフォントを検索(resources/fonts/ 配下の TTFFont)
let fontUuid: string | null = null;
try {
const assets = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/resources/fonts/**",
ccType: "cc.TTFFont",
});
if (Array.isArray(assets) && assets.length > 0) {
fontUuid = assets[0].uuid;
}
} catch { /* ignore */ }
if (!fontUuid) return;
// 全子孫ノードを取得
const descendants = await this.sceneScript("getAllDescendants", [rootUuid]);
if (!descendants?.success) return;
const allNodes = [{ uuid: rootUuid, name: "root" }, ...descendants.data];
for (const node of allNodes) {
try {
const nodeDump = await (Editor.Message.request as any)("scene", "query-node", node.uuid);
if (!nodeDump) continue;
const comps = nodeDump.__comps__ || [];
for (let i = 0; i < comps.length; i++) {
const compType = comps[i].type || "";
// Label にフォント設定
if (compType === "cc.Label") {
const fontDump = comps[i].value?.font;
if (!fontDump?.value?.uuid) {
await (Editor.Message.request as any)("scene", "set-property", {
uuid: node.uuid,
path: `__comps__.${i}.font`,
dump: { type: "cc.TTFFont", value: { uuid: fontUuid } },
});
}
}
}
} catch { /* skip nodes that can't be queried */ }
}
}
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: "cocos-creator-mcp",
method,
args,
});
}
}
@@ -0,0 +1,84 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class PreferencesTools implements ToolCategory {
readonly categoryName = "preferences";
getTools(): ToolDefinition[] {
return [
{
name: "preferences_get",
description: "Get a preference value by key.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name (e.g. 'general', 'builder', 'engine')" },
key: { type: "string", description: "Preference key" },
},
required: ["protocol", "key"],
},
},
{
name: "preferences_set",
description: "Set a preference value.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name" },
key: { type: "string", description: "Preference key" },
value: { description: "Value to set" },
},
required: ["protocol", "key", "value"],
},
},
{
name: "preferences_get_all",
description: "Get all preferences for a given protocol.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name (e.g. 'general')" },
},
required: ["protocol"],
},
},
{
name: "preferences_reset",
description: "Reset a preference to its default value.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Protocol name" },
key: { type: "string", description: "Preference key to reset" },
},
required: ["protocol", "key"],
},
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "preferences_get": {
const value = Editor.Profile.getConfig(args.protocol, args.key);
return ok({ success: true, protocol: args.protocol, key: args.key, value });
}
case "preferences_set":
Editor.Profile.setConfig(args.protocol, args.key, args.value);
return ok({ success: true, protocol: args.protocol, key: args.key });
case "preferences_get_all": {
const config = Editor.Profile.getConfig(args.protocol);
return ok({ success: true, protocol: args.protocol, config });
}
case "preferences_reset":
Editor.Profile.removeConfig(args.protocol, args.key);
return ok({ success: true, protocol: args.protocol, key: args.key });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,167 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class ProjectTools implements ToolCategory {
readonly categoryName = "project";
getTools(): ToolDefinition[] {
return [
{
name: "project_get_info",
description: "Get project information (name, path, engine version).",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "project_refresh_assets",
description: "Refresh the asset database to detect file changes.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "project_get_asset_info",
description: "Get information about an asset by UUID.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Asset UUID" },
},
required: ["uuid"],
},
},
{
name: "project_find_asset",
description: "Find assets by name pattern (glob). Returns matching asset paths and UUIDs.",
inputSchema: {
type: "object",
properties: {
pattern: { type: "string", description: "Glob pattern (e.g. 'db://assets/**/*.ts', 'db://assets/**/Button*')" },
},
required: ["pattern"],
},
},
{
name: "project_get_settings",
description: "Get project settings for a given protocol.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string", description: "Settings protocol (e.g. 'general', 'engine')" },
},
},
},
{
name: "project_set_settings",
description: "Set a project setting.",
inputSchema: {
type: "object",
properties: {
protocol: { type: "string" },
key: { type: "string" },
value: {},
},
required: ["protocol", "key", "value"],
},
},
{
name: "project_get_engine_info",
description: "Get engine version and path information.",
inputSchema: { type: "object", properties: {} },
},
{
name: "project_query_scripts",
description: "Query all script plugins in the project.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
switch (toolName) {
case "project_get_info":
return this.getInfo();
case "project_refresh_assets":
return this.refreshAssets();
case "project_get_asset_info":
return this.getAssetInfo(args.uuid);
case "project_find_asset":
return this.findAsset(args.pattern);
case "project_get_settings": {
try {
const config = await (Editor.Message.request as any)("project", "query-config", args.protocol || "general");
return ok({ success: true, config });
} catch (e: any) { return err(e.message || String(e)); }
}
case "project_set_settings": {
try {
await (Editor.Message.request as any)("project", "set-config", args.protocol, args.key, args.value);
return ok({ success: true });
} catch (e: any) { return err(e.message || String(e)); }
}
case "project_get_engine_info": {
try {
const info = await (Editor.Message.request as any)("engine", "query-info");
return ok({ success: true, info });
} catch (e: any) { return err(e.message || String(e)); }
}
case "project_query_scripts": {
try {
const scripts = await (Editor.Message.request as any)("programming", "query-sorted-plugins");
return ok({ success: true, scripts });
} catch (e: any) { return err(e.message || String(e)); }
}
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async getInfo(): Promise<ToolResult> {
try {
return ok({
success: true,
name: Editor.Project.name,
path: Editor.Project.path,
tmpDir: Editor.Project.tmpDir,
});
} catch (e: any) {
return err(e.message || String(e));
}
}
private async refreshAssets(): Promise<ToolResult> {
try {
await Editor.Message.request("asset-db", "refresh-asset", "db://assets");
return ok({ success: true });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getAssetInfo(uuid: string): Promise<ToolResult> {
try {
const info = await (Editor.Message.request as any)("asset-db", "query-asset-info", uuid);
return ok({ success: true, info });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async findAsset(pattern: string): Promise<ToolResult> {
try {
const results = await Editor.Message.request("asset-db", "query-assets", { pattern });
const assets = (results || []).map((a: any) => ({
uuid: a.uuid,
path: a.path || a.url,
name: a.name,
type: a.type,
}));
return ok({ success: true, assets });
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,150 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class ReferenceImageTools implements ToolCategory {
readonly categoryName = "referenceImage";
getTools(): ToolDefinition[] {
return [
{
name: "refimage_add",
description: "Add a reference image to the scene view.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Image file path or db:// path" },
},
required: ["path"],
},
},
{
name: "refimage_remove",
description: "Remove a reference image by index.",
inputSchema: {
type: "object",
properties: {
index: { type: "number", description: "Image index" },
},
required: ["index"],
},
},
{
name: "refimage_list",
description: "List all reference images in the scene view.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_clear_all",
description: "Remove all reference images.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_switch",
description: "Switch to a specific reference image by index.",
inputSchema: {
type: "object",
properties: {
index: { type: "number", description: "Image index to switch to" },
},
required: ["index"],
},
},
{
name: "refimage_set_position",
description: "Set the position of the current reference image.",
inputSchema: {
type: "object",
properties: {
x: { type: "number" },
y: { type: "number" },
},
required: ["x", "y"],
},
},
{
name: "refimage_set_scale",
description: "Set the scale of the current reference image.",
inputSchema: {
type: "object",
properties: {
scale: { type: "number", description: "Scale factor" },
},
required: ["scale"],
},
},
{
name: "refimage_set_opacity",
description: "Set the opacity of the current reference image.",
inputSchema: {
type: "object",
properties: {
opacity: { type: "number", description: "Opacity (0-255)" },
},
required: ["opacity"],
},
},
{
name: "refimage_query_config",
description: "Get the current reference image configuration.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_query_current",
description: "Get info about the currently active reference image.",
inputSchema: { type: "object", properties: {} },
},
{
name: "refimage_refresh",
description: "Refresh the reference image display.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "refimage_add":
await (Editor.Message.request as any)("scene", "add-reference-image", args.path);
return ok({ success: true, path: args.path });
case "refimage_remove":
await (Editor.Message.request as any)("scene", "remove-reference-image", args.index);
return ok({ success: true, index: args.index });
case "refimage_list": {
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
return ok({ success: true, config });
}
case "refimage_clear_all":
await (Editor.Message.request as any)("scene", "clear-all-reference-images");
return ok({ success: true });
case "refimage_switch":
await (Editor.Message.request as any)("scene", "switch-reference-image", args.index);
return ok({ success: true, index: args.index });
case "refimage_set_position":
await (Editor.Message.request as any)("scene", "set-reference-image-position", args.x, args.y);
return ok({ success: true, x: args.x, y: args.y });
case "refimage_set_scale":
await (Editor.Message.request as any)("scene", "set-reference-image-scale", args.scale);
return ok({ success: true, scale: args.scale });
case "refimage_set_opacity":
await (Editor.Message.request as any)("scene", "set-reference-image-opacity", args.opacity);
return ok({ success: true, opacity: args.opacity });
case "refimage_query_config": {
const config = await (Editor.Message.request as any)("scene", "query-reference-image-config").catch(() => null);
return ok({ success: true, config });
}
case "refimage_query_current": {
const current = await (Editor.Message.request as any)("scene", "query-current-reference-image").catch(() => null);
return ok({ success: true, current });
}
case "refimage_refresh":
await (Editor.Message.request as any)("scene", "refresh-reference-image");
return ok({ success: true });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,635 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { ensureSceneSafeToSwitch } from "./scene-tools";
const EXT_NAME = "cocos-creator-mcp";
export class SceneAdvancedTools implements ToolCategory {
readonly categoryName = "sceneAdvanced";
getTools(): ToolDefinition[] {
return [
{
name: "scene_execute_script",
description: "Execute a scene script method by name with arguments.",
inputSchema: {
type: "object",
properties: {
method: { type: "string", description: "Scene script method name" },
args: { type: "array", description: "Arguments to pass", items: {} },
},
required: ["method"],
},
},
{
name: "scene_snapshot",
description: "Take a snapshot of the current scene state (for undo).",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_dirty",
description: "Check if the current scene has unsaved changes.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_classes",
description: "Query all available component classes in the scene.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_components",
description: "Query available components for a given node.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "scene_query_node_tree",
description: "Query the raw node tree from the editor (alternative to scene_get_hierarchy).",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_nodes_by_asset",
description: "Find all nodes that reference a given asset UUID.",
inputSchema: {
type: "object",
properties: {
assetUuid: { type: "string", description: "Asset UUID to search for" },
},
required: ["assetUuid"],
},
},
{
name: "scene_soft_reload",
description: "Soft reload the current scene without losing state.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_reset_node_transform",
description: "Reset a node's transform to default (position 0,0,0 / rotation 0,0,0 / scale 1,1,1).",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "scene_copy_node",
description: "Copy a node to clipboard.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node UUID" },
},
required: ["uuid"],
},
},
{
name: "scene_paste_node",
description: "Paste node from clipboard under a parent.",
inputSchema: {
type: "object",
properties: {
parentUuid: { type: "string", description: "Parent node UUID" },
},
required: ["parentUuid"],
},
},
{
name: "scene_create",
description: "Create a new empty 2D scene. If path is omitted, uses the editor's built-in new-scene command (may not work on CC 3.8.x). If path is specified, creates a .scene file via asset-db as a fallback. Returns an error if the current scene is dirty and untitled (to avoid modal save dialog); pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
path: { type: "string", description: "Scene asset path (e.g. 'db://assets/scenes/NewScene.scene'). If omitted, uses editor's new-scene command." },
force: { type: "boolean", description: "Skip dirty-scene preflight check (may trigger modal save dialog)" },
},
},
},
{
name: "scene_cut_node",
description: "Cut a node to clipboard (removes from scene).",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Node UUID" } },
required: ["uuid"],
},
},
{
name: "scene_reset_property",
description: "Reset a specific property on a node or component to its default value.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node or component UUID" },
path: { type: "string", description: "Property path (e.g. 'position', 'color')" },
},
required: ["uuid", "path"],
},
},
{
name: "scene_reset_component",
description: "Reset a component to its default state.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Component UUID" } },
required: ["uuid"],
},
},
{
name: "scene_execute_component_method",
description: "Call a method on a component at edit-time.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Component UUID" },
method: { type: "string", description: "Method name" },
args: { type: "array", description: "Method arguments", items: {} },
},
required: ["uuid", "method"],
},
},
{
name: "scene_move_array_element",
description: "Move an array element to a new position in a property.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node or component UUID" },
path: { type: "string", description: "Array property path" },
target: { type: "number", description: "Current index" },
offset: { type: "number", description: "Move offset (+1 = down, -1 = up)" },
},
required: ["uuid", "path", "target", "offset"],
},
},
{
name: "scene_remove_array_element",
description: "Remove an element from an array property by index.",
inputSchema: {
type: "object",
properties: {
uuid: { type: "string", description: "Node or component UUID" },
path: { type: "string", description: "Array property path" },
index: { type: "number", description: "Index to remove" },
},
required: ["uuid", "path", "index"],
},
},
{
name: "scene_snapshot_abort",
description: "Abort the current undo snapshot.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_ready",
description: "Check if the scene is fully loaded and ready.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_query_component_has_script",
description: "Check if a component has an associated script file.",
inputSchema: {
type: "object",
properties: { name: { type: "string", description: "Component class name" } },
required: ["name"],
},
},
{
name: "scene_restore_prefab",
description: "Restore a prefab node to its original prefab state.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Node UUID" } },
required: ["uuid"],
},
},
{
name: "scene_begin_undo",
description: "Begin recording undo operations.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_end_undo",
description: "End undo recording and save the undo step.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_cancel_undo",
description: "Cancel the current undo recording.",
inputSchema: { type: "object", properties: {} },
},
{
name: "scene_save_as",
description: "Save the current scene to a new file (shows save dialog).",
inputSchema: { type: "object", properties: {} },
},
// ── 以下、既存MCP未対応のEditor API ──
{
name: "scene_set_parent",
description: "Reparent node(s) using the official Editor API (alternative to node_move).",
inputSchema: {
type: "object",
properties: {
uuids: { type: "array", items: { type: "string" }, description: "Node UUID(s) to move" },
parent: { type: "string", description: "New parent node UUID" },
keepWorldTransform: { type: "boolean", description: "Keep world position (default false)" },
},
required: ["uuids", "parent"],
},
},
{
name: "scene_query_node",
description: "Get a full property dump of a node (all serialized data).",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Node UUID" } },
required: ["uuid"],
},
},
{
name: "scene_query_component",
description: "Get a full property dump of a component by its UUID.",
inputSchema: {
type: "object",
properties: { uuid: { type: "string", description: "Component UUID" } },
required: ["uuid"],
},
},
{
name: "scene_query_scene_bounds",
description: "Get the bounding rect of the current scene.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "scene_execute_script":
return ok(await this.sceneScript(args.method, args.args || []));
case "scene_snapshot":
return ok(await (Editor.Message.request as any)("scene", "snapshot"));
case "scene_query_dirty": {
const dirty = await (Editor.Message.request as any)("scene", "query-dirty");
return ok({ success: true, dirty });
}
case "scene_query_classes": {
const classes = await (Editor.Message.request as any)("scene", "query-classes");
return ok({ success: true, classes });
}
case "scene_query_components": {
const comps = await (Editor.Message.request as any)("scene", "query-components", args.uuid);
return ok({ success: true, components: comps });
}
case "scene_query_node_tree": {
const tree = await Editor.Message.request("scene", "query-node-tree");
return ok({ success: true, tree });
}
case "scene_query_nodes_by_asset": {
const nodes = await (Editor.Message.request as any)("scene", "query-nodes-by-asset-uuid", args.assetUuid);
return ok({ success: true, nodes });
}
case "scene_soft_reload":
await (Editor.Message.request as any)("scene", "soft-reload");
return ok({ success: true });
case "scene_reset_node_transform":
return await this.resetTransform(args.uuid);
case "scene_copy_node":
await (Editor.Message.request as any)("scene", "copy-node", args.uuid);
return ok({ success: true, uuid: args.uuid });
case "scene_paste_node": {
const result = await (Editor.Message.request as any)("scene", "paste-node", args.parentUuid);
return ok({ success: true, result });
}
case "scene_create":
return this.createScene(args.path, !!args.force);
case "scene_cut_node":
await (Editor.Message.request as any)("scene", "cut-node", args.uuid);
return ok({ success: true, uuid: args.uuid });
case "scene_reset_property":
await (Editor.Message.request as any)("scene", "reset-property", { uuid: args.uuid, path: args.path });
return ok({ success: true });
case "scene_reset_component":
await (Editor.Message.request as any)("scene", "reset-component", { uuid: args.uuid });
return ok({ success: true });
case "scene_execute_component_method": {
const result = await (Editor.Message.request as any)("scene", "execute-component-method", { uuid: args.uuid, name: args.method, args: args.args || [] });
return ok({ success: true, result });
}
case "scene_move_array_element":
await (Editor.Message.request as any)("scene", "move-array-element", { uuid: args.uuid, path: args.path, target: args.target, offset: args.offset });
return ok({ success: true });
case "scene_remove_array_element":
await (Editor.Message.request as any)("scene", "remove-array-element", { uuid: args.uuid, path: args.path, index: args.index });
return ok({ success: true });
case "scene_snapshot_abort":
await (Editor.Message.request as any)("scene", "snapshot-abort");
return ok({ success: true });
case "scene_query_ready": {
const ready = await (Editor.Message.request as any)("scene", "query-is-ready");
return ok({ success: true, ready });
}
case "scene_query_component_has_script": {
const hasScript = await (Editor.Message.request as any)("scene", "query-component-has-script", args.name);
return ok({ success: true, name: args.name, hasScript });
}
case "scene_restore_prefab":
await (Editor.Message.request as any)("scene", "restore-prefab", { uuid: args.uuid });
return ok({ success: true, uuid: args.uuid });
case "scene_begin_undo":
await (Editor.Message.request as any)("scene", "begin-recording");
return ok({ success: true });
case "scene_end_undo":
await (Editor.Message.request as any)("scene", "end-recording");
return ok({ success: true });
case "scene_cancel_undo":
await (Editor.Message.request as any)("scene", "cancel-recording");
return ok({ success: true });
case "scene_save_as": {
const result = await (Editor.Message.request as any)("scene", "save-as-scene");
return ok({ success: true, result });
}
case "scene_set_parent":
await (Editor.Message.request as any)("scene", "set-parent", {
parent: args.parent,
uuids: args.uuids,
keepWorldTransform: args.keepWorldTransform || false,
});
return ok({ success: true });
case "scene_query_node": {
const dump = await (Editor.Message.request as any)("scene", "query-node", args.uuid);
return ok({ success: true, node: dump });
}
case "scene_query_component": {
const dump = await (Editor.Message.request as any)("scene", "query-component", args.uuid);
return ok({ success: true, component: dump });
}
case "scene_query_scene_bounds": {
const bounds = await (Editor.Message.request as any)("scene", "query-scene-bounds");
return ok({ success: true, bounds });
}
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
private async resetTransform(uuid: string): Promise<ToolResult> {
const result = await this.sceneScript("setNodeProperty", [uuid, "position", { x: 0, y: 0, z: 0 }]);
await this.sceneScript("setNodeProperty", [uuid, "rotation", { x: 0, y: 0, z: 0 }]);
await this.sceneScript("setNodeProperty", [uuid, "scale", { x: 1, y: 1, z: 1 }]);
return ok({ success: true, uuid });
}
private async createScene(path?: string, force: boolean = false): Promise<ToolResult> {
// ダイアログ割り込み防止: 現在シーンが dirty かつ untitled の場合は事前エラー
try { await ensureSceneSafeToSwitch(force); }
catch (e: any) { return err(e.message || String(e)); }
// まず scene:new-scene を試行(path 未指定時のみ)
if (!path) {
try {
await (Editor.Message.request as any)("scene", "new-scene");
return ok({ success: true });
} catch (e: any) {
const msg = e?.message || String(e);
if (msg.includes("Message does not exist") || msg.includes("scene - new-scene")) {
// CC 3.8.x → asset-db fallback にフォール
const fallbackPath = await this.generateAvailableScenePath();
return this.createSceneViaAssetDb(fallbackPath);
}
return err(msg);
}
}
// path 指定 → asset-db fallback
return this.createSceneViaAssetDb(path);
}
private async generateAvailableScenePath(): Promise<string> {
const basePath = "db://assets/NewScene.scene";
try {
const result = await (Editor.Message.request as any)("asset-db", "generate-available-url", basePath);
if (result) return result;
} catch { /* fallback */ }
return `db://assets/NewScene_${Date.now()}.scene`;
}
private async createSceneViaAssetDb(path: string): Promise<ToolResult> {
try {
if (!path.endsWith(".scene")) path += ".scene";
const sceneName = path.split("/").pop()!.replace(".scene", "");
const uid = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
const sid = () => {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
let s = "";
for (let i = 0; i < 21; i++) s += chars[Math.floor(Math.random() * chars.length)];
return s;
};
const sceneJson = this.buildMinimalSceneJson(sceneName, uid, sid);
const content = JSON.stringify(sceneJson, null, 2);
await (Editor.Message.request as any)("asset-db", "create-asset", path, content);
// シーンを開く
try {
// ensureSceneSafeToSwitch は createScene 入口で既に通過済みなのでここでは再チェックしない
const queryResult = await (Editor.Message.request as any)("asset-db", "query-uuid", path);
if (queryResult) {
await (Editor.Message.request as any)("scene", "open-scene", queryResult);
}
} catch { /* open failure is not critical */ }
return ok({ success: true, path, method: "asset-db-fallback" });
} catch (e: any) {
return err(e.message || String(e));
}
}
private buildMinimalSceneJson(name: string, uid: () => string, sid: () => string): any[] {
const sceneId = uid();
const canvasNodeId = sid();
const cameraNodeId = sid();
const vec3 = (x: number, y: number, z: number) => ({ __type__: "cc.Vec3", x, y, z });
const quat = () => ({ __type__: "cc.Quat", x: 0, y: 0, z: 0, w: 1 });
return [
// [0] SceneAsset
{
__type__: "cc.SceneAsset",
_name: name,
_objFlags: 0,
__editorExtras__: {},
_native: "",
scene: { __id__: 1 },
},
// [1] Scene
{
__type__: "cc.Scene",
_name: name,
_objFlags: 0,
__editorExtras__: {},
_parent: null,
_children: [{ __id__: 2 }],
_active: true,
_components: [],
_prefab: null,
_lpos: vec3(0, 0, 0),
_lrot: quat(),
_lscale: vec3(1, 1, 1),
_mobility: 0,
_layer: 1073741824,
_euler: vec3(0, 0, 0),
autoReleaseAssets: false,
_globals: { __id__: 10 },
_id: sceneId,
},
// [2] Canvas node
{
__type__: "cc.Node",
_name: "Canvas",
_objFlags: 0,
__editorExtras__: {},
_parent: { __id__: 1 },
_children: [{ __id__: 3 }],
_active: true,
_components: [{ __id__: 5 }, { __id__: 6 }, { __id__: 7 }],
_prefab: null,
_lpos: vec3(0, 0, 0),
_lrot: quat(),
_lscale: vec3(1, 1, 1),
_mobility: 0,
_layer: 33554432,
_euler: vec3(0, 0, 0),
_id: canvasNodeId,
},
// [3] Camera node
{
__type__: "cc.Node",
_name: "Camera",
_objFlags: 0,
__editorExtras__: {},
_parent: { __id__: 2 },
_children: [],
_active: true,
_components: [{ __id__: 4 }],
_prefab: null,
_lpos: vec3(0, 0, 1000),
_lrot: quat(),
_lscale: vec3(1, 1, 1),
_mobility: 0,
_layer: 1073741824,
_euler: vec3(0, 0, 0),
_id: cameraNodeId,
},
// [4] Camera component
{
__type__: "cc.Camera",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 3 },
_enabled: true,
_projection: 1,
_priority: 0,
_fov: 45,
_fovAxis: 0,
_orthoHeight: 10,
_near: 1,
_far: 2000,
_color: { __type__: "cc.Color", r: 0, g: 0, b: 0, a: 255 },
_depth: 1,
_stencil: 0,
_clearFlags: 6,
_rect: { __type__: "cc.Rect", x: 0, y: 0, width: 1, height: 1 },
_visibility: 1108344832,
_id: "",
},
// [5] UITransform on Canvas
{
__type__: "cc.UITransform",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 2 },
_enabled: true,
_contentSize: { __type__: "cc.Size", width: 720, height: 1280 },
_anchorPoint: { __type__: "cc.Vec2", x: 0.5, y: 0.5 },
_id: "",
},
// [6] Canvas component
{
__type__: "cc.Canvas",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 2 },
_enabled: true,
_cameraComponent: { __id__: 4 },
_alignCanvasWithScreen: true,
_id: "",
},
// [7] Widget on Canvas (fullscreen)
{
__type__: "cc.Widget",
_name: "",
_objFlags: 0,
__editorExtras__: {},
node: { __id__: 2 },
_enabled: true,
_alignFlags: 15,
_target: null,
_left: 0,
_right: 0,
_top: 0,
_bottom: 0,
_isAbsLeft: true,
_isAbsRight: true,
_isAbsTop: true,
_isAbsBottom: true,
_originalWidth: 0,
_originalHeight: 0,
_id: "",
},
// [8] cc.PrefabInfo for scene
// [9] (reserved)
// [10] SceneGlobals
{
__type__: "cc.SceneGlobals",
ambient: { __id__: 11 },
shadows: { __id__: 12 },
_skybox: { __id__: 13 },
fog: { __id__: 14 },
},
// [11] AmbientInfo
{ __type__: "cc.AmbientInfo", _skyLightingColor: { __type__: "cc.Vec4", x: 0.2, y: 0.2, z: 0.2, w: 1 } },
// [12] ShadowsInfo
{ __type__: "cc.ShadowsInfo" },
// [13] SkyboxInfo
{ __type__: "cc.SkyboxInfo" },
// [14] FogInfo
{ __type__: "cc.FogInfo" },
];
}
private async sceneScript(method: string, args: any[]): Promise<any> {
return Editor.Message.request("scene", "execute-scene-script", {
name: EXT_NAME,
method,
args,
});
}
}
@@ -0,0 +1,361 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
const EXT_NAME = "cocos-creator-mcp";
/** シーン名から「untitled (保存先なし)」かどうかを判定 */
const UNTITLED_SCENE_NAMES = new Set(["scene-2d", "scene-3d", "Untitled", "NewScene", ""]);
/**
* 現在のシーンの dirty 状態を取得。
* CC バージョンによって query-dirty / query-is-dirty のどちらか(または両方)が存在するので両方試行。
*/
export async function queryCurrentSceneDirty(): Promise<boolean> {
try {
const r = await (Editor.Message.request as any)("scene", "query-dirty");
return !!r;
} catch { /* try alternate */ }
try {
const r = await (Editor.Message.request as any)("scene", "query-is-dirty");
return !!r;
} catch { /* assume clean if both fail */ }
return false;
}
/**
* 現在のシーン情報(名前・UUID)を取得。
*/
export async function queryCurrentSceneInfo(): Promise<{ sceneName: string; sceneUuid: string }> {
try {
const result: any = await Editor.Message.request(
"scene",
"execute-scene-script",
{ name: EXT_NAME, method: "getSceneHierarchy", args: [false] }
);
return { sceneName: result?.sceneName || "", sceneUuid: result?.sceneUuid || "" };
} catch {
return { sceneName: "", sceneUuid: "" };
}
}
/**
* 現在のシーンが untitled (scene-2d 等) かを判定。
*/
export async function isCurrentSceneUntitled(): Promise<boolean> {
const { sceneName } = await queryCurrentSceneInfo();
return UNTITLED_SCENE_NAMES.has(sceneName);
}
/**
* save-scene をダイアログ安全に呼び出す。
*
* - 現在のシーンが untitled (scene-2d 等) → no-op(ダイアログ防止)
* - それ以外 → save-scene を実行
*
* 任意の MCP ツール内部で save-scene を呼ぶ場合は必ずこれ経由にする。
* 直接 `Editor.Message.request("scene", "save-scene")` を呼ぶと、
* 現在シーンが untitled のときにモーダルダイアログが出て MCP が固まる。
*
* @returns 実際に保存を実行したか
*/
export async function safeSaveScene(): Promise<boolean> {
if (await isCurrentSceneUntitled()) {
console.warn(
"[cocos-creator-mcp] safeSaveScene: current scene is untitled, " +
"skipping save-scene to avoid modal dialog."
);
return false;
}
await (Editor.Message.request as any)("scene", "save-scene");
return true;
}
/**
* シーン切替系ツール(scene_open/close/new など)の前処理。
*
* - clean → OK
* - dirty + 保存先ありのシーン → save-scene で自動保存
* - dirty + untitled シーン → ダイアログを自動応答("Don't Save")して変更を破棄
*
* 目的: "Save changes?" ダイアログで MCP がブロックされるのを防ぐ。
*/
export async function ensureSceneSafeToSwitch(force: boolean = false): Promise<void> {
const isDirty = await queryCurrentSceneDirty();
if (!isDirty) return;
const { sceneName } = await queryCurrentSceneInfo();
const isUntitled = UNTITLED_SCENE_NAMES.has(sceneName);
if (isUntitled) {
// untitled + dirty: ダイアログが出るとMCPがブロックされるので、
// Electron dialog をパッチして「保存しない」を自動応答する。
// パッチは次回のシーン切替(呼び出し元の scene_open 等)でダイアログが出る瞬間に効く。
patchDialogForDiscard();
console.warn(
`[cocos-creator-mcp] ensureSceneSafeToSwitch: untitled scene "${sceneName}" ` +
`is dirty — dialog will auto-respond "Don't Save" to avoid blocking MCP.`
);
return;
}
try {
// ここに来る時点で untitled ではない(上で判定済み)ので直接 save-scene OK
await (Editor.Message.request as any)("scene", "save-scene");
} catch (e: any) {
if (force) {
console.warn(
`[cocos-creator-mcp] ensureSceneSafeToSwitch: save-scene failed but force=true — proceeding. ` +
`Error: ${e.message || e}`
);
return;
}
throw new Error(
`Failed to auto-save dirty scene "${sceneName}" before switch: ${e.message || e}. ` +
`Save manually and retry, or pass force=true to bypass.`
);
}
}
/**
* Electron の dialog.showMessageBox(Sync) を一時的にパッチして、
* 次回のダイアログで「保存しない (Don't Save)」を自動選択する。
*
* CocosCreator の "Save changes?" ダイアログは通常:
* buttons: ["Save", "Cancel", "Don't Save"] → index 2 = Don't Save
* または:
* buttons: ["Save", "Don't Save", "Cancel"] → index 1 = Don't Save
*
* ボタンテキストから "Don't Save" / "not" / "discard" を探し、
* 見つからなければ最後のボタン(通常 Don't Save)を選択する。
*/
function patchDialogForDiscard(): void {
try {
const electron = require("electron");
const dialog = electron.dialog;
const origSync = dialog.showMessageBoxSync;
const origAsync = dialog.showMessageBox;
function findDiscardIndex(buttons: string[]): number {
if (!buttons || buttons.length === 0) return 0;
const idx = buttons.findIndex((b: string) => {
const lower = b.toLowerCase();
return lower.includes("don't save") || lower.includes("not")
|| lower.includes("discard") || lower.includes("保存しない");
});
return idx >= 0 ? idx : buttons.length - 1;
}
// Sync version
dialog.showMessageBoxSync = function (...args: any[]) {
dialog.showMessageBoxSync = origSync; // 1回で復元
const options = args.length > 1 ? args[1] : args[0];
const buttons = options?.buttons || [];
const result = findDiscardIndex(buttons);
console.warn(`[cocos-creator-mcp] dialog auto-responded: button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
return result;
};
// Async version
dialog.showMessageBox = function (...args: any[]) {
dialog.showMessageBox = origAsync; // 1回で復元
const options = args.length > 1 ? args[1] : args[0];
const buttons = options?.buttons || [];
const result = findDiscardIndex(buttons);
console.warn(`[cocos-creator-mcp] dialog auto-responded (async): button[${result}]="${buttons[result] || "?"}" (buttons: ${JSON.stringify(buttons)})`);
return Promise.resolve({ response: result, checkboxChecked: false });
};
// 安全策: 5秒後に未使用なら復元(次のダイアログが出なかったケース)
setTimeout(() => {
if (dialog.showMessageBoxSync !== origSync) {
dialog.showMessageBoxSync = origSync;
}
if (dialog.showMessageBox !== origAsync) {
dialog.showMessageBox = origAsync;
}
}, 5000);
} catch (e: any) {
console.error(`[cocos-creator-mcp] patchDialogForDiscard failed: ${e.message || e}`);
}
}
export class SceneTools implements ToolCategory {
readonly categoryName = "scene";
getTools(): ToolDefinition[] {
return [
{
name: "scene_get_hierarchy",
description: "Get the node hierarchy of the current scene. Returns tree structure with uuid, name, active, and optionally components.",
inputSchema: {
type: "object",
properties: {
includeComponents: {
type: "boolean",
description: "Include component info for each node",
},
},
},
},
{
name: "scene_open",
description: "Open a scene by its asset UUID or database path (e.g. 'db://assets/scenes/Main.scene'). If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog that would block MCP. Pass force=true to bypass this guard.",
inputSchema: {
type: "object",
properties: {
scene: {
type: "string",
description: "Scene UUID or db:// path",
},
force: {
type: "boolean",
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
},
},
required: ["scene"],
},
},
{
name: "scene_save",
description: "Save the currently open scene.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "scene_get_list",
description: "List all scene files in the project.",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "scene_close",
description: "Close the current scene. If the current scene is dirty and untitled, returns an error instead of triggering a modal save dialog. Pass force=true to bypass.",
inputSchema: {
type: "object",
properties: {
force: {
type: "boolean",
description: "Skip dirty-scene preflight check (may trigger modal save dialog)",
},
},
},
},
{
name: "scene_get_current",
description: "Get the name and UUID of the currently open scene.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
switch (toolName) {
case "scene_get_hierarchy":
return this.getHierarchy(args.includeComponents ?? false);
case "scene_open":
return this.openScene(args.scene, !!args.force);
case "scene_save":
return this.saveScene();
case "scene_get_list":
return this.getSceneList();
case "scene_close":
try {
await ensureSceneSafeToSwitch(!!args.force);
await (Editor.Message.request as any)("scene", "close-scene");
return ok({ success: true });
} catch (e: any) { return err(e.message || String(e)); }
case "scene_get_current":
return this.getHierarchy(false).then((r) => {
const parsed = JSON.parse(r.content[0].text);
return ok({ success: true, sceneName: parsed.sceneName, sceneUuid: parsed.sceneUuid });
}).catch((e) => err(String(e)));
default:
return err(`Unknown tool: ${toolName}`);
}
}
private async getHierarchy(includeComponents: boolean): Promise<ToolResult> {
try {
const result = await Editor.Message.request(
"scene",
"execute-scene-script",
{
name: EXT_NAME,
method: "getSceneHierarchy",
args: [includeComponents],
}
);
return ok(result);
} catch (e: any) {
// Fallback: use query-node-tree
try {
const tree = await Editor.Message.request("scene", "query-node-tree");
return ok({ success: true, hierarchy: tree });
} catch (e2: any) {
return err(e2.message || String(e2));
}
}
}
private async openScene(scene: string, force: boolean): Promise<ToolResult> {
try {
await ensureSceneSafeToSwitch(force);
await Editor.Message.request("asset-db", "open-asset", scene);
return ok({ success: true, scene });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async saveScene(): Promise<ToolResult> {
try {
// 現在のシーンが保存済みファイルか確認(新規シーンの場合はダイアログが出るのでスキップ)
const scenes = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/**/*.scene",
}).catch(() => []);
// シーン名を取得して既存シーンか判定
const hierarchy = await Editor.Message.request(
"scene", "execute-scene-script",
{ name: "cocos-creator-mcp", method: "getSceneHierarchy", args: [false] }
).catch(() => null);
const sceneName = hierarchy?.sceneName;
const isNewScene = !sceneName || sceneName === "scene-2d" || sceneName === "Untitled";
if (isNewScene) {
return ok({ success: true, note: "New/untitled scene, skip save to avoid dialog" });
}
// シーンがdirtyでない場合は保存不要
const isDirty = await (Editor.Message.request as any)("scene", "query-is-dirty").catch(() => true);
if (!isDirty) {
return ok({ success: true, note: "Scene not dirty, skip save" });
}
const result = await (Editor.Message.request as any)("scene", "save-scene", false);
return ok({ success: true, result });
} catch (e: any) {
return err(e.message || String(e));
}
}
private async getSceneList(): Promise<ToolResult> {
try {
const results = await Editor.Message.request("asset-db", "query-assets", {
pattern: "db://assets/**/*.scene",
});
const scenes = (results || []).map((a: any) => ({
uuid: a.uuid,
path: a.path || a.url,
name: a.name,
}));
return ok({ success: true, scenes });
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,232 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
export class SceneViewTools implements ToolCategory {
readonly categoryName = "sceneView";
getTools(): ToolDefinition[] {
return [
{
name: "view_change_gizmo_tool",
description: "Change the gizmo tool (move, rotate, scale, rect).",
inputSchema: {
type: "object",
properties: {
tool: { type: "string", enum: ["move", "rotate", "scale", "rect"], description: "Gizmo tool name" },
},
required: ["tool"],
},
},
{
name: "view_query_gizmo_tool",
description: "Get the currently active gizmo tool.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_change_gizmo_pivot",
description: "Change gizmo pivot mode (center or pivot).",
inputSchema: {
type: "object",
properties: {
pivot: { type: "string", enum: ["center", "pivot"], description: "Pivot mode" },
},
required: ["pivot"],
},
},
{
name: "view_query_gizmo_pivot",
description: "Get the current gizmo pivot mode.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_change_gizmo_coordinate",
description: "Change gizmo coordinate system (local or global).",
inputSchema: {
type: "object",
properties: {
coordinate: { type: "string", enum: ["local", "global"], description: "Coordinate system" },
},
required: ["coordinate"],
},
},
{
name: "view_query_gizmo_coordinate",
description: "Get the current gizmo coordinate system.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_change_mode_2d_3d",
description: "Switch between 2D and 3D view mode.",
inputSchema: {
type: "object",
properties: {
mode: { type: "string", enum: ["2d", "3d"], description: "View mode" },
},
required: ["mode"],
},
},
{
name: "view_query_mode_2d_3d",
description: "Get the current 2D/3D view mode.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_set_grid_visible",
description: "Show or hide the scene grid.",
inputSchema: {
type: "object",
properties: {
visible: { type: "boolean", description: "Whether to show the grid" },
},
required: ["visible"],
},
},
{
name: "view_query_grid_visible",
description: "Check if the scene grid is visible.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_focus_on_node",
description: "Focus the scene camera on specific node(s).",
inputSchema: {
type: "object",
properties: {
uuids: { type: "array", items: { type: "string" }, description: "Node UUIDs to focus on" },
},
required: ["uuids"],
},
},
{
name: "view_get_status",
description: "Get the current scene view status (gizmo tool, pivot, coordinate, grid, etc.).",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_set_icon_gizmo_3d",
description: "Toggle 3D icon gizmos on/off.",
inputSchema: {
type: "object",
properties: { enabled: { type: "boolean" } },
required: ["enabled"],
},
},
{
name: "view_query_icon_gizmo_3d",
description: "Check if 3D icon gizmos are enabled.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_set_icon_gizmo_size",
description: "Set the icon gizmo size.",
inputSchema: {
type: "object",
properties: { size: { type: "number" } },
required: ["size"],
},
},
{
name: "view_query_icon_gizmo_size",
description: "Get the current icon gizmo size.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_align_with_view",
description: "Align the selected node with the current camera view.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_align_view_with_node",
description: "Align the camera view with the selected node.",
inputSchema: { type: "object", properties: {} },
},
{
name: "view_reset",
description: "Reset the scene view to default state.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "view_change_gizmo_tool":
await (Editor.Message.request as any)("scene", "change-gizmo-tool", args.tool);
return ok({ success: true, tool: args.tool });
case "view_query_gizmo_tool": {
const tool = await (Editor.Message.request as any)("scene", "query-gizmo-tool-name");
return ok({ success: true, tool });
}
case "view_change_gizmo_pivot":
await (Editor.Message.request as any)("scene", "change-gizmo-pivot", args.pivot);
return ok({ success: true, pivot: args.pivot });
case "view_query_gizmo_pivot": {
const pivot = await (Editor.Message.request as any)("scene", "query-gizmo-pivot");
return ok({ success: true, pivot });
}
case "view_change_gizmo_coordinate":
await (Editor.Message.request as any)("scene", "change-gizmo-coordinate", args.coordinate);
return ok({ success: true, coordinate: args.coordinate });
case "view_query_gizmo_coordinate": {
const coord = await (Editor.Message.request as any)("scene", "query-gizmo-coordinate");
return ok({ success: true, coordinate: coord });
}
case "view_change_mode_2d_3d":
await (Editor.Message.request as any)("scene", "change-view-mode-2d-3d", args.mode);
return ok({ success: true, mode: args.mode });
case "view_query_mode_2d_3d": {
const mode = await (Editor.Message.request as any)("scene", "query-view-mode-2d-3d");
return ok({ success: true, mode });
}
case "view_set_grid_visible":
await (Editor.Message.request as any)("scene", "set-grid-visible", args.visible);
return ok({ success: true, visible: args.visible });
case "view_query_grid_visible": {
const visible = await (Editor.Message.request as any)("scene", "query-grid-visible");
return ok({ success: true, visible });
}
case "view_focus_on_node":
await (Editor.Message.request as any)("scene", "focus-camera-on-nodes", args.uuids);
return ok({ success: true, uuids: args.uuids });
case "view_get_status": {
const [tool, pivot, coord, mode, grid] = await Promise.all([
(Editor.Message.request as any)("scene", "query-gizmo-tool-name").catch(() => null),
(Editor.Message.request as any)("scene", "query-gizmo-pivot").catch(() => null),
(Editor.Message.request as any)("scene", "query-gizmo-coordinate").catch(() => null),
(Editor.Message.request as any)("scene", "query-view-mode-2d-3d").catch(() => null),
(Editor.Message.request as any)("scene", "query-grid-visible").catch(() => null),
]);
return ok({ success: true, tool, pivot, coordinate: coord, mode, gridVisible: grid });
}
case "view_set_icon_gizmo_3d":
await (Editor.Message.request as any)("scene", "set-icon-gizmo-3d", args.enabled);
return ok({ success: true, enabled: args.enabled });
case "view_query_icon_gizmo_3d": {
const enabled = await (Editor.Message.request as any)("scene", "query-is-icon-gizmo-3d");
return ok({ success: true, enabled });
}
case "view_set_icon_gizmo_size":
await (Editor.Message.request as any)("scene", "set-icon-gizmo-size", args.size);
return ok({ success: true, size: args.size });
case "view_query_icon_gizmo_size": {
const size = await (Editor.Message.request as any)("scene", "query-icon-gizmo-size");
return ok({ success: true, size });
}
case "view_align_with_view":
await (Editor.Message.request as any)("scene", "align-with-view");
return ok({ success: true });
case "view_align_view_with_node":
await (Editor.Message.request as any)("scene", "align-view-with-node");
return ok({ success: true });
case "view_reset":
await (Editor.Message.request as any)("scene", "reset-scene-view");
return ok({ success: true });
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,130 @@
import { ToolCategory, ToolDefinition, ToolResult } from "../types";
import { ok, err } from "../tool-base";
import { BUILD_HASH } from "../mcp-server";
export class ServerTools implements ToolCategory {
readonly categoryName = "server";
getTools(): ToolDefinition[] {
return [
{
name: "server_query_ip_list",
description: "Get the list of IP addresses the editor server is listening on.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_query_port",
description: "Get the port number of the editor's built-in server.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_get_status",
description: "Get the editor server status (IP, port, connectivity).",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_get_build_hash",
description: "Get the build hash of the MCP server. The hash is derived from the code content, so identical code always produces the same hash.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_check_connectivity",
description: "Check if the editor server is reachable.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_get_network_interfaces",
description: "Get detailed network interface information.",
inputSchema: { type: "object", properties: {} },
},
{
name: "server_check_code_sync",
description: "Check if the running MCP extension code matches the latest build. Compares the runtime BUILD_HASH with the hash of dist/ files. Returns whether extension reload or CC restart is needed.",
inputSchema: { type: "object", properties: {} },
},
];
}
async execute(toolName: string, _args: Record<string, any>): Promise<ToolResult> {
try {
switch (toolName) {
case "server_query_ip_list": {
const ips = await (Editor.Message.request as any)("server", "query-ip-list");
return ok({ success: true, ips });
}
case "server_query_port": {
const port = await (Editor.Message.request as any)("server", "query-port");
return ok({ success: true, port });
}
case "server_get_status": {
const [ips, port] = await Promise.all([
(Editor.Message.request as any)("server", "query-ip-list").catch(() => []),
(Editor.Message.request as any)("server", "query-port").catch(() => null),
]);
return ok({ success: true, ips, port, buildId: BUILD_HASH });
}
case "server_get_build_hash": {
return ok({ success: true, buildHash: BUILD_HASH });
}
case "server_check_connectivity": {
try {
const port = await (Editor.Message.request as any)("server", "query-port");
return ok({ success: true, reachable: true, port });
} catch {
return ok({ success: true, reachable: false });
}
}
case "server_get_network_interfaces": {
const os = require("os");
const interfaces = os.networkInterfaces();
return ok({ success: true, interfaces });
}
case "server_check_code_sync": {
try {
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const extDir = path.join(Editor.Project.path, "extensions", "cocos-creator-mcp", "dist");
if (!fs.existsSync(extDir)) {
return ok({ success: true, synced: false, note: "Extension dist/ not found", runtimeHash: BUILD_HASH });
}
const hash = crypto.createHash("sha256");
const collectJs = (dir: string, prefix = ""): string[] => {
let files: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) files = files.concat(collectJs(path.join(dir, entry.name), rel));
else if (entry.name.endsWith(".js")) files.push(rel);
}
return files;
};
for (const file of collectJs(extDir).sort()) {
let content = fs.readFileSync(path.join(extDir, file), "utf8");
// mcp-server.js の baked hash を postbuild 前と同じプレースホルダに逆置換
// (postbuild は __BUILD_HASH__ が残った状態でハッシュ計算しているため、同じ入力にする)
if (file === "mcp-server.js") {
content = content.replace(/exports\.BUILD_HASH = "[a-f0-9]{12}"/, 'exports.BUILD_HASH = "__BUILD_HASH__"');
}
hash.update(content.replace(/__BUILD_HASH__/g, ""));
}
const diskHash = hash.digest("hex").substring(0, 12);
const synced = diskHash === BUILD_HASH;
return ok({
success: true,
synced,
runtimeHash: BUILD_HASH,
diskHash,
action: synced ? "none" : "Extension reload or CC restart needed",
});
} catch (e: any) {
return ok({ success: true, synced: false, error: e.message, runtimeHash: BUILD_HASH });
}
}
default:
return err(`Unknown tool: ${toolName}`);
}
} catch (e: any) {
return err(e.message || String(e));
}
}
}
@@ -0,0 +1,56 @@
/** MCP Tool definition (JSON Schema based) */
export interface ToolDefinition {
name: string;
description: string;
inputSchema: {
type: "object";
properties: Record<string, any>;
required?: string[];
};
}
/** Result returned from tool execution */
export interface ToolResult {
content: Array<{ type: "text"; text: string }>;
isError?: boolean;
}
/** Interface that all tool categories must implement */
export interface ToolCategory {
readonly categoryName: string;
getTools(): ToolDefinition[];
execute(toolName: string, args: Record<string, any>): Promise<ToolResult>;
}
/** JSON-RPC 2.0 request */
export interface JsonRpcRequest {
jsonrpc: "2.0";
id?: string | number;
method: string;
params?: any;
}
/** JSON-RPC 2.0 response */
export interface JsonRpcResponse {
jsonrpc: "2.0";
id?: string | number | null;
result?: any;
error?: {
code: number;
message: string;
data?: any;
};
}
/** Server configuration */
export interface ServerConfig {
port: number;
autoStart: boolean;
autoArchiveRecordings: boolean;
}
export const DEFAULT_CONFIG: ServerConfig = {
port: 3000,
autoStart: false,
autoArchiveRecordings: false,
};
@@ -0,0 +1,10 @@
/**
* MCP クライアントが JSON オブジェクトを文字列化して送信する問題への共通対策.
* スキーマに type が未宣言のオブジェクト引数は文字列で届く場合がある.
*/
export function parseMaybeJson<T = any>(value: any): T {
if (typeof value === "string") {
try { return JSON.parse(value); } catch { /* string のまま */ }
}
return value;
}