97ac0f71f5
https://assetstore.unity.com/packages/tools/generative-ai/synaptic-ai-pro-natural-language-control-for-unity-336030
907 lines
38 KiB
JavaScript
907 lines
38 KiB
JavaScript
/**
|
|
* Synaptic AI Pro - Token SuperSave Mode
|
|
*
|
|
* Experimental: Only 6 meta-tools for 99% context reduction.
|
|
* Compatible with all MCP clients without dynamic tool loading support.
|
|
*
|
|
* Tools:
|
|
* 1. list_categories() - Show available tool categories
|
|
* 2. list_tools(category) - Show tools in a category with their schemas
|
|
* 3. execute(tool_name, params) - Run any tool by name
|
|
* 4. inspect(target, ...) - Dynamically inspect objects/components
|
|
* 5. modify(gameObject, component, properties) - Dynamically modify properties
|
|
* 6. create(type, ...) - Create objects, prefabs, components
|
|
*/
|
|
|
|
const express = require('express');
|
|
const http = require('http');
|
|
const WebSocket = require('ws');
|
|
const cors = require('cors');
|
|
const { z } = require('zod');
|
|
const { createServer } = require('./mcp-server');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
|
|
const app = express();
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
|
|
const server = http.createServer(app);
|
|
let wss = null;
|
|
|
|
let unityWebSocket = null;
|
|
let mcpServer = null;
|
|
|
|
// =====================================================
|
|
// Load Tool Registry from JSON file
|
|
// =====================================================
|
|
let TOOL_REGISTRY_RAW = {};
|
|
let CATEGORIES = {};
|
|
let ALL_TOOLS = {};
|
|
|
|
function loadToolRegistry() {
|
|
try {
|
|
const registryPath = path.join(__dirname, 'tool-registry.json');
|
|
const data = fs.readFileSync(registryPath, 'utf8');
|
|
TOOL_REGISTRY_RAW = JSON.parse(data);
|
|
|
|
// Build categories from registry
|
|
CATEGORIES = {};
|
|
ALL_TOOLS = {};
|
|
|
|
for (const [toolName, toolData] of Object.entries(TOOL_REGISTRY_RAW)) {
|
|
const category = (toolData.category || 'Other').toLowerCase();
|
|
|
|
if (!CATEGORIES[category]) {
|
|
CATEGORIES[category] = {
|
|
description: `${toolData.category} tools`,
|
|
tools: {}
|
|
};
|
|
}
|
|
|
|
// Store tool info (use clean name without unity_ prefix as primary key)
|
|
const cleanName = toolName.replace(/^unity_/, '');
|
|
CATEGORIES[category].tools[cleanName] = {
|
|
fullName: toolName,
|
|
title: toolData.title,
|
|
description: toolData.description
|
|
};
|
|
|
|
// Only store clean name to avoid duplication
|
|
ALL_TOOLS[cleanName] = {
|
|
fullName: toolName,
|
|
category: category,
|
|
title: toolData.title,
|
|
description: toolData.description
|
|
};
|
|
}
|
|
|
|
console.error(`[SuperSave] Loaded ${Object.keys(TOOL_REGISTRY_RAW).length} tools from tool-registry.json`);
|
|
} catch (err) {
|
|
console.error('[SuperSave] Failed to load tool-registry.json:', err.message);
|
|
}
|
|
}
|
|
|
|
// Load on startup
|
|
loadToolRegistry();
|
|
|
|
// =====================================================
|
|
// WebSocket Setup (same as index.js)
|
|
// =====================================================
|
|
function setupWebSocketHandlers() {
|
|
if (!wss) return;
|
|
|
|
// Surface server-level errors (ECONNRESET etc.) that would otherwise
|
|
// disappear into the void.
|
|
wss.on('error', (err) => {
|
|
console.error('[SuperSave] WSS error:', err && err.message ? err.message : err);
|
|
});
|
|
|
|
wss.on('connection', (ws, req) => {
|
|
const isUnity = req.headers['x-client-type'] === 'unity' || req.url === '/unity';
|
|
|
|
// Per-socket error handler — without this, send() failures (peer gone,
|
|
// backpressure, etc.) crash silently and the caller just hits the 60s
|
|
// timeout with no clue why.
|
|
ws.on('error', (err) => {
|
|
console.error('[SuperSave] WS socket error:', err && err.message ? err.message : err);
|
|
});
|
|
|
|
if (isUnity || !req.url.includes('mcp')) {
|
|
if (unityWebSocket) {
|
|
unityWebSocket.close();
|
|
}
|
|
unityWebSocket = ws;
|
|
|
|
ws.on('message', async (message) => {
|
|
try {
|
|
const data = JSON.parse(message);
|
|
|
|
// Handle shutdown request from new process trying to take over
|
|
if (data.type === 'shutdown') {
|
|
console.error('[SuperSave] Received shutdown request from new process, shutting down...');
|
|
shutdownServer();
|
|
return;
|
|
}
|
|
|
|
const responseId = data.id || data.operationId;
|
|
if ((data.type === 'operation_result' || data.type === 'operation_response') && responseId) {
|
|
const numericId = typeof responseId === 'string' ? parseInt(responseId) : responseId;
|
|
if (pendingRequests.has(numericId)) {
|
|
const { resolve, reject, timeout } = pendingRequests.get(numericId);
|
|
clearTimeout(timeout);
|
|
pendingRequests.delete(numericId);
|
|
if (data.success !== false) {
|
|
resolve(data.content || data.result);
|
|
} else {
|
|
reject(new Error(data.content || data.error || 'Unity command failed'));
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
unityWebSocket = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Unity command helper
|
|
const pendingRequests = new Map();
|
|
let requestId = 0;
|
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
|
async function sendUnityCommandOnce(command, params, id) {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
pendingRequests.delete(id);
|
|
reject(new Error('timeout'));
|
|
}, 60000);
|
|
|
|
pendingRequests.set(id, { resolve, reject, timeout });
|
|
|
|
const message = JSON.stringify({
|
|
type: 'unity_operation',
|
|
command: command,
|
|
parameters: {
|
|
...params,
|
|
operationId: id.toString()
|
|
}
|
|
});
|
|
|
|
// Confirm the socket is open before pushing — without this we have
|
|
// raced an in-flight close() and the message disappears.
|
|
if (!unityWebSocket || unityWebSocket.readyState !== 1 /* OPEN */) {
|
|
clearTimeout(timeout);
|
|
pendingRequests.delete(id);
|
|
const state = unityWebSocket ? unityWebSocket.readyState : 'null';
|
|
return reject(new Error(`unityWebSocket not open (readyState=${state})`));
|
|
}
|
|
|
|
// send() with callback so write failures surface immediately instead
|
|
// of bleeding into the 60s timeout. ESC-0102 root-cause hunt.
|
|
unityWebSocket.send(message, (err) => {
|
|
if (err) {
|
|
clearTimeout(timeout);
|
|
pendingRequests.delete(id);
|
|
console.error('[SuperSave] send failed:', err.message);
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function sendUnityCommand(command, params = {}) {
|
|
const MAX_RETRIES = 30;
|
|
const RETRY_DELAY = 10000;
|
|
|
|
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
if (!unityWebSocket || unityWebSocket.readyState !== WebSocket.OPEN) {
|
|
if (attempt < MAX_RETRIES) {
|
|
console.error(`[SuperSave] Unity not connected (attempt ${attempt}/${MAX_RETRIES}). Waiting...`);
|
|
await sleep(RETRY_DELAY);
|
|
continue;
|
|
}
|
|
throw new Error('Unity not connected');
|
|
}
|
|
|
|
const id = ++requestId;
|
|
try {
|
|
const result = await sendUnityCommandOnce(command, params, id);
|
|
return result;
|
|
} catch (error) {
|
|
if (attempt < MAX_RETRIES) {
|
|
console.error(`[SuperSave] Command failed (attempt ${attempt}): ${error.message}`);
|
|
await sleep(RETRY_DELAY);
|
|
}
|
|
}
|
|
}
|
|
throw new Error('Unity not connected after retries');
|
|
}
|
|
|
|
// =====================================================
|
|
// MCP Server with 3 Meta-Tools
|
|
// =====================================================
|
|
async function setupMCPServer() {
|
|
mcpServer = createServer();
|
|
|
|
// ===== META-TOOL 1: list_categories =====
|
|
mcpServer.registerTool('list_categories', {
|
|
title: 'List Tool Categories',
|
|
description: 'List all available tool categories. Use this first to discover what tools are available.',
|
|
inputSchema: z.object({})
|
|
}, async () => {
|
|
const categories = Object.entries(CATEGORIES).map(([name, data]) => ({
|
|
name,
|
|
description: data.description,
|
|
toolCount: Object.keys(data.tools).length
|
|
}));
|
|
|
|
const totalTools = Object.keys(TOOL_REGISTRY_RAW).length;
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Available Categories (${categories.length} categories, ${totalTools} total tools):\n\n` +
|
|
categories.map(c => `• ${c.name} (${c.toolCount} tools)\n ${c.description}`).join('\n\n') +
|
|
'\n\nUse list_tools(category) to see tools in a specific category.'
|
|
}]
|
|
};
|
|
});
|
|
|
|
// ===== META-TOOL 2: list_tools =====
|
|
mcpServer.registerTool('list_tools', {
|
|
title: 'List Tools in Category',
|
|
description: 'List all tools in a specific category with their parameters. Use this to learn how to use specific tools.',
|
|
inputSchema: z.object({
|
|
category: z.string().describe('Category name (e.g., "gameobject", "material", "lighting")')
|
|
})
|
|
}, async (params) => {
|
|
const category = params.category.toLowerCase();
|
|
|
|
if (!CATEGORIES[category]) {
|
|
const availableCategories = Object.keys(CATEGORIES).join(', ');
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Unknown category: "${category}"\n\nAvailable categories: ${availableCategories}`
|
|
}]
|
|
};
|
|
}
|
|
|
|
const categoryData = CATEGORIES[category];
|
|
const tools = Object.entries(categoryData.tools).map(([name, data]) => {
|
|
return `• ${name} (${data.fullName})\n ${data.title}\n ${data.description}`;
|
|
});
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Category: ${category}\n${categoryData.description}\n\nTools (${tools.length}):\n\n${tools.join('\n\n')}\n\nUse execute(tool_name, params) to run a tool.`
|
|
}]
|
|
};
|
|
});
|
|
|
|
// ===== META-TOOL 2.5: search_tools =====
|
|
mcpServer.registerTool('search_tools', {
|
|
title: 'Search Tools',
|
|
description: 'Search for tools by keyword in name, title, or description. Use this when you don\'t know the exact category.',
|
|
inputSchema: z.object({
|
|
query: z.string().describe('Search keyword (e.g., "material", "camera", "physics")'),
|
|
category: z.string().optional().describe('Optional: filter by category'),
|
|
limit: z.number().optional().default(20).describe('Max results (default: 20)')
|
|
})
|
|
}, async (params) => {
|
|
const query = (params.query || '').toLowerCase();
|
|
const categoryFilter = params.category?.toLowerCase();
|
|
const limit = params.limit || 20;
|
|
|
|
const results = [];
|
|
|
|
for (const [toolName, toolData] of Object.entries(TOOL_REGISTRY_RAW)) {
|
|
const title = toolData.title || toolName;
|
|
const description = toolData.description || '';
|
|
const category = (toolData.category || 'Other').toLowerCase();
|
|
|
|
// Category filter
|
|
if (categoryFilter && category !== categoryFilter) continue;
|
|
|
|
// If no query, return all (with limit)
|
|
if (!query) {
|
|
results.push({ name: toolName, title, description, category: toolData.category, score: 0 });
|
|
if (results.length >= limit) break;
|
|
continue;
|
|
}
|
|
|
|
// Calculate score
|
|
let score = 0;
|
|
if (toolName.toLowerCase().includes(query)) score += 100;
|
|
if (title.toLowerCase().includes(query)) score += 80;
|
|
if (description.toLowerCase().includes(query)) score += 40;
|
|
|
|
if (score > 0) {
|
|
results.push({ name: toolName, title, description, category: toolData.category, score });
|
|
}
|
|
}
|
|
|
|
// Sort by score and limit
|
|
const sorted = results
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, limit);
|
|
|
|
if (sorted.length === 0) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `No tools found for query: "${params.query}"\n\nTry different keywords or use list_categories to see available categories.`
|
|
}]
|
|
};
|
|
}
|
|
|
|
const toolList = sorted.map(t => `• ${t.name}\n ${t.title} [${t.category}]\n ${t.description.slice(0, 100)}${t.description.length > 100 ? '...' : ''}`);
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Found ${sorted.length} tools for "${params.query}":\n\n${toolList.join('\n\n')}\n\nUse execute(tool_name, params) to run a tool.`
|
|
}]
|
|
};
|
|
});
|
|
|
|
// ===== META-TOOL 2.6: get_tools_reference =====
|
|
mcpServer.registerTool('get_tools_reference', {
|
|
title: 'Get Full Tools Reference',
|
|
description: 'Get complete reference of ALL tools in Markdown format. Use this once at the start of a session to have full tool knowledge without repeated search_tools calls. Saves tokens by eliminating multiple tool discovery calls.',
|
|
inputSchema: z.object({
|
|
lang: z.enum(['en', 'jp']).optional().default('en').describe('Language for descriptions (en/jp)'),
|
|
category: z.string().optional().describe('Optional: filter by specific category'),
|
|
format: z.enum(['markdown', 'compact']).optional().default('markdown').describe('Output format: markdown (detailed) or compact (name + description only)')
|
|
})
|
|
}, async (params) => {
|
|
const lang = params.lang || 'en';
|
|
const categoryFilter = params.category?.toLowerCase();
|
|
const format = params.format || 'markdown';
|
|
|
|
// Group tools by category
|
|
const byCategory = {};
|
|
for (const [toolName, toolData] of Object.entries(TOOL_REGISTRY_RAW)) {
|
|
const category = (toolData.category || 'Other');
|
|
const categoryLower = category.toLowerCase();
|
|
|
|
if (categoryFilter && categoryLower !== categoryFilter) continue;
|
|
|
|
if (!byCategory[category]) {
|
|
byCategory[category] = [];
|
|
}
|
|
byCategory[category].push({ name: toolName, ...toolData });
|
|
}
|
|
|
|
const totalTools = Object.values(byCategory).reduce((sum, tools) => sum + tools.length, 0);
|
|
let output = '';
|
|
|
|
if (format === 'markdown') {
|
|
output = `# Synaptic AI Pro - Tools Reference\n`;
|
|
output += `Total: ${totalTools} tools in ${Object.keys(byCategory).length} categories\n\n`;
|
|
|
|
for (const [category, tools] of Object.entries(byCategory).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
output += `## ${category} (${tools.length} tools)\n\n`;
|
|
|
|
for (const tool of tools.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
output += `### ${tool.name}\n`;
|
|
output += `${tool.description || tool.title || ''}\n`;
|
|
|
|
// Add inputSchema info if available
|
|
if (tool.inputSchema?.properties) {
|
|
const props = Object.entries(tool.inputSchema.properties);
|
|
if (props.length > 0) {
|
|
output += `**Parameters:**\n`;
|
|
for (const [propName, propData] of props) {
|
|
const required = tool.inputSchema.required?.includes(propName) ? ' (required)' : '';
|
|
const type = propData.type || 'any';
|
|
const desc = propData.description || '';
|
|
output += `- \`${propName}\`: ${type}${required} - ${desc}\n`;
|
|
}
|
|
}
|
|
}
|
|
output += '\n';
|
|
}
|
|
}
|
|
} else {
|
|
// Compact format
|
|
output = `# Tools Reference (${totalTools} tools)\n\n`;
|
|
|
|
for (const [category, tools] of Object.entries(byCategory).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
output += `## ${category}\n`;
|
|
for (const tool of tools.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
output += `- ${tool.name}: ${(tool.description || tool.title || '').slice(0, 80)}\n`;
|
|
}
|
|
output += '\n';
|
|
}
|
|
}
|
|
|
|
output += `\n---\nUse execute(tool_name, params) to run any tool.`;
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: output
|
|
}]
|
|
};
|
|
});
|
|
|
|
// ===== MCP RESOURCES: Tools Reference =====
|
|
// Generate tools reference markdown from tool-registry.json
|
|
function generateToolsReference(format = 'compact') {
|
|
const byCategory = {};
|
|
for (const [toolName, toolData] of Object.entries(TOOL_REGISTRY_RAW)) {
|
|
const category = (toolData.category || 'Other');
|
|
if (!byCategory[category]) {
|
|
byCategory[category] = [];
|
|
}
|
|
byCategory[category].push({ name: toolName, ...toolData });
|
|
}
|
|
|
|
const totalTools = Object.values(byCategory).reduce((sum, tools) => sum + tools.length, 0);
|
|
let output = '';
|
|
|
|
if (format === 'markdown') {
|
|
output = `# Synaptic AI Pro - Tools Reference\n`;
|
|
output += `Total: ${totalTools} tools in ${Object.keys(byCategory).length} categories\n\n`;
|
|
|
|
for (const [category, tools] of Object.entries(byCategory).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
output += `## ${category} (${tools.length} tools)\n\n`;
|
|
for (const tool of tools.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
output += `### ${tool.name}\n`;
|
|
output += `${tool.description || tool.title || ''}\n`;
|
|
if (tool.inputSchema?.properties) {
|
|
const props = Object.entries(tool.inputSchema.properties);
|
|
if (props.length > 0) {
|
|
output += `**Parameters:**\n`;
|
|
for (const [propName, propData] of props) {
|
|
const required = tool.inputSchema.required?.includes(propName) ? ' (required)' : '';
|
|
const type = propData.type || 'any';
|
|
const desc = propData.description || '';
|
|
output += `- \`${propName}\`: ${type}${required} - ${desc}\n`;
|
|
}
|
|
}
|
|
}
|
|
output += '\n';
|
|
}
|
|
}
|
|
} else {
|
|
// Compact format
|
|
output = `# Tools Reference (${totalTools} tools)\n\n`;
|
|
for (const [category, tools] of Object.entries(byCategory).sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
output += `## ${category}\n`;
|
|
for (const tool of tools.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
output += `- ${tool.name}: ${(tool.description || tool.title || '').slice(0, 80)}\n`;
|
|
}
|
|
output += '\n';
|
|
}
|
|
}
|
|
|
|
output += `\n---\nUse execute(tool_name, params) to run any tool.`;
|
|
return output;
|
|
}
|
|
|
|
// Register MCP Resources for tools reference (for prompt caching)
|
|
mcpServer.registerResource('synaptic://tools/reference', {
|
|
title: 'Tools Reference (Compact)',
|
|
description: 'Complete list of all Unity tools in compact format. Add to context for efficient tool discovery.',
|
|
mimeType: 'text/markdown'
|
|
}, async () => {
|
|
return {
|
|
contents: [{
|
|
uri: 'synaptic://tools/reference',
|
|
mimeType: 'text/markdown',
|
|
text: generateToolsReference('compact')
|
|
}]
|
|
};
|
|
});
|
|
|
|
mcpServer.registerResource('synaptic://tools/reference/full', {
|
|
title: 'Tools Reference (Full)',
|
|
description: 'Complete list of all Unity tools with full parameter details.',
|
|
mimeType: 'text/markdown'
|
|
}, async () => {
|
|
return {
|
|
contents: [{
|
|
uri: 'synaptic://tools/reference/full',
|
|
mimeType: 'text/markdown',
|
|
text: generateToolsReference('markdown')
|
|
}]
|
|
};
|
|
});
|
|
|
|
// ===== META-TOOL 3: execute =====
|
|
mcpServer.registerTool('execute', {
|
|
title: 'Execute Tool',
|
|
description: 'Execute any Unity tool by name. Use list_tools(category) first to see available tools and their parameters.',
|
|
inputSchema: z.object({
|
|
tool: z.string().describe('Tool name to execute (e.g., "create_gameobject", "set_transform")'),
|
|
params: z.any().optional().describe('Parameters as JSON object {"name":"value"}')
|
|
})
|
|
}, async (params) => {
|
|
const toolName = params.tool;
|
|
let toolParams = params.params || {};
|
|
|
|
// Handle case where params is passed as string (e.g., '{"name":"x"}')
|
|
if (typeof toolParams === 'string') {
|
|
try {
|
|
toolParams = JSON.parse(toolParams);
|
|
} catch (e) {
|
|
// Try to parse key=value format (e.g., 'name=MyCube')
|
|
const keyValueMatch = toolParams.match(/^(\w+)=(.+)$/);
|
|
if (keyValueMatch) {
|
|
toolParams = { [keyValueMatch[1]]: keyValueMatch[2] };
|
|
} else {
|
|
// Treat as single value if it's just a plain string
|
|
toolParams = {};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normalize tool name - strip unity_ prefix if present
|
|
const strippedName = toolName.startsWith('unity_') ? toolName.substring(6) : toolName;
|
|
const fullName = `unity_${strippedName}`;
|
|
|
|
// Check if tool exists in registry
|
|
const toolInfo = ALL_TOOLS[strippedName];
|
|
if (!toolInfo) {
|
|
// Find similar tool names for helpful error message
|
|
const allToolNames = Object.keys(ALL_TOOLS);
|
|
const similar = allToolNames.filter(t =>
|
|
t.includes(strippedName) || strippedName.includes(t) ||
|
|
t.split('_').some(part => strippedName.includes(part))
|
|
).slice(0, 5);
|
|
|
|
let errorMsg = `Unknown tool: "${toolName}"`;
|
|
if (similar.length > 0) {
|
|
errorMsg += `\n\nDid you mean: ${similar.join(', ')}?`;
|
|
}
|
|
errorMsg += `\n\nUse list_categories() to see available categories, then list_tools(category) to see tools.`;
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: errorMsg
|
|
}]
|
|
};
|
|
}
|
|
|
|
// Get the command name (without unity_ prefix) for Unity
|
|
// Use lowercase - Unity's ConvertCommandToOperationType expects lowercase
|
|
const commandName = strippedName.toLowerCase();
|
|
|
|
try {
|
|
const result = await sendUnityCommand(commandName, toolParams);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
// Detailed error message
|
|
let errorDetail = `Error executing "${strippedName}":\n`;
|
|
errorDetail += ` Message: ${error.message}\n`;
|
|
|
|
if (error.message.includes('not connected')) {
|
|
errorDetail += `\nTroubleshooting:\n`;
|
|
errorDetail += ` 1. Check Unity Editor is running\n`;
|
|
errorDetail += ` 2. Verify Synaptic AI Pro is connected (check Console)\n`;
|
|
errorDetail += ` 3. Try restarting the MCP server\n`;
|
|
} else if (error.message.includes('timeout')) {
|
|
errorDetail += `\nThe command timed out. Unity may be:\n`;
|
|
errorDetail += ` - Compiling scripts\n`;
|
|
errorDetail += ` - Processing a heavy operation\n`;
|
|
errorDetail += ` - Not responding\n`;
|
|
}
|
|
|
|
errorDetail += `\nTool info: ${toolInfo.title} (${toolInfo.category})`;
|
|
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: errorDetail
|
|
}]
|
|
};
|
|
}
|
|
});
|
|
|
|
// ===== META-TOOL 3.5: run_csharp =====
|
|
// Arbitrary C# execution escape-hatch (equivalent of Blender's run_python).
|
|
// Promoted to a top-level meta-tool so small local LLMs don't have to go
|
|
// through the execute({tool, params}) two-level nest.
|
|
mcpServer.registerTool('run_csharp', {
|
|
title: 'Run C# Code',
|
|
description: 'Execute arbitrary C# code against the running Unity Editor (equivalent of Blender run_python). UnityEngine / UnityEditor / System.Linq / Newtonsoft.Json are pre-imported. Use this when no dedicated tool covers the operation. Does NOT trigger AssemblyReload so the connection stays alive.\n\nReturn value: end the snippet with `return X;` to capture X into the `result` field (prefix statements like `var x = ...;` execute first). A bare expression without trailing `;` is also accepted. Side-effect-only snippets return `result: null`. Debug.Log / LogWarning / LogError are captured into `output`.\n\nWORKS: GameObject / Transform / Component manipulation, AssetDatabase, Selection, EditorApplication, scene/asset queries via `FindObjectsByType<T>()` and other generic METHODS, generic method extension calls (`GetComponent<T>()`), arrays + foreach + LINQ-like loops, string interpolation, math, multi-statement bodies.\n\nKNOWN LIMITATION: Unity Mono.CSharp interactive parser cannot instantiate generic TYPES — `new List<int>()`, `new Dictionary<K,V>()`, `new HashSet<T>()` etc. silently return `result: null`. Workarounds: use plain arrays (`new int[] {1,2,3}`), `System.Collections.ArrayList`, or invoke generic helper methods that already exist (e.g. `FindObjectsByType<GameObject>(...)`). Generic method calls themselves are fine; only `new T<U>()` is blocked. LINQ chains that infer `IEnumerable<T>` may also fail — use `foreach` instead.',
|
|
inputSchema: z.object({
|
|
code: z.string().describe('C# code. End with `return X;` to capture X into `result`. A bare expression (no trailing `;`) also returns its value. Pre-imported: System, System.Linq, System.Collections.Generic, UnityEngine, UnityEditor, Newtonsoft.Json. AVOID `new List<int>()` / `new Dictionary<K,V>()` style generic instantiation (Mono parser limitation) — use arrays or ArrayList instead.')
|
|
})
|
|
}, async (params) => {
|
|
try {
|
|
const result = await sendUnityCommand('run_csharp', params);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `run_csharp failed: ${error.message}`
|
|
}]
|
|
};
|
|
}
|
|
});
|
|
|
|
// ===== META-TOOL 4: inspect =====
|
|
mcpServer.registerTool('inspect', {
|
|
title: 'Inspect Unity Objects',
|
|
description: 'Dynamically inspect any Unity object, component, scene, or project assets. Use this to discover what properties are available before modifying them.',
|
|
inputSchema: z.object({
|
|
target: z.enum(['gameobject', 'component', 'scene', 'project', 'prefabs', 'hierarchy'])
|
|
.describe('What to inspect: gameobject (properties/components), component (all serialized fields), scene (current scene info), project (project structure), prefabs (search prefabs), hierarchy (scene hierarchy)'),
|
|
name: z.string().optional().describe('GameObject name (for gameobject/component targets)'),
|
|
component: z.string().optional().describe('Component type to inspect (e.g., "Camera", "CinemachineVirtualCamera")'),
|
|
path: z.string().optional().describe('Asset path filter for project/prefabs (e.g., "Assets/Prefabs/*")'),
|
|
depth: z.number().optional().default(2).describe('Hierarchy depth for nested inspection (default: 2)')
|
|
})
|
|
}, async (params) => {
|
|
try {
|
|
const result = await sendUnityCommand('dynamic_inspect', params);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Inspect failed: ${error.message}\n\nTips:\n- For gameobject: provide "name"\n- For component: provide "name" and "component"\n- For prefabs: provide "path" with wildcards (e.g., "Assets/**/*.prefab")`
|
|
}]
|
|
};
|
|
}
|
|
});
|
|
|
|
// ===== META-TOOL 5: modify =====
|
|
mcpServer.registerTool('modify', {
|
|
title: 'Modify Unity Objects',
|
|
description: 'Dynamically modify any property of a Unity component using property paths. Use inspect() first to discover available properties.',
|
|
inputSchema: z.object({
|
|
gameObject: z.string().describe('GameObject name'),
|
|
component: z.string().describe('Component type (e.g., "Transform", "Camera", "CinemachineVirtualCamera")'),
|
|
properties: z.record(z.any()).describe('Property paths and values as key-value pairs (e.g., {"m_Lens.FieldOfView": 60, "m_Priority": 10})'),
|
|
createIfMissing: z.boolean().optional().default(false).describe('Add component if it does not exist')
|
|
})
|
|
}, async (params) => {
|
|
try {
|
|
const result = await sendUnityCommand('dynamic_modify', params);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Modify failed: ${error.message}\n\nTips:\n- Use inspect(target:"component") first to see available property paths\n- Nested properties use dot notation: "m_Lens.FieldOfView"\n- Array elements: "m_Materials.Array.data[0]"`
|
|
}]
|
|
};
|
|
}
|
|
});
|
|
|
|
// ===== META-TOOL 6: create =====
|
|
mcpServer.registerTool('create', {
|
|
title: 'Create Unity Objects',
|
|
description: 'Create GameObjects, instantiate prefabs, load scenes, or add components. Universal creation tool.',
|
|
inputSchema: z.object({
|
|
type: z.enum(['gameobject', 'prefab', 'scene', 'component'])
|
|
.describe('What to create: gameobject (empty or primitive), prefab (instantiate from asset), scene (load scene), component (add to existing object)'),
|
|
// For gameobject
|
|
name: z.string().optional().describe('Name for new GameObject'),
|
|
primitive: z.enum(['empty', 'cube', 'sphere', 'cylinder', 'plane', 'capsule', 'quad']).optional().describe('Primitive type (for gameobject)'),
|
|
// For prefab
|
|
asset: z.string().optional().describe('Asset path for prefab (e.g., "Assets/Prefabs/Enemy.prefab")'),
|
|
// For scene
|
|
scene: z.string().optional().describe('Scene name or path to load'),
|
|
additive: z.boolean().optional().default(false).describe('Load scene additively (for scene type)'),
|
|
// For component
|
|
gameObject: z.string().optional().describe('Target GameObject (for component type)'),
|
|
component: z.string().optional().describe('Component type to add (for component type)'),
|
|
// Common
|
|
parent: z.string().optional().describe('Parent GameObject name'),
|
|
position: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe('World position'),
|
|
rotation: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe('Euler rotation'),
|
|
scale: z.object({ x: z.number(), y: z.number(), z: z.number() }).optional().describe('Local scale')
|
|
})
|
|
}, async (params) => {
|
|
try {
|
|
const result = await sendUnityCommand('dynamic_create', params);
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
}]
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
content: [{
|
|
type: 'text',
|
|
text: `Create failed: ${error.message}\n\nExamples:\n- GameObject: {type:"gameobject", name:"Player", primitive:"capsule"}\n- Prefab: {type:"prefab", asset:"Assets/Prefabs/Enemy.prefab", position:{x:0,y:0,z:0}}\n- Scene: {type:"scene", scene:"Level2", additive:true}\n- Component: {type:"component", gameObject:"Player", component:"Rigidbody"}`
|
|
}]
|
|
};
|
|
}
|
|
});
|
|
|
|
// Start MCP server
|
|
await mcpServer.start();
|
|
}
|
|
|
|
// =====================================================
|
|
// Main Entry Point
|
|
// =====================================================
|
|
|
|
// Send shutdown request to prior process on same port
|
|
async function requestShutdownFromPriorProcess(port) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
const ws = new WebSocket(`ws://localhost:${port}`);
|
|
const timeout = setTimeout(() => {
|
|
ws.close();
|
|
resolve(false);
|
|
}, 2000);
|
|
|
|
ws.on('open', () => {
|
|
ws.send(JSON.stringify({ type: 'shutdown' }));
|
|
clearTimeout(timeout);
|
|
setTimeout(() => {
|
|
ws.close();
|
|
resolve(true);
|
|
}, 500);
|
|
});
|
|
|
|
ws.on('error', () => {
|
|
clearTimeout(timeout);
|
|
resolve(false);
|
|
});
|
|
} catch (e) {
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Try to start server with retry logic
|
|
function startServerWithRetry(port, maxRetries = 5, retryDelay = 1000) {
|
|
return new Promise((resolve, reject) => {
|
|
let attempt = 0;
|
|
|
|
const tryListen = () => {
|
|
attempt++;
|
|
console.error(`[SuperSave] Attempting to listen on port ${port} (attempt ${attempt}/${maxRetries})`);
|
|
|
|
const onError = async (err) => {
|
|
server.removeListener('error', onError);
|
|
|
|
if (err.code === 'EADDRINUSE') {
|
|
console.error(`[SuperSave] Port ${port} in use`);
|
|
|
|
// Check if existing server is healthy (another Claude session may be using it)
|
|
try {
|
|
const http = require('http');
|
|
const healthCheck = await new Promise((res, rej) => {
|
|
const req = http.get(`http://localhost:${port}/health`, { timeout: 2000 }, (resp) => {
|
|
let data = '';
|
|
resp.on('data', chunk => data += chunk);
|
|
resp.on('end', () => res(data));
|
|
});
|
|
req.on('error', rej);
|
|
req.on('timeout', () => { req.destroy(); rej(new Error('timeout')); });
|
|
});
|
|
// Existing server is healthy - don't kill it, just skip server binding
|
|
console.error(`[SuperSave] Existing healthy server found on port ${port} - sharing connection`);
|
|
resolve();
|
|
return;
|
|
} catch (e) {
|
|
// Existing server is dead - safe to take over
|
|
}
|
|
|
|
if (attempt === 1) {
|
|
// First retry: try to shutdown prior process
|
|
console.error(`[SuperSave] Sending shutdown request to prior process...`);
|
|
await requestShutdownFromPriorProcess(port);
|
|
}
|
|
|
|
if (attempt < maxRetries) {
|
|
console.error(`[SuperSave] Retrying in ${retryDelay}ms...`);
|
|
setTimeout(tryListen, retryDelay);
|
|
} else {
|
|
reject(new Error(`Failed to bind to port ${port} after ${maxRetries} attempts`));
|
|
}
|
|
} else {
|
|
reject(err);
|
|
}
|
|
};
|
|
|
|
server.once('error', onError);
|
|
|
|
server.listen(port, () => {
|
|
server.removeListener('error', onError);
|
|
console.error(`[SuperSave] Token SuperSave Mode started on port ${port}`);
|
|
console.error(`[SuperSave] Only 6 meta-tools loaded (99% context reduction)`);
|
|
resolve();
|
|
});
|
|
};
|
|
|
|
tryListen();
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
const PORT = process.env.PORT || 8090;
|
|
|
|
// Start WebSocket server
|
|
wss = new WebSocket.Server({ server });
|
|
setupWebSocketHandlers();
|
|
|
|
// Setup and start MCP
|
|
await setupMCPServer();
|
|
|
|
// Start HTTP server with retry logic for EADDRINUSE
|
|
await startServerWithRetry(PORT);
|
|
}
|
|
|
|
// Shutdown handler
|
|
function shutdownServer() {
|
|
if (unityWebSocket && unityWebSocket.readyState === WebSocket.OPEN) {
|
|
unityWebSocket.close();
|
|
}
|
|
if (wss) {
|
|
wss.close();
|
|
}
|
|
if (server && server.listening) {
|
|
server.close(() => {
|
|
process.exit(0);
|
|
});
|
|
} else {
|
|
process.exit(0);
|
|
}
|
|
setTimeout(() => {
|
|
process.exit(1);
|
|
}, 5000);
|
|
}
|
|
|
|
process.on('SIGINT', shutdownServer);
|
|
process.on('SIGTERM', shutdownServer);
|
|
process.stdin.on('close', () => {
|
|
shutdownServer();
|
|
});
|
|
|
|
process.on('uncaughtException', (error) => {
|
|
// Log EADDRINUSE and other critical errors
|
|
if (error.code === 'EADDRINUSE') {
|
|
console.error(`[SuperSave] uncaughtException: EADDRINUSE - port already in use`);
|
|
} else {
|
|
console.error(`[SuperSave] uncaughtException: ${error.message}`);
|
|
}
|
|
});
|
|
|
|
process.on('unhandledRejection', (reason, promise) => {
|
|
console.error(`[SuperSave] unhandledRejection: ${reason}`);
|
|
});
|
|
|
|
main().catch(err => {
|
|
console.error('[SuperSave] Fatal error:', err);
|
|
process.exit(1);
|
|
});
|