From a903f7f0af5fb68ce4a2f5e3d651db94f49485d8 Mon Sep 17 00:00:00 2001 From: winlifes <110321103+Winlifes@users.noreply.github.com> Date: Thu, 30 Apr 2026 18:12:29 +0800 Subject: [PATCH] Release v0.1.2 --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 20 +++++ README.md | 1 + README_CN.md | 1 + lib/path-safety.js | 36 +++++++++ lib/resources.js | 3 +- lib/server.js | 146 +++++++++++++++++++++++++++++++++---- lib/tool-registry.js | 20 +++-- package.json | 5 +- test/path-safety.test.js | 38 ++++++++++ test/server.test.js | 128 ++++++++++++++++++++++++++++++++ test/tool-registry.test.js | 46 ++++++++++++ 12 files changed, 423 insertions(+), 24 deletions(-) create mode 100644 lib/path-safety.js create mode 100644 test/path-safety.test.js create mode 100644 test/server.test.js create mode 100644 test/tool-registry.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc2fab5..f947253 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,6 @@ jobs: - name: Run syntax checks run: npm run check + + - name: Run tests + run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 177ef96..8809ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ 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. +## [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 diff --git a/README.md b/README.md index 7e69afe..6e1fad6 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,7 @@ Try a higher-level prompt in your AI client: - 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. - 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. - If you change the server port or tool exposure in the panel, the extension saves the config and restarts the server when needed. diff --git a/README_CN.md b/README_CN.md index fb97db4..416762d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -199,6 +199,7 @@ url = "http://127.0.0.1:8765/" - 如果配置端口被占用,服务会自动回退到下一个可用端口,面板与一键客户端配置会使用实际运行端口。 - 默认 `core` profile 暴露 19 个高频工具;如果需要完整工具集,可在面板切到 `full`,暴露全部 67 个工具。 - 所有已暴露的 MCP 工具都会直接执行,Cocos 扩展里没有额外 approval 开关。 +- 文件工具和 `cocos://asset/path/...` 资源默认只能访问当前 Cocos 项目根目录内的路径。 - 推荐工作流是优先使用 `execute_javascript`,再配合截图、诊断、资产、检查类工具。 - 如果在面板里修改端口或工具暴露模式,扩展会自动保存配置,并在需要时重启服务。 diff --git a/lib/path-safety.js b/lib/path-safety.js new file mode 100644 index 0000000..6822f8b --- /dev/null +++ b/lib/path-safety.js @@ -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, +}; diff --git a/lib/resources.js b/lib/resources.js index 0e06fb0..85b44f9 100644 --- a/lib/resources.js +++ b/lib/resources.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const { getCurrentSelection, queryAssetInfo, queryAssetMeta } = require('./assets'); const { runScriptDiagnostics } = require('./diagnostics'); +const { resolveProjectPath } = require('./path-safety'); function createResource(uri, name, description) { return { uri, name, description, mimeType: 'text/plain' }; @@ -199,7 +200,7 @@ class ResourceProvider { readAssetByPath(relativePath) { const { projectPath } = this.getRuntimeContext(); - const fullPath = path.isAbsolute(relativePath) ? relativePath : path.join(projectPath, relativePath); + const fullPath = resolveProjectPath(projectPath, relativePath); if (!fs.existsSync(fullPath)) { return `Asset not found: ${relativePath}`; } diff --git a/lib/server.js b/lib/server.js index c4127ce..15c2584 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,12 +1,24 @@ 'use strict'; const http = require('http'); +const { safeStringify } = require('./utils'); const IMAGE_DATA_URI_PREFIX = 'data:image/png;base64,'; 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) { - response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' }); +function json(response, statusCode, payload, protocolVersion = MCP_PROTOCOL_VERSION) { + response.writeHead(statusCode, { + 'Content-Type': 'application/json; charset=utf-8', + 'MCP-Protocol-Version': protocolVersion, + }); response.end(JSON.stringify(payload)); } @@ -33,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 { constructor(options) { this.config = options.config; @@ -45,6 +73,7 @@ class McpServer { this.server = null; this.actualPort = null; this.portFallbackInfo = null; + this.negotiatedProtocolVersion = MCP_PROTOCOL_VERSION; } isRunning() { @@ -82,34 +111,54 @@ class McpServer { try { if (request.method === 'GET' && request.url === '/health') { console.log(`${LOG_PREFIX} GET /health`); - return json(response, 200, { ok: true, name: this.serverName, version: this.serverVersion }); + 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') { 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); 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) { 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); if (result == null) { - response.writeHead(204); + response.writeHead(204, { 'MCP-Protocol-Version': this.negotiatedProtocolVersion }); response.end(); return; } - return json(response, 200, result); + return json(response, 200, result, this.negotiatedProtocolVersion); } catch (error) { 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); } }; @@ -209,12 +258,61 @@ class McpServer { readBody(request) { return new Promise((resolve, reject) => { 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('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) { if (!request || request.jsonrpc !== '2.0') { return this.createError(request && request.id, -32600, 'Invalid Request'); @@ -226,8 +324,9 @@ class McpServer { } if (method === 'initialize') { + this.negotiatedProtocolVersion = this.negotiateProtocolVersion(request.params && request.params.protocolVersion); return this.createResult(request.id, { - protocolVersion: '2024-11-05', + protocolVersion: this.negotiatedProtocolVersion, serverInfo: { name: this.serverName, version: this.serverVersion, @@ -255,10 +354,20 @@ class McpServer { } try { - const output = await this.toolRegistry.callTool(params.name, params.arguments || {}); - return this.createResult(request.id, { content: textContent(output) }); + const output = typeof this.toolRegistry.callToolDetailed === 'function' + ? 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) { - return this.createError(request.id, -32603, error.message); + return this.createResult(request.id, { + content: textContent(error.message), + isError: true, + }); } } @@ -293,6 +402,13 @@ class McpServer { 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) { return { jsonrpc: '2.0', @@ -315,4 +431,6 @@ class McpServer { module.exports = { McpServer, + MCP_PROTOCOL_VERSION, + SUPPORTED_PROTOCOL_VERSIONS, }; diff --git a/lib/tool-registry.js b/lib/tool-registry.js index 1982f9a..092c2e3 100644 --- a/lib/tool-registry.js +++ b/lib/tool-registry.js @@ -14,6 +14,7 @@ const { } = require('./assets'); const { runScriptDiagnostics } = require('./diagnostics'); const { listWindows, sendKeyCombo, sendKeyPress, sendMouseClick, sendMouseDrag } = require('./input'); +const { resolveProjectPath } = require('./path-safety'); const { captureDesktopScreenshot, captureEditorWindowScreenshot, capturePanelScreenshot } = require('./screenshots'); const { safeStringify } = require('./utils'); @@ -35,10 +36,6 @@ function toOutput(value) { return safeStringify(value); } -function resolveProjectPath(projectPath, rawPath) { - return path.isAbsolute(rawPath) ? rawPath : path.join(projectPath, rawPath); -} - function matchesPattern(fileName, pattern) { const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'); return new RegExp(`^${escaped}$`, 'i').test(fileName); @@ -1249,7 +1246,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed }, ]; - return { + const registry = { listTools() { const { config } = getRuntimeContext(); return tools @@ -1260,7 +1257,7 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed inputSchema: tool.inputSchema, })); }, - async callTool(name, args) { + async callToolDetailed(name, args) { const { config } = getRuntimeContext(); const tool = tools.find((item) => item.name === name); if (!tool) { @@ -1274,13 +1271,22 @@ function createToolRegistry({ getRuntimeContext, interactionLog, sceneBridge, ed const result = await tool.handler(args || {}); const output = toOutput(result); interactionLog.add(name, 'success', output.slice(0, 500)); - return output; + return { + value: result, + text: output, + }; } catch (error) { interactionLog.add(name, 'error', error.message); throw error; } }, + async callTool(name, args) { + const result = await registry.callToolDetailed(name, args); + return result.text; + }, }; + + return registry; } module.exports = { diff --git a/package.json b/package.json index 9b88db8..9d09042 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "funplay-cocos-mcp", "package_version": 2, - "version": "0.1.1", + "version": "0.1.2", "description": "Embedded MCP server for Cocos Creator with scene script execution, project resources, prompts, and file/scene tools.", "author": "Funplay", "license": "MIT", "main": "browser.js", "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": { "default": { diff --git a/test/path-safety.test.js b/test/path-safety.test.js new file mode 100644 index 0000000..3788f9c --- /dev/null +++ b/test/path-safety.test.js @@ -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); +}); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..85cb354 --- /dev/null +++ b/test/server.test.js @@ -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/); +}); diff --git a/test/tool-registry.test.js b/test/tool-registry.test.js new file mode 100644 index 0000000..3919500 --- /dev/null +++ b/test/tool-registry.test.js @@ -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/); +});