424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
import * as http from 'http';
|
|
import * as url from 'url';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { MCPServerSettings, ServerStatus, MCPClient, ToolDefinition } from './types';
|
|
import { SceneTools } from './tools/scene-tools';
|
|
import { NodeTools } from './tools/node-tools';
|
|
import { ComponentTools } from './tools/component-tools';
|
|
import { PrefabTools } from './tools/prefab-tools';
|
|
import { ProjectTools } from './tools/project-tools';
|
|
import { DebugTools } from './tools/debug-tools';
|
|
import { PreferencesTools } from './tools/preferences-tools';
|
|
import { ServerTools } from './tools/server-tools';
|
|
import { BroadcastTools } from './tools/broadcast-tools';
|
|
import { SceneAdvancedTools } from './tools/scene-advanced-tools';
|
|
import { SceneViewTools } from './tools/scene-view-tools';
|
|
import { ReferenceImageTools } from './tools/reference-image-tools';
|
|
import { AssetAdvancedTools } from './tools/asset-advanced-tools';
|
|
import { ValidationTools } from './tools/validation-tools';
|
|
|
|
export class MCPServer {
|
|
private settings: MCPServerSettings;
|
|
private httpServer: http.Server | null = null;
|
|
private clients: Map<string, MCPClient> = new Map();
|
|
private tools: Record<string, any> = {};
|
|
private toolsList: ToolDefinition[] = [];
|
|
|
|
constructor(settings: MCPServerSettings) {
|
|
this.settings = settings;
|
|
this.initializeTools();
|
|
}
|
|
|
|
private initializeTools(): void {
|
|
try {
|
|
console.log('[MCPServer] Initializing tools...');
|
|
this.tools.scene = new SceneTools();
|
|
this.tools.node = new NodeTools();
|
|
this.tools.component = new ComponentTools();
|
|
this.tools.prefab = new PrefabTools();
|
|
this.tools.project = new ProjectTools();
|
|
this.tools.debug = new DebugTools();
|
|
this.tools.preferences = new PreferencesTools();
|
|
this.tools.server = new ServerTools();
|
|
this.tools.broadcast = new BroadcastTools();
|
|
this.tools.sceneAdvanced = new SceneAdvancedTools();
|
|
this.tools.sceneView = new SceneViewTools();
|
|
this.tools.referenceImage = new ReferenceImageTools();
|
|
this.tools.assetAdvanced = new AssetAdvancedTools();
|
|
this.tools.validation = new ValidationTools();
|
|
console.log('[MCPServer] Tools initialized successfully');
|
|
} catch (error) {
|
|
console.error('[MCPServer] Error initializing tools:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
if (this.httpServer) {
|
|
console.log('[MCPServer] Server is already running');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
console.log(`[MCPServer] Starting HTTP server on port ${this.settings.port}...`);
|
|
this.httpServer = http.createServer(this.handleHttpRequest.bind(this));
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.httpServer!.listen(this.settings.port, '127.0.0.1', () => {
|
|
console.log(`[MCPServer] ✅ HTTP server started successfully on http://127.0.0.1:${this.settings.port}`);
|
|
console.log(`[MCPServer] Health check: http://127.0.0.1:${this.settings.port}/health`);
|
|
console.log(`[MCPServer] MCP endpoint: http://127.0.0.1:${this.settings.port}/mcp`);
|
|
resolve();
|
|
});
|
|
this.httpServer!.on('error', (err: any) => {
|
|
console.error('[MCPServer] ❌ Failed to start server:', err);
|
|
if (err.code === 'EADDRINUSE') {
|
|
console.error(`[MCPServer] Port ${this.settings.port} is already in use. Please change the port in settings.`);
|
|
}
|
|
reject(err);
|
|
});
|
|
});
|
|
|
|
this.setupTools();
|
|
console.log('[MCPServer] 🚀 MCP Server is ready for connections');
|
|
} catch (error) {
|
|
console.error('[MCPServer] ❌ Failed to start server:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private setupTools(): void {
|
|
this.toolsList = [];
|
|
|
|
for (const [category, toolSet] of Object.entries(this.tools)) {
|
|
const tools = toolSet.getTools();
|
|
for (const tool of tools) {
|
|
this.toolsList.push({
|
|
name: `${category}_${tool.name}`,
|
|
description: tool.description,
|
|
inputSchema: tool.inputSchema
|
|
});
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
public async executeToolCall(toolName: string, args: any): Promise<any> {
|
|
const parts = toolName.split('_');
|
|
const category = parts[0];
|
|
const toolMethodName = parts.slice(1).join('_');
|
|
|
|
if (this.tools[category]) {
|
|
return await this.tools[category].execute(toolMethodName, args);
|
|
}
|
|
|
|
throw new Error(`Tool ${toolName} not found`);
|
|
}
|
|
|
|
public getClients(): MCPClient[] {
|
|
return Array.from(this.clients.values());
|
|
}
|
|
public getAvailableTools(): ToolDefinition[] {
|
|
return this.toolsList;
|
|
}
|
|
|
|
public getSettings(): MCPServerSettings {
|
|
return this.settings;
|
|
}
|
|
|
|
private async handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
const parsedUrl = url.parse(req.url || '', true);
|
|
const pathname = parsedUrl.pathname;
|
|
|
|
// Set CORS headers
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
res.setHeader('Content-Type', 'application/json');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
res.writeHead(200);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (pathname === '/mcp' && req.method === 'POST') {
|
|
await this.handleMCPRequest(req, res);
|
|
} else if (pathname === '/health' && req.method === 'GET') {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ status: 'ok', tools: this.toolsList.length }));
|
|
} else if (pathname?.startsWith('/api/') && req.method === 'POST') {
|
|
await this.handleSimpleAPIRequest(req, res, pathname);
|
|
} else if (pathname === '/api/tools' && req.method === 'GET') {
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({ tools: this.getSimplifiedToolsList() }));
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end(JSON.stringify({ error: 'Not found' }));
|
|
}
|
|
} catch (error) {
|
|
console.error('HTTP request error:', error);
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({ error: 'Internal server error' }));
|
|
}
|
|
}
|
|
|
|
private async handleMCPRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
let body = '';
|
|
|
|
req.on('data', (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on('end', async () => {
|
|
try {
|
|
// Enhanced JSON parsing with better error handling
|
|
let message;
|
|
try {
|
|
message = JSON.parse(body);
|
|
} catch (parseError: any) {
|
|
// Try to fix common JSON issues
|
|
const fixedBody = this.fixCommonJsonIssues(body);
|
|
try {
|
|
message = JSON.parse(fixedBody);
|
|
console.log('[MCPServer] Fixed JSON parsing issue');
|
|
} catch (secondError) {
|
|
throw new Error(`JSON parsing failed: ${parseError.message}. Original body: ${body.substring(0, 500)}...`);
|
|
}
|
|
}
|
|
|
|
const response = await this.handleMessage(message);
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify(response));
|
|
} catch (error: any) {
|
|
console.error('Error handling MCP request:', error);
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({
|
|
jsonrpc: '2.0',
|
|
id: null,
|
|
error: {
|
|
code: -32700,
|
|
message: `Parse error: ${error.message}`
|
|
}
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
|
|
private async handleMessage(message: any): Promise<any> {
|
|
const { id, method, params } = message;
|
|
|
|
try {
|
|
let result: any;
|
|
|
|
switch (method) {
|
|
case 'tools/list':
|
|
result = { tools: this.getAvailableTools() };
|
|
break;
|
|
case 'tools/call':
|
|
const { name, arguments: args } = params;
|
|
const toolResult = await this.executeToolCall(name, args);
|
|
result = { content: [{ type: 'text', text: JSON.stringify(toolResult) }] };
|
|
break;
|
|
case 'initialize':
|
|
// MCP initialization
|
|
result = {
|
|
protocolVersion: '2024-11-05',
|
|
capabilities: {
|
|
tools: {}
|
|
},
|
|
serverInfo: {
|
|
name: 'cocos-mcp-server',
|
|
version: '1.0.0'
|
|
}
|
|
};
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown method: ${method}`);
|
|
}
|
|
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result
|
|
};
|
|
} catch (error: any) {
|
|
return {
|
|
jsonrpc: '2.0',
|
|
id,
|
|
error: {
|
|
code: -32603,
|
|
message: error.message
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
private fixCommonJsonIssues(jsonStr: string): string {
|
|
let fixed = jsonStr;
|
|
|
|
// Fix common escape character issues
|
|
fixed = fixed
|
|
// Fix unescaped quotes in strings
|
|
.replace(/([^\\])"([^"]*[^\\])"([^,}\]:])/g, '$1\\"$2\\"$3')
|
|
// Fix unescaped backslashes
|
|
.replace(/([^\\])\\([^"\\\/bfnrt])/g, '$1\\\\$2')
|
|
// Fix trailing commas
|
|
.replace(/,(\s*[}\]])/g, '$1')
|
|
// Fix single quotes (should be double quotes)
|
|
.replace(/'/g, '"')
|
|
// Fix common control characters
|
|
.replace(/\n/g, '\\n')
|
|
.replace(/\r/g, '\\r')
|
|
.replace(/\t/g, '\\t');
|
|
|
|
return fixed;
|
|
}
|
|
|
|
public stop(): void {
|
|
if (this.httpServer) {
|
|
this.httpServer.close();
|
|
this.httpServer = null;
|
|
console.log('[MCPServer] HTTP server stopped');
|
|
}
|
|
|
|
this.clients.clear();
|
|
}
|
|
|
|
public getStatus(): ServerStatus {
|
|
return {
|
|
running: !!this.httpServer,
|
|
port: this.settings.port,
|
|
clients: 0 // HTTP is stateless, no persistent clients
|
|
};
|
|
}
|
|
|
|
private async handleSimpleAPIRequest(req: http.IncomingMessage, res: http.ServerResponse, pathname: string): Promise<void> {
|
|
let body = '';
|
|
|
|
req.on('data', (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
|
|
req.on('end', async () => {
|
|
try {
|
|
// Extract tool name from path like /api/node/set_position
|
|
const pathParts = pathname.split('/').filter(p => p);
|
|
if (pathParts.length < 3) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({ error: 'Invalid API path. Use /api/{category}/{tool_name}' }));
|
|
return;
|
|
}
|
|
|
|
const category = pathParts[1];
|
|
const toolName = pathParts[2];
|
|
const fullToolName = `${category}_${toolName}`;
|
|
|
|
// Parse parameters with enhanced error handling
|
|
let params;
|
|
try {
|
|
params = body ? JSON.parse(body) : {};
|
|
} catch (parseError: any) {
|
|
// Try to fix JSON issues
|
|
const fixedBody = this.fixCommonJsonIssues(body);
|
|
try {
|
|
params = JSON.parse(fixedBody);
|
|
console.log('[MCPServer] Fixed API JSON parsing issue');
|
|
} catch (secondError: any) {
|
|
res.writeHead(400);
|
|
res.end(JSON.stringify({
|
|
error: 'Invalid JSON in request body',
|
|
details: parseError.message,
|
|
receivedBody: body.substring(0, 200)
|
|
}));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Execute tool
|
|
const result = await this.executeToolCall(fullToolName, params);
|
|
|
|
res.writeHead(200);
|
|
res.end(JSON.stringify({
|
|
success: true,
|
|
tool: fullToolName,
|
|
result: result
|
|
}));
|
|
|
|
} catch (error: any) {
|
|
console.error('Simple API error:', error);
|
|
res.writeHead(500);
|
|
res.end(JSON.stringify({
|
|
success: false,
|
|
error: error.message,
|
|
tool: pathname
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
|
|
private getSimplifiedToolsList(): any[] {
|
|
return this.toolsList.map(tool => {
|
|
const parts = tool.name.split('_');
|
|
const category = parts[0];
|
|
const toolName = parts.slice(1).join('_');
|
|
|
|
return {
|
|
name: tool.name,
|
|
category: category,
|
|
toolName: toolName,
|
|
description: tool.description,
|
|
apiPath: `/api/${category}/${toolName}`,
|
|
curlExample: this.generateCurlExample(category, toolName, tool.inputSchema)
|
|
};
|
|
});
|
|
}
|
|
|
|
private generateCurlExample(category: string, toolName: string, schema: any): string {
|
|
// Generate sample parameters based on schema
|
|
const sampleParams = this.generateSampleParams(schema);
|
|
const jsonString = JSON.stringify(sampleParams, null, 2);
|
|
|
|
return `curl -X POST http://127.0.0.1:8585/api/${category}/${toolName} \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '${jsonString}'`;
|
|
}
|
|
|
|
private generateSampleParams(schema: any): any {
|
|
if (!schema || !schema.properties) return {};
|
|
|
|
const sample: any = {};
|
|
for (const [key, prop] of Object.entries(schema.properties as any)) {
|
|
const propSchema = prop as any;
|
|
switch (propSchema.type) {
|
|
case 'string':
|
|
sample[key] = propSchema.default || 'example_string';
|
|
break;
|
|
case 'number':
|
|
sample[key] = propSchema.default || 42;
|
|
break;
|
|
case 'boolean':
|
|
sample[key] = propSchema.default || true;
|
|
break;
|
|
case 'object':
|
|
sample[key] = propSchema.default || { x: 0, y: 0, z: 0 };
|
|
break;
|
|
default:
|
|
sample[key] = 'example_value';
|
|
}
|
|
}
|
|
return sample;
|
|
}
|
|
|
|
public updateSettings(settings: MCPServerSettings) {
|
|
this.settings = settings;
|
|
if (this.httpServer) {
|
|
this.stop();
|
|
this.start();
|
|
}
|
|
}
|
|
}
|
|
|
|
// HTTP transport doesn't need persistent connections
|
|
// MCP over HTTP uses request-response pattern
|