4 Commits
Author SHA1 Message Date
winlifes a903f7f0af Release v0.1.2 2026-04-30 18:12:29 +08:00
winlifes 8c46313ac6 Release v0.1.1 2026-04-16 17:47:57 +08:00
winlifes be53f3c56c Improve MCP startup flow and repo automation 2026-04-16 17:45:35 +08:00
winlifes 140d6295a8 Client config: fix VS Code path by platform 2026-04-16 17:06:26 +08:00
16 changed files with 719 additions and 42 deletions
+14
View File
@@ -0,0 +1,14 @@
## What changed
- Describe the change briefly
- Explain the user impact or motivation
## Checklist
- [ ] I tested the extension in a clean Cocos Creator 3.8+ project
- [ ] I verified `Funplay > MCP Server` opens and the MCP server can start correctly
- [ ] If I changed setup, one-click config, or port/config behavior, I verified the affected flow end-to-end
- [ ] I ran `npm run check`
- [ ] I updated docs for any user-facing behavior changes
- [ ] I did not commit local junk such as `.DS_Store`, `temp/`, or `library/`
- [ ] I updated `CHANGELOG.md` when the change affects users
+77
View File
@@ -0,0 +1,77 @@
name: CI
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Validate repository metadata
run: |
python - <<'PY'
import json
import pathlib
import sys
root = pathlib.Path('.')
errors = []
package_path = root / 'package.json'
try:
package = json.loads(package_path.read_text(encoding='utf-8'))
except Exception as exc:
errors.append(f"package.json is not valid JSON: {exc}")
else:
for key in ('name', 'version', 'main', 'package_version'):
if key not in package or package[key] in (None, ''):
errors.append(f"package.json is missing required key: {key}")
required_docs = [
'README.md',
'README_CN.md',
'LICENSE',
'CHANGELOG.md',
'CONTRIBUTING.md',
]
for relative in required_docs:
if not (root / relative).exists():
errors.append(f"Missing required repository file: {relative}")
forbidden_paths = []
for path in root.rglob('*'):
if '.git' in path.parts:
continue
if path.name == '.DS_Store' or '.idea' in path.parts or 'library' in path.parts or 'Library' in path.parts:
forbidden_paths.append(str(path))
if forbidden_paths:
errors.append('Repository contains local junk files:\n- ' + '\n- '.join(sorted(forbidden_paths)))
if errors:
print('Validation failed:\n')
for error in errors:
print(f'- {error}')
sys.exit(1)
print('Repository validation passed.')
PY
- name: Run syntax checks
run: npm run check
- name: Run tests
run: npm test
+41
View File
@@ -4,6 +4,47 @@ All notable changes to Funplay MCP for Cocos will be documented in this file.
This project follows a simple changelog format inspired by [Keep a Changelog](https://keepachangelog.com/), and uses semantic versioning when releases are tagged. This project follows a simple changelog format inspired by [Keep a Changelog](https://keepachangelog.com/), and uses semantic versioning when releases are tagged.
## [Unreleased]
## [0.1.2] - 2026-04-30
### Added
- Added Node.js unit tests for MCP protocol negotiation, tool profile exports, tool execution errors, and project file path safety.
### Changed
- Updated the MCP initialize response to negotiate protocol version `2025-11-25` by default while retaining compatibility with older supported protocol versions.
- Added `structuredContent` to tool call results when a tool returns structured JSON data.
- Changed tool execution failures to return MCP tool errors instead of JSON-RPC internal errors, improving client-side self-correction.
- Updated CI to run the new Node.js test suite.
### Security
- Restricted project file and asset-path resources to paths inside the active Cocos project root.
- Added HTTP request body size limits and invalid `Origin` header rejection for the embedded MCP server.
## [0.1.1] - 2026-04-16
### Added
- Added automatic port fallback when the configured MCP port is already occupied.
- Added actual-running-port reporting in MCP server status and panel state.
- Added `.github/pull_request_template.md` for repository contribution guidance.
- Added `.github/workflows/ci.yml` for lightweight GitHub validation.
- Added a lightweight GitHub Star promotion log after successful MCP server startup.
### Changed
- Updated one-click MCP client configuration to write the actual running server port instead of the requested port when port fallback is active.
- Updated the MCP panel status line to show configured-port to actual-port fallback information.
- Updated the English and Chinese README files to document automatic port fallback behavior.
### Fixed
- Fixed VS Code one-click configuration to use platform-specific config paths with macOS fallback behavior.
- Fixed Windows one-click MCP configuration path resolution by using a more reliable home/appdata lookup strategy.
## [0.1.0] - 2026-04-15 ## [0.1.0] - 2026-04-15
### Added ### Added
+4
View File
@@ -63,6 +63,8 @@ Funplay > MCP Server
The server runs on `http://127.0.0.1:8765/` by default. The server runs on `http://127.0.0.1:8765/` by default.
If the configured port is already occupied, the extension automatically falls back to the next available local port and uses the actual running port for one-click MCP client configuration.
The panel is intentionally small: The panel is intentionally small:
- Enable or disable the MCP server - Enable or disable the MCP server
@@ -194,8 +196,10 @@ Try a higher-level prompt in your AI client:
- This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build. - This extension is **Editor-only**. It is meant to automate Cocos Creator, not to add runtime dependencies to your final game build.
- The MCP server listens on `http://127.0.0.1:8765/` by default. - The MCP server listens on `http://127.0.0.1:8765/` by default.
- If the configured port is busy, the server automatically falls back to the next available port and the panel/client config use the actual running port.
- The default `core` profile exposes 19 high-signal tools. Switch to `full` in the panel if you want all 67 tools exposed. - The default `core` profile exposes 19 high-signal tools. Switch to `full` in the panel if you want all 67 tools exposed.
- All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension. - All exposed MCP tools execute directly. There is no extra approval toggle inside the Cocos extension.
- File tools and `cocos://asset/path/...` resources are restricted to the active Cocos project root.
- The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection. - The recommended workflow is `execute_javascript` first, then focused helper tools for screenshots, diagnostics, assets, and inspection.
- If you change the server port or tool exposure in the panel, the extension saves the config and restarts the server when needed. - If you change the server port or tool exposure in the panel, the extension saves the config and restarts the server when needed.
+4
View File
@@ -63,6 +63,8 @@ Funplay > MCP Server
服务默认运行在 `http://127.0.0.1:8765/` 服务默认运行在 `http://127.0.0.1:8765/`
如果配置端口已被占用,扩展会自动回退到下一个可用本地端口,并在一键客户端配置时使用实际运行端口。
面板刻意保持精简: 面板刻意保持精简:
- 启用或停用 MCP Server - 启用或停用 MCP Server
@@ -194,8 +196,10 @@ url = "http://127.0.0.1:8765/"
- 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。 - 这是一个 **仅限 Editor** 的扩展,用于自动化 Cocos Creator,不会给最终游戏包添加运行时依赖。
- MCP Server 默认监听 `http://127.0.0.1:8765/` - MCP Server 默认监听 `http://127.0.0.1:8765/`
- 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。
- 默认 `core` profile 暴露 19 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 67 个工具。 - 默认 `core` profile 暴露 19 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 67 个工具。
- 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。
- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。
- 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。
- 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。 - 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。
+40 -4
View File
@@ -14,6 +14,7 @@ const { InteractionLog } = require('./lib/interaction-log');
const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp'; const EXTENSION_NAME = manifest.name || 'funplay-cocos-mcp';
const LOG_PREFIX = '[Funplay Cocos MCP]'; const LOG_PREFIX = '[Funplay Cocos MCP]';
const REPOSITORY_URL = 'https://github.com/FunplayAI/funplay-cocos-mcp';
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
class ExtensionService { class ExtensionService {
@@ -114,6 +115,9 @@ class ExtensionService {
await this.server.start(); await this.server.start();
console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`); console.log(`${LOG_PREFIX} MCP server started at ${this.getStatus().url}`);
console.log(
`${LOG_PREFIX} If this tool saves you time, please consider giving it a Star on GitHub: ${REPOSITORY_URL}`
);
return this.getStatus(); return this.getStatus();
} }
@@ -137,17 +141,35 @@ class ExtensionService {
return status; return status;
} }
getEffectiveServerConnection() {
const port = this.server && this.server.isRunning() && typeof this.server.getPort === 'function'
? this.server.getPort()
: this.config.port;
return {
host: this.config.host,
port,
url: `http://${this.config.host}:${port}/`,
};
}
getStatus() { getStatus() {
const effective = this.getEffectiveServerConnection();
const fallbackInfo = this.server && this.server.isRunning() && typeof this.server.getPortFallbackInfo === 'function'
? this.server.getPortFallbackInfo()
: null;
return { return {
running: Boolean(this.server && this.server.isRunning()), running: Boolean(this.server && this.server.isRunning()),
host: this.config.host, host: this.config.host,
port: this.config.port, port: effective.port,
requestedPort: this.config.port,
portFallbackActive: Boolean(fallbackInfo),
portFallbackInfo: fallbackInfo,
toolProfile: this.config.toolProfile, toolProfile: this.config.toolProfile,
autostart: this.config.autostart, autostart: this.config.autostart,
projectPath: getProjectPath(), projectPath: getProjectPath(),
projectName: getProjectName(), projectName: getProjectName(),
cocosVersion: getCocosVersion(), cocosVersion: getCocosVersion(),
url: `http://${this.config.host}:${this.config.port}/`, url: effective.url,
}; };
} }
@@ -234,7 +256,7 @@ class ExtensionService {
} }
getClientConfig() { getClientConfig() {
const url = `http://${this.config.host}:${this.config.port}/`; const { url } = this.getEffectiveServerConnection();
return { return {
url, url,
codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`, codex: `[mcp_servers.funplay_cocos]\nurl = "${url}"\n`,
@@ -251,7 +273,21 @@ class ExtensionService {
configureClient(targetId) { configureClient(targetId) {
this.ensureRuntime(); this.ensureRuntime();
console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`); console.log(`${LOG_PREFIX} Configuring MCP client target: ${targetId}`);
const result = configureTarget(this.config, targetId); const effective = this.getEffectiveServerConnection();
if (effective.port !== this.config.port) {
console.log(
`${LOG_PREFIX} Using actual running port ${effective.port} for MCP client configuration ` +
`(requested: ${this.config.port}).`
);
}
const result = configureTarget(
{
...this.config,
host: effective.host,
port: effective.port,
},
targetId
);
console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`); console.log(`${LOG_PREFIX} MCP client configured: ${result.name} -> ${result.configPath}`);
return { return {
...result, ...result,
+41 -2
View File
@@ -6,6 +6,45 @@ const path = require('path');
const SERVER_NAME = 'funplay_cocos'; const SERVER_NAME = 'funplay_cocos';
function getUserHomePath() {
const home = os.homedir();
if (home) {
return home;
}
const homeDrive = process.env.HOMEDRIVE;
const homePath = process.env.HOMEPATH;
if (homeDrive && homePath) {
return `${homeDrive}${homePath}`;
}
return process.env.HOME || '';
}
function getVSCodeConfigPath(homePath) {
switch (process.platform) {
case 'win32': {
const appData = process.env.APPDATA || path.join(homePath, 'AppData', 'Roaming');
return path.join(appData, 'Code', 'User', 'mcp.json');
}
case 'darwin': {
const primaryPath = path.join(homePath, 'Library', 'Application Support', 'Code', 'User', 'mcp.json');
const primaryDirectory = path.dirname(primaryPath);
if (fs.existsSync(primaryPath) || fs.existsSync(primaryDirectory)) {
return primaryPath;
}
return path.join(homePath, '.vscode', 'mcp.json');
}
case 'linux':
return path.join(homePath, '.config', 'Code', 'User', 'mcp.json');
default:
return path.join(homePath, '.vscode', 'mcp.json');
}
}
function ensureParent(filePath) { function ensureParent(filePath) {
const dir = path.dirname(filePath); const dir = path.dirname(filePath);
if (dir && !fs.existsSync(dir)) { if (dir && !fs.existsSync(dir)) {
@@ -67,7 +106,7 @@ function configureTomlTarget(target) {
} }
function buildTargets(config) { function buildTargets(config) {
const home = os.homedir(); const home = getUserHomePath();
const url = `http://${config.host}:${config.port}/`; const url = `http://${config.host}:${config.port}/`;
return [ return [
@@ -88,7 +127,7 @@ function buildTargets(config) {
{ {
id: 'vscode', id: 'vscode',
name: 'VS Code', name: 'VS Code',
configPath: path.join(home, '.vscode', 'mcp.json'), configPath: getVSCodeConfigPath(home),
rootKey: 'servers', rootKey: 'servers',
entry: { type: 'http', url }, entry: { type: 'http', url },
}, },
+36
View File
@@ -0,0 +1,36 @@
'use strict';
const path = require('path');
function normalizeRoot(projectPath) {
return path.resolve(String(projectPath || process.cwd()));
}
function isPathInside(rootPath, targetPath) {
const root = normalizeRoot(rootPath);
const target = path.resolve(String(targetPath || ''));
const relative = path.relative(root, target);
return relative === '' || (relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function resolveProjectPath(projectPath, rawPath) {
if (!rawPath || typeof rawPath !== 'string') {
throw new Error('path is required.');
}
const root = normalizeRoot(projectPath);
const targetPath = path.isAbsolute(rawPath)
? path.resolve(rawPath)
: path.resolve(root, rawPath);
if (!isPathInside(root, targetPath)) {
throw new Error(`Path is outside the Cocos project: ${rawPath}`);
}
return targetPath;
}
module.exports = {
isPathInside,
resolveProjectPath,
};
+2 -1
View File
@@ -4,6 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets'); const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets');
const { runScriptDiagnostics } = require('./diagnostics'); const { runScriptDiagnostics } = require('./diagnostics');
const { resolveProjectPath } = require('./path-safety');
function createResource(uri, name, description) { function createResource(uri, name, description) {
return { uri, name, description, mimeType: 'text/plain' }; return { uri, name, description, mimeType: 'text/plain' };
@@ -199,7 +200,7 @@ class ResourceProvider {
readAssetByPath(relativePath) { readAssetByPath(relativePath) {
const { projectPath } = this.getRuntimeContext(); const { projectPath } = this.getRuntimeContext();
const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath); const fullPath = resolveProjectPath(projectPath, relativePath);
if (!fs.existsSync(fullPath)) { if (!fs.existsSync(fullPath)) {
return `Asset not found: ${relativePath}`; return `Asset not found: ${relativePath}`;
} }
+227 -25
View File
@@ -1,11 +1,24 @@
'use strict'; 'use strict';
const http = require('http'); const http = require('http');
const { safeStringify } = require('./utils');
const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,';
const LOG_PREFIX = '[Funplay Cocos MCP Server]'; const LOG_PREFIX = '[Funplay Cocos MCP Server]';
const MAX_PORT_FALLBACK_ATTEMPTS = 20;
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
const MCP_PROTOCOL_VERSION = '2025-11-25';
const SUPPORTED_PROTOCOL_VERSIONS = [
MCP_PROTOCOL_VERSION,
'2025-06-18',
'2025-03-26',
'2024-11-05',
];
function json(response, statusCode, payload) { function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) {
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'MCP-Protocol-Version': protocolVersion,
});
response.end(JSON.stringify(payload)); response.end(JSON.stringify(payload));
} }
@@ -32,6 +45,22 @@ function textContent(value) {
]; ];
} }
function isStructuredValue(value) {
return value !== null && typeof value === 'object' && !Buffer.isBuffer(value);
}
function structuredContent(value) {
if (!isStructuredValue(value)) {
return null;
}
try {
return JSON.parse(safeStringify(value));
} catch (error) {
return null;
}
}
class McpServer { class McpServer {
constructor(options) { constructor(options) {
this.config = options.config; this.config = options.config;
@@ -42,62 +71,148 @@ class McpServer {
this.serverName = options.serverName; this.serverName = options.serverName;
this.serverVersion = options.serverVersion; this.serverVersion = options.serverVersion;
this.server = null; this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION;
} }
isRunning() { isRunning() {
return Boolean(this.server && this.server.listening); return Boolean(this.server && this.server.listening);
} }
getPort() {
if (this.server && typeof this.server.address === 'function') {
const address = this.server.address();
if (address && typeof address.port === 'number') {
return address.port;
}
}
return this.actualPort || this.config.port;
}
getRequestedPort() {
return this.config.port;
}
getPortFallbackInfo() {
return this.portFallbackInfo;
}
async start() { async start() {
if (this.isRunning()) { if (this.isRunning()) {
console.log(`${LOG_PREFIX} Start skipped: already running.`); console.log(`${LOG_PREFIX} Start skipped: already running.`);
return; return;
} }
console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${this.config.port}...`); this.actualPort = null;
this.server = http.createServer(async (request, response) => { this.portFallbackInfo = null;
const requestHandler = async (request, response) => {
try { try {
if (request.method === 'GET' && request.url === '/health') { if (request.method === 'GET' && request.url === '/health') {
console.log(`${LOG_PREFIX} GET /health`); console.log(`${LOG_PREFIX} GET /health`);
return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }); return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }, this.negotiatedProtocolVersion);
}
if (!this.isAllowedOrigin(request)) {
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: invalid Origin header.`);
return json(response, 403, { error: 'Forbidden: invalid Origin header' }, this.negotiatedProtocolVersion);
} }
if (request.method !== 'POST') { if (request.method !== 'POST') {
console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`); console.warn(`${LOG_PREFIX} Rejected ${request.method} ${request.url}: method not allowed.`);
return json(response, 405, { error: 'Method Not Allowed' }); return json(response, 405, { error: 'Method Not Allowed' }, this.negotiatedProtocolVersion);
} }
const body = await this.readBody(request); const body = await this.readBody(request);
if (!body) { if (!body) {
return json(response, 400, this.createError(null, -32700, 'Parse error: empty body')); return json(response, 400, this.createError(null, -32700, 'Parse error: empty body'), this.negotiatedProtocolVersion);
}
let rpc;
try {
rpc = JSON.parse(body);
} catch (error) {
return json(response, 400, this.createError(null, -32700, `Parse error: ${error.message}`), this.negotiatedProtocolVersion);
} }
const rpc = JSON.parse(body);
if (rpc && rpc.method) { if (rpc && rpc.method) {
console.log(`${LOG_PREFIX} RPC ${rpc.method}`); console.log(`${LOG_PREFIX} RPC ${rpc.method}`);
} }
const protocolHeaderError = this.validateProtocolVersionHeader(request, rpc);
if (protocolHeaderError) {
return json(response, 400, protocolHeaderError, this.negotiatedProtocolVersion);
}
const result = await this.handleRpcRequest(rpc); const result = await this.handleRpcRequest(rpc);
if (result == null) { if (result == null) {
response.writeHead(204); response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion });
response.end(); response.end();
return; return;
} }
return json(response, 200, result); return json(response, 200, result, this.negotiatedProtocolVersion);
} catch (error) { } catch (error) {
console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`); console.error(`${LOG_PREFIX} Request handling failed: ${error.message}`);
return json(response, 500, this.createError(null, -32603, `Internal error: ${error.message}`)); const statusCode = error.statusCode || 500;
const rpcCode = error.rpcCode || -32603;
const message = statusCode === 500 ? `Internal error: ${error.message}` : error.message;
return json(response, statusCode, this.createError(null, rpcCode, message), this.negotiatedProtocolVersion);
} }
}); };
await new Promise((resolve, reject) => { let attempt = 0;
this.server.once('error', reject); let port = this.config.port;
this.server.listen(this.config.port, this.config.host, () => { let lastError = null;
this.server.off('error', reject);
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.config.port}/`); while (attempt <= MAX_PORT_FALLBACK_ATTEMPTS) {
resolve(); console.log(`${LOG_PREFIX} Creating HTTP server on ${this.config.host}:${port}...`);
}); const candidate = http.createServer(requestHandler);
});
try {
await this.listen(candidate, port, this.config.host);
this.server = candidate;
this.actualPort = candidate.address() && typeof candidate.address().port === 'number'
? candidate.address().port
: port;
if (this.actualPort !== this.config.port) {
this.portFallbackInfo = {
requestedPort: this.config.port,
actualPort: this.actualPort,
attempts: attempt,
};
console.warn(
`${LOG_PREFIX} Port ${this.config.port} was unavailable. ` +
`Fell back to ${this.actualPort}.`
);
}
console.log(`${LOG_PREFIX} Listening on http://${this.config.host}:${this.actualPort}/`);
return;
} catch (error) {
lastError = error;
if (error && error.code === 'EADDRINUSE' && port < 65535 && attempt < MAX_PORT_FALLBACK_ATTEMPTS) {
const nextPort = port + 1;
console.warn(
`${LOG_PREFIX} Port ${port} is already in use. ` +
`Trying fallback port ${nextPort}...`
);
port = nextPort;
attempt += 1;
continue;
}
candidate.removeAllListeners();
break;
}
}
this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
throw lastError || new Error('Failed to start MCP server.');
} }
async stop() { async stop() {
@@ -109,6 +224,8 @@ class McpServer {
console.log(`${LOG_PREFIX} Closing HTTP server...`); console.log(`${LOG_PREFIX} Closing HTTP server...`);
const active = this.server; const active = this.server;
this.server = null; this.server = null;
this.actualPort = null;
this.portFallbackInfo = null;
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
active.close((error) => { active.close((error) => {
if (error) { if (error) {
@@ -122,15 +239,80 @@ class McpServer {
}); });
} }
listen(server, port, host) {
return new Promise((resolve, reject) => {
const onError = (error) => {
server.off('listening', onListening);
reject(error);
};
const onListening = () => {
server.off('error', onError);
resolve();
};
server.once('error', onError);
server.once('listening', onListening);
server.listen(port, host);
});
}
readBody(request) { readBody(request) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = []; const chunks = [];
request.on('data', (chunk) => chunks.push(chunk)); let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
if (size > MAX_REQUEST_BODY_BYTES) {
const error = new Error(`Request body exceeds ${MAX_REQUEST_BODY_BYTES} bytes.`);
error.statusCode = 413;
error.rpcCode = -32600;
reject(error);
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); request.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
request.on('error', reject); request.on('error', reject);
}); });
} }
isAllowedOrigin(request) {
const origin = request.headers && request.headers.origin;
if (!origin) {
return true;
}
try {
const parsed = new URL(String(origin));
const hostname = parsed.hostname.toLowerCase();
const configuredHost = String(this.config.host || '').toLowerCase();
return hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '::1'
|| (configuredHost && hostname === configuredHost);
} catch (error) {
return false;
}
}
validateProtocolVersionHeader(request, rpc) {
const header = request.headers && request.headers['mcp-protocol-version'];
if (!header || (rpc && rpc.method === 'initialize')) {
return null;
}
const version = Array.isArray(header) ? header[0] : String(header);
if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) {
return this.createError(
rpc && rpc.id,
-32600,
`Unsupported MCP protocol version header: ${version}`
);
}
return null;
}
async handleRpcRequest(request) { async handleRpcRequest(request) {
if (!request || request.jsonrpc !== '2.0') { if (!request || request.jsonrpc !== '2.0') {
return this.createError(request && request.id, -32600, 'Invalid Request'); return this.createError(request && request.id, -32600, 'Invalid Request');
@@ -142,8 +324,9 @@ class McpServer {
} }
if (method === 'initialize') { if (method === 'initialize') {
this.negotiatedProtocolVersion = this.negotiateProtocolVersion(request.params && request.params.protocolVersion);
return this.createResult(request.id, { return this.createResult(request.id, {
protocolVersion: '2024-11-05', protocolVersion: this.negotiatedProtocolVersion,
serverInfo: { serverInfo: {
name: this.serverName, name: this.serverName,
version: this.serverVersion, version: this.serverVersion,
@@ -171,10 +354,20 @@ class McpServer {
} }
try { try {
const output = await this.toolRegistry.callTool(params.name, params.arguments || {}); const output = typeof this.toolRegistry.callToolDetailed === 'function'
return this.createResult(request.id, { content: textContent(output) }); ? await this.toolRegistry.callToolDetailed(params.name, params.arguments || {})
: { value: null, text: await this.toolRegistry.callTool(params.name, params.arguments || {}) };
const result = { content: textContent(output.text) };
const structured = structuredContent(output.value);
if (structured) {
result.structuredContent = structured;
}
return this.createResult(request.id, result);
} catch (error) { } catch (error) {
return this.createError(request.id, -32603, error.message); return this.createResult(request.id, {
content: textContent(error.message),
isError: true,
});
} }
} }
@@ -209,6 +402,13 @@ class McpServer {
return this.createError(request.id, -32601, `Method not found: ${method}`); return this.createError(request.id, -32601, `Method not found: ${method}`);
} }
negotiateProtocolVersion(clientVersion) {
if (SUPPORTED_PROTOCOL_VERSIONS.includes(clientVersion)) {
return clientVersion;
}
return MCP_PROTOCOL_VERSION;
}
createResult(id, result) { createResult(id, result) {
return { return {
jsonrpc: '2.0', jsonrpc: '2.0',
@@ -231,4 +431,6 @@ class McpServer {
module.exports = { module.exports = {
McpServer, McpServer,
MCP_PROTOCOL_VERSION,
SUPPORTED_PROTOCOL_VERSIONS,
}; };
+13 -7
View File
@@ -14,6 +14,7 @@ const {
} = require('./assets'); } = require('./assets');
const { runScriptDiagnostics } = require('./diagnostics'); const { runScriptDiagnostics } = require('./diagnostics');
const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input'); const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input');
const { resolveProjectPath } = require('./path-safety');
const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots');
const { safeStringify } = require('./utils'); const { safeStringify } = require('./utils');
@@ -35,10 +36,6 @@ function toOutput(value) {
return safeStringify(value); return safeStringify(value);
} }
function resolveProjectPath(projectPath, rawPath) {
return path.isAbsolute(rawPath) ? rawPath : path.join(projectPath, rawPath);
}
function matchesPattern(fileName, pattern) { function matchesPattern(fileName, pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`, 'i').test(fileName); return new RegExp(`^${escaped}$`, 'i').test(fileName);
@@ -1249,7 +1246,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
}, },
]; ];
return { const registry = {
listTools() { listTools() {
const { config } = getRuntimeContext(); const { config } = getRuntimeContext();
return tools return tools
@@ -1260,7 +1257,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
})); }));
}, },
async callTool(name, args) { async callToolDetailed(name, args) {
const { config } = getRuntimeContext(); const { config } = getRuntimeContext();
const tool = tools.find((item) => item.name === name); const tool = tools.find((item) => item.name === name);
if (!tool) { if (!tool) {
@@ -1274,13 +1271,22 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed
const result = await tool.handler(args || {}); const result = await tool.handler(args || {});
const output = toOutput(result); const output = toOutput(result);
interactionLog.add(name, 'success', output.slice(0, 500)); interactionLog.add(name, 'success', output.slice(0, 500));
return output; return {
value: result,
text: output,
};
} catch (error) { } catch (error) {
interactionLog.add(name, 'error', error.message); interactionLog.add(name, 'error', error.message);
throw error; throw error;
} }
}, },
async callTool(name, args) {
const result = await registry.callToolDetailed(name, args);
return result.text;
},
}; };
return registry;
} }
module.exports = { module.exports = {
+3 -2
View File
@@ -1,13 +1,14 @@
{ {
"name": "funplay-cocos-mcp", "name": "funplay-cocos-mcp",
"package_version": 2, "package_version": 2,
"version": "0.1.0", "version": "0.1.2",
"description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.",
"author": "Funplay", "author": "Funplay",
"license": "MIT", "license": "MIT",
"main": "browser.js", "main": "browser.js",
"scripts": { "scripts": {
"check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/utils.js" "check": "node --check browser.js && node --check scene.js && node --check panel/index.js && node --check lib/assets.js && node --check lib/client-config.js && node --check lib/config.js && node --check lib/diagnostics.js && node --check lib/electron-tools.js && node --check lib/input.js && node --check lib/interaction-log.js && node --check lib/path-safety.js && node --check lib/prompts.js && node --check lib/resources.js && node --check lib/screenshots.js && node --check lib/server.js && node --check lib/tool-registry.js && node --check lib/utils.js",
"test": "node --test"
}, },
"panels": { "panels": {
"default": { "default": {
+5 -1
View File
@@ -233,7 +233,11 @@ module.exports = Editor.Panel.define({
this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped'; this.$.statusPill.textContent = isRunning ? 'Running' : 'Stopped';
this.$.statusPill.classList.toggle('running', isRunning); this.$.statusPill.classList.toggle('running', isRunning);
this.$.statusPill.classList.toggle('stopped', !isRunning); this.$.statusPill.classList.toggle('stopped', !isRunning);
this.$.statusText.textContent = `${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}`; const portText = status.portFallbackActive
? ` | Port fallback: ${status.requestedPort} -> ${status.port}`
: '';
this.$.statusText.textContent =
`${status.url || ''} | Project: ${status.projectName || ''} | Cocos ${status.cocosVersion || ''}${portText}`;
this.$.enabledInput.value = Boolean(isRunning || config.autostart); this.$.enabledInput.value = Boolean(isRunning || config.autostart);
this.$.portInput.value = Number(config.port || status.port || 8765); this.$.portInput.value = Number(config.port || status.port || 8765);
+38
View File
@@ -0,0 +1,38 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const { isPathInside, resolveProjectPath } = require('../lib/path-safety');
test('resolveProjectPath resolves project-relative paths inside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.equal(resolveProjectPath(root, 'assets/player.ts'), path.join(root, 'assets', 'player.ts'));
});
test('resolveProjectPath allows absolute paths only when they stay inside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
const inside = path.join(root, 'assets', 'scene.scene');
assert.equal(resolveProjectPath(root, inside), inside);
});
test('resolveProjectPath rejects path traversal outside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.throws(
() => resolveProjectPath(root, '../secret.txt'),
/outside the Cocos project/
);
});
test('resolveProjectPath rejects absolute paths outside the project root', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.throws(
() => resolveProjectPath(root, '/tmp/secret.txt'),
/outside the Cocos project/
);
});
test('isPathInside treats the project root as inside itself', () => {
const root = path.resolve('/tmp/funplay-cocos-test-project');
assert.equal(isPathInside(root, root), true);
});
+128
View File
@@ -0,0 +1,128 @@
'use strict';
const assert = require('node:assert/strict');
const test = require('node:test');
const {
MCP_PROTOCOL_VERSION,
McpServer,
SUPPORTED_PROTOCOL_VERSIONS,
} = require('../lib/server');
function createServer(toolRegistry = {}) {
return new McpServer({
config: { host: '127.0.0.1', port: 8765 },
toolRegistry: {
listTools: () => [],
callTool: async () => 'ok',
...toolRegistry,
},
resourceProvider: {
listResources: () => [],
listResourceTemplates: () => [],
readResource: async () => ({ contents: [] }),
},
promptProvider: {
listPrompts: () => [],
getPrompt: () => ({ messages: [] }),
},
interactionLog: { add() {} },
serverName: 'test-server',
serverVersion: '0.0.0-test',
});
}
test('initialize negotiates the current MCP protocol version by default', async () => {
const server = createServer();
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {},
});
assert.equal(response.result.protocolVersion, MCP_PROTOCOL_VERSION);
assert.equal(response.result.serverInfo.name, 'test-server');
});
test('initialize can negotiate an older supported MCP protocol version', async () => {
const server = createServer();
const olderVersion = SUPPORTED_PROTOCOL_VERSIONS[SUPPORTED_PROTOCOL_VERSIONS.length - 1];
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: olderVersion },
});
assert.equal(response.result.protocolVersion, olderVersion);
});
test('tool execution failures are returned as MCP tool errors', async () => {
const server = createServer({
callTool: async () => {
throw new Error('bad arguments');
},
});
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'example', arguments: {} },
});
assert.equal(response.error, undefined);
assert.equal(response.result.isError, true);
assert.deepEqual(response.result.content, [{ type: 'text', text: 'bad arguments' }]);
});
test('tool object results include structuredContent', async () => {
const value = { ok: true, count: 2 };
const server = createServer({
callToolDetailed: async () => ({
value,
text: JSON.stringify(value, null, 2),
}),
});
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'example', arguments: {} },
});
assert.deepEqual(response.result.structuredContent, value);
assert.equal(response.result.content[0].type, 'text');
});
test('structuredContent sanitizes circular values', async () => {
const value = { ok: true };
value.self = value;
const server = createServer({
callToolDetailed: async () => ({
value,
text: '{ "ok": true, "self": "[Circular]" }',
}),
});
const response = await server.handleRpcRequest({
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: { name: 'example', arguments: {} },
});
assert.deepEqual(response.result.structuredContent, { ok: true, self: '[Circular]' });
});
test('unsupported protocol version headers are rejected when present after initialize', () => {
const server = createServer();
const response = server.validateProtocolVersionHeader(
{ headers: { 'mcp-protocol-version': '1999-01-01' } },
{ jsonrpc: '2.0', id: 3, method: 'tools/list' }
);
assert.equal(response.error.code, -32600);
assert.match(response.error.message, /Unsupported MCP protocol version/);
});
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const assert = require('node:assert/strict');
const path = require('node:path');
const test = require('node:test');
const { createToolRegistry } = require('../lib/tool-registry');
function createRegistry(profile, projectPath = path.resolve('/tmp/funplay-cocos-test-project')) {
return createToolRegistry({
getRuntimeContext: () => ({
config: { toolProfile: profile },
projectPath,
}),
interactionLog: { add() {} },
sceneBridge: { call: async () => ({ ok: true }) },
editorExecutor: async () => ({ ok: true }),
});
}
test('core profile exposes the documented focused tool set', () => {
const tools = createRegistry('core').listTools();
assert.equal(tools.length, 19);
assert.equal(tools.some((tool) => tool.name === 'execute_javascript'), true);
assert.equal(tools.some((tool) => tool.name === 'write_file'), false);
});
test('full profile exposes all built-in tools', () => {
const tools = createRegistry('full').listTools();
assert.equal(tools.length, 67);
assert.equal(tools.some((tool) => tool.name === 'write_file'), true);
});
test('file tools reject writes outside the project root', async () => {
const registry = createRegistry('full');
await assert.rejects(
() => registry.callTool('write_file', { path: '../outside.txt', content: 'x' }),
/outside the Cocos project/
);
});
test('callToolDetailed preserves structured values and text output', async () => {
const registry = createRegistry('core');
const result = await registry.callToolDetailed('get_project_info', {});
assert.equal(result.value.projectPath, path.resolve('/tmp/funplay-cocos-test-project'));
assert.match(result.text, /projectPath/);
});