Initial public release: cc-3-8-x-mcp

Cocos Creator 3.8.x MCP bridge extension with a built-in offline CLI.

Components:
- Editor extension: in-process MCP server exposing scene / asset-db /
  preview / local / editor-process-control tools
- stdio router: aggregates multiple editor instances on one machine,
  with shortName dedup
- offline CLI (cocos-mcp-cli): headless prefab read/write + a wrapper
  around the Cocos CLI build

Pure Node.js, zero third-party dependencies. Licensed under Apache-2.0.
This commit is contained in:
furao
2026-06-06 11:33:19 +08:00
commit 14c5b00f14
96 changed files with 15855 additions and 0 deletions
+302
View File
@@ -0,0 +1,302 @@
'use strict';
/**
* 最小 MCP Server 实现(JSON-RPC 2.0 over HTTP
*
* 协议参考:https://modelcontextprotocol.io/
* 只实现 MCP 客户端常用的方法,够 Claude Code / Cursor 调用即可:
* - initialize
* - tools/list
* - tools/call
* - resources/list
* - resources/read
* - ping
*
* 传输:streamable HTTP 子集
* - 唯一端点 POST /mcpbody 是 JSON-RPC 请求,response 是 JSON-RPC 响应
* - 不处理 SSE / session resumption(第一版足够)
* - 额外暴露 GET /status 用于面板自检
*/
var http = require('http');
var url = require('url');
var SERVER_INFO = {
name: 'cc-3-8-x-mcp',
version: '2.0.0',
};
var PROTOCOL_VERSION = '2024-11-05';
/**
* @typedef {Object} ToolDef
* @property {string} name
* @property {string} description
* @property {object} inputSchema JSON Schema
* @property {(args: object) => Promise<any>} handler
*/
function createServer(options) {
options = options || {};
var port = options.port || 7523;
var host = options.host || '127.0.0.1';
var logger = options.logger || console;
/** @type {Map<string, ToolDef>} */
var tools = new Map();
/** @type {Map<string, {uri:string, name:string, description:string, mimeType:string, read:()=>Promise<any>}>} */
var resources = new Map();
var httpServer = null;
var started = false;
var stats = {
startedAt: null,
requestCount: 0,
lastRequest: null,
lastError: null,
};
function registerTool(def) {
if (!def || !def.name || typeof def.handler !== 'function') {
throw new Error('invalid tool def');
}
tools.set(def.name, def);
}
function registerResource(def) {
if (!def || !def.uri || typeof def.read !== 'function') {
throw new Error('invalid resource def');
}
resources.set(def.uri, def);
}
/** 把 tool handler 抛错统一转成 JSON-RPC error payload */
async function callTool(name, args) {
var tool = tools.get(name);
if (!tool) {
var err = new Error('unknown tool: ' + name);
err.code = -32601;
throw err;
}
try {
var result = await tool.handler(args || {});
// MCP tools/call result shape: { content: [{type:'text', text:...}], isError?: boolean }
if (result && result.content) return result;
return {
content: [{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2),
}],
};
} catch (e) {
return {
content: [{
type: 'text',
text: 'Error: ' + (e && (e.stack || e.message) || String(e)),
}],
isError: true,
};
}
}
async function handleJsonRpc(req) {
// 批量请求
if (Array.isArray(req)) {
var out = [];
for (var i = 0; i < req.length; i++) {
var r = await handleJsonRpc(req[i]);
if (r) out.push(r);
}
return out;
}
if (!req || req.jsonrpc !== '2.0') {
return { jsonrpc: '2.0', id: (req && req.id) || null, error: { code: -32600, message: 'invalid request' } };
}
var id = req.id;
var method = req.method;
var params = req.params || {};
try {
var result;
switch (method) {
case 'initialize':
result = {
protocolVersion: PROTOCOL_VERSION,
serverInfo: SERVER_INFO,
capabilities: {
tools: { listChanged: false },
resources: { subscribe: false, listChanged: false },
logging: {},
},
};
break;
case 'initialized':
case 'notifications/initialized':
// notification, no response
if (id == null) return null;
result = {};
break;
case 'ping':
result = {};
break;
case 'tools/list':
result = {
tools: Array.from(tools.values()).map(function (t) {
return {
name: t.name,
description: t.description || '',
inputSchema: t.inputSchema || { type: 'object', properties: {} },
};
}),
};
break;
case 'tools/call':
result = await callTool(params.name, params.arguments);
break;
case 'resources/list':
result = {
resources: Array.from(resources.values()).map(function (r) {
return {
uri: r.uri,
name: r.name || r.uri,
description: r.description || '',
mimeType: r.mimeType || 'application/json',
};
}),
};
break;
case 'resources/read':
var uri = params.uri;
var res = resources.get(uri);
if (!res) {
throw Object.assign(new Error('unknown resource: ' + uri), { code: -32602 });
}
var content = await res.read();
result = {
contents: [{
uri: res.uri,
mimeType: res.mimeType || 'application/json',
text: typeof content === 'string' ? content : JSON.stringify(content, null, 2),
}],
};
break;
default:
throw Object.assign(new Error('method not found: ' + method), { code: -32601 });
}
// notification: id 缺失,不返回
if (id == null) return null;
return { jsonrpc: '2.0', id: id, result: result };
} catch (e) {
stats.lastError = { at: new Date().toISOString(), method: method, message: e.message };
if (id == null) return null;
return {
jsonrpc: '2.0',
id: id,
error: {
code: e.code || -32603,
message: e.message || 'internal error',
},
};
}
}
function handleHttp(httpReq, httpRes) {
var parsed = url.parse(httpReq.url, true);
var pathname = parsed.pathname || '/';
// CORS / 允许任意来源本机调试
httpRes.setHeader('Access-Control-Allow-Origin', '*');
httpRes.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
httpRes.setHeader('Access-Control-Allow-Headers', 'Content-Type, Mcp-Session-Id');
if (httpReq.method === 'OPTIONS') {
httpRes.writeHead(204);
httpRes.end();
return;
}
if (pathname === '/status' && httpReq.method === 'GET') {
httpRes.writeHead(200, { 'Content-Type': 'application/json' });
httpRes.end(JSON.stringify({
server: SERVER_INFO,
protocolVersion: PROTOCOL_VERSION,
toolCount: tools.size,
resourceCount: resources.size,
stats: stats,
}));
return;
}
if (pathname === '/mcp' && httpReq.method === 'POST') {
var chunks = [];
httpReq.on('data', function (c) { chunks.push(c); });
httpReq.on('end', async function () {
var raw = Buffer.concat(chunks).toString('utf-8');
var body;
try { body = JSON.parse(raw); }
catch (e) {
httpRes.writeHead(400, { 'Content-Type': 'application/json' });
httpRes.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
return;
}
stats.requestCount++;
stats.lastRequest = {
at: new Date().toISOString(),
method: body && body.method,
};
var response = await handleJsonRpc(body);
httpRes.writeHead(response == null ? 204 : 200, { 'Content-Type': 'application/json' });
httpRes.end(response == null ? '' : JSON.stringify(response));
});
return;
}
httpRes.writeHead(404, { 'Content-Type': 'text/plain' });
httpRes.end('not found');
}
function start() {
if (started) return Promise.resolve({ port: port, host: host });
return new Promise(function (resolve, reject) {
httpServer = http.createServer(handleHttp);
httpServer.on('error', function (e) {
if (!started) reject(e);
else logger.warn('[cc-mcp] server error:', e.message);
});
httpServer.listen(port, host, function () {
started = true;
stats.startedAt = new Date().toISOString();
logger.log('[cc-mcp] MCP server listening http://' + host + ':' + port + '/mcp');
resolve({ port: port, host: host });
});
});
}
function stop() {
if (!started || !httpServer) return Promise.resolve();
return new Promise(function (resolve) {
httpServer.close(function () {
started = false;
httpServer = null;
logger.log('[cc-mcp] MCP server stopped');
resolve();
});
});
}
return {
registerTool: registerTool,
registerResource: registerResource,
start: start,
stop: stop,
get started() { return started; },
get port() { return port; },
get host() { return host; },
get toolCount() { return tools.size; },
get resourceCount() { return resources.size; },
get stats() { return stats; },
};
}
module.exports = { createServer: createServer, PROTOCOL_VERSION: PROTOCOL_VERSION };
+378
View File
@@ -0,0 +1,378 @@
'use strict';
/**
* MCP Tools 注册表
* 每个 tool 透传到对应的 Editor.Message.request 或本地 helper。
* ctx 提供:editor message 封装、本地辅助函数。
*/
function defineTools(ctx) {
var msg = ctx.msg; // async (target, name, ...args) => result
var local = ctx.local; // { getPreviewUrl, doReimport, doRefreshPreview, doOpenPreview, doScreenshot, doRefreshAssets, doReloadScene, evalInPreview, listWorktrees, openDevDir, cleanDevDir, getStatus, getPanelConfig }
return [
// ── scene 域 ──
{
name: 'scene_query_node_tree',
description: '查询当前场景节点树。传 uuid 查子树,不传查根。',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string', description: '可选:节点 uuid' } },
},
handler: async function (args) {
return await msg('scene', 'query-node-tree', args.uuid);
},
},
{
name: 'scene_query_node',
description: '查询单个节点完整 dump(含所有组件属性)',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string' } },
required: ['uuid'],
},
handler: async function (args) {
return await msg('scene', 'query-node', args.uuid);
},
},
{
name: 'scene_set_property',
description: '设置节点/组件属性。path 是 dump path(如 "position" 或 "__comps__.0.string"),dump 是 {type,value}。\n⚠️ 注意:若目的是修改 prefab 资源文件的属性,建议改用 prefab_edit(offline,不需要编辑器运行,直接读写 .prefab 文件,支持嵌套 prefab override);scene_set_property 仅适用于修改运行时场景节点或需要编辑器上下文的场景。',
inputSchema: {
type: 'object',
properties: {
uuid: { type: 'string' },
path: { type: 'string' },
dump: { type: 'object' },
},
required: ['uuid', 'path', 'dump'],
},
handler: async function (args) {
return await msg('scene', 'set-property', {
uuid: args.uuid,
path: args.path,
dump: args.dump,
});
},
},
{
name: 'scene_open_scene',
description: '打开场景',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string', description: '场景资源 uuid' } },
required: ['uuid'],
},
handler: async function (args) {
return await msg('scene', 'open-scene', args.uuid);
},
},
{
name: 'scene_save_scene',
description: '保存当前场景',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return await msg('scene', 'save-scene');
},
},
{
name: 'scene_soft_reload',
description: '软重载场景(不清编辑器状态)',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return await msg('scene', 'soft-reload');
},
},
{
name: 'scene_execute_component_method',
description: '调用指定节点组件上的方法',
inputSchema: {
type: 'object',
properties: {
uuid: { type: 'string' },
name: { type: 'string', description: '方法名' },
args: { type: 'array', items: {} },
},
required: ['uuid', 'name'],
},
handler: async function (args) {
return await msg('scene', 'execute-component-method', {
uuid: args.uuid,
name: args.name,
args: args.args || [],
});
},
},
// ── asset-db 域 ──
{
name: 'asset_query_assets',
description: '按 pattern 列出资源(glob,如 db://assets/**/*.prefab',
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string' },
ccType: { type: 'string', description: '可选:cc 类型过滤,如 cc.Prefab' },
},
},
handler: async function (args) {
return await msg('asset-db', 'query-assets', {
pattern: args.pattern,
ccType: args.ccType,
});
},
},
{
name: 'asset_query_info',
description: '按 url 或 uuid 查询资源元数据',
inputSchema: {
type: 'object',
properties: {
urlOrUUID: { type: 'string' },
},
required: ['urlOrUUID'],
},
handler: async function (args) {
return await msg('asset-db', 'query-asset-info', args.urlOrUUID);
},
},
{
name: 'asset_query_url',
description: '由 uuid 反查 url',
inputSchema: {
type: 'object',
properties: { uuid: { type: 'string' } },
required: ['uuid'],
},
handler: async function (args) {
return await msg('asset-db', 'query-url', args.uuid);
},
},
{
name: 'asset_query_uuid',
description: '由 url 反查 uuid',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
handler: async function (args) {
return await msg('asset-db', 'query-uuid', args.url);
},
},
{
name: 'asset_refresh',
description: '刷新资源(可指定子目录)',
inputSchema: {
type: 'object',
properties: { url: { type: 'string', description: '默认 db://assets/' } },
},
handler: async function (args) {
return await msg('asset-db', 'refresh-asset', args.url || 'db://assets/');
},
},
{
name: 'asset_reimport',
description: '重新导入指定资源',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
handler: async function (args) {
return await msg('asset-db', 'reimport-asset', args.url);
},
},
{
name: 'asset_create',
description: '创建资源(文本内容)',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
content: { type: 'string' },
overwrite: { type: 'boolean' },
},
required: ['url', 'content'],
},
handler: async function (args) {
return await msg('asset-db', 'create-asset', args.url, args.content, { overwrite: !!args.overwrite });
},
},
{
name: 'asset_save',
description: '保存已有资源内容',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
content: { type: 'string' },
},
required: ['url', 'content'],
},
handler: async function (args) {
return await msg('asset-db', 'save-asset', args.url, args.content);
},
},
{
name: 'asset_delete',
description: '删除资源',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
},
handler: async function (args) {
return await msg('asset-db', 'delete-asset', args.url);
},
},
{
name: 'asset_move',
description: '移动资源',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string' },
target: { type: 'string' },
},
required: ['source', 'target'],
},
handler: async function (args) {
return await msg('asset-db', 'move-asset', args.source, args.target);
},
},
// ── preview 域 ──
{
name: 'preview_query_url',
description: '查询当前预览地址',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return { url: await local.getPreviewUrl() };
},
},
{
name: 'preview_open_browser',
description: '在系统默认浏览器打开预览',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
await local.doOpenPreview();
return 'ok';
},
},
{
name: 'preview_refresh_browser',
description: '刷新已打开的预览浏览器页面(AppleScript 驱动 Chrome/Safari',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
await local.doRefreshPreview();
return 'ok';
},
},
{
name: 'preview_screenshot',
description: '截图预览页面到指定路径(默认 .dev/screenshot.png),返回路径',
inputSchema: {
type: 'object',
properties: { outputPath: { type: 'string' } },
},
handler: async function (args) {
var p = args.outputPath || null;
return await local.doScreenshot(p);
},
},
{
name: 'preview_eval_js',
description: '向预览 Chrome 页面注入 JS 代码并返回执行结果',
inputSchema: {
type: 'object',
properties: { code: { type: 'string' } },
required: ['code'],
},
handler: async function (args) {
return await local.evalInPreview(args.code);
},
},
{
name: 'preview_refresh_and_reload',
description: '一键:刷新资源 + 软重载场景 + 刷新预览浏览器',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
await local.doRefreshAssets();
await local.doReloadScene();
await local.doRefreshPreview();
return 'ok';
},
},
// ── local 域 ──
{
name: 'local_get_status',
description: '获取插件本地状态(git 分支/HEAD、watchers、预览、命令日志)',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return await local.getStatus();
},
},
{
name: 'local_list_worktrees',
description: '扫描同机其他 worktree 的 dev-reload-info.json',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return local.listWorktrees();
},
},
{
name: 'local_open_dev_dir',
description: '在 Finder 打开 .dev 目录',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return local.openDevDir();
},
},
{
name: 'local_clean_dev_dir',
description: '清理 .dev 临时产物',
inputSchema: { type: 'object', properties: {} },
handler: async function () {
return local.cleanDevDir();
},
},
];
}
function defineResources(ctx) {
var msg = ctx.msg;
var local = ctx.local;
return [
{
uri: 'cocos://project/info',
name: 'Project Info',
description: '项目路径、名称、引擎版本',
mimeType: 'application/json',
read: async function () {
return await local.getStatus();
},
},
{
uri: 'cocos://scene/tree',
name: 'Current Scene Tree',
description: '当前场景节点树 dump',
mimeType: 'application/json',
read: async function () {
return await msg('scene', 'query-node-tree');
},
},
{
uri: 'cocos://preview/url',
name: 'Preview URL',
description: '当前预览地址',
mimeType: 'text/plain',
read: async function () {
return await local.getPreviewUrl();
},
},
];
}
module.exports = { defineTools: defineTools, defineResources: defineResources };