Release v0.1.2
This commit is contained in:
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
+132
-14
@@ -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,
|
||||
};
|
||||
|
||||
+13
-7
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user