Files
FreewayGamesTest/Assets/Synaptic AI Pro/MCPServer/http-server.js
T

614 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Synaptic AI Pro - HTTP Server (Standalone)
*
* Node.js based HTTP server for Unity control.
* Runs outside Unity process - no domain reload issues.
*
* Usage:
* node http-server.js [port]
* node http-server.js 8086
*
* Environment variables:
* HTTP_PORT - Server port (default: 8086)
*
* HTTP and WebSocket run on the SAME port.
* - HTTP: http://localhost:8086/
* - WebSocket: ws://localhost:8086/
*/
const express = require('express');
const http = require('http');
const WebSocket = require('ws');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
// ===== Configuration =====
const PORT = parseInt(process.argv[2]) || parseInt(process.env.HTTP_PORT) || 8086;
// ===== Parent-PID watchdog =====
// When launched detached from Unity's JobObject (Windows), we lose the
// guarantee that the OS will reap us if Unity dies. Poll the parent PID
// every 5s and self-terminate if it's gone — prevents orphaned node.exe.
(function setupParentWatchdog() {
const arg = (process.argv.find(a => typeof a === 'string' && a.startsWith('--parent-pid=')) || '');
const parentPid = arg ? parseInt(arg.split('=')[1], 10) : 0;
if (!parentPid) return;
setInterval(() => {
try {
// Signal 0 is a "check process exists" probe on both POSIX and Windows
process.kill(parentPid, 0);
} catch (_e) {
console.error(`[watchdog] Parent PID ${parentPid} gone. Exiting.`);
process.exit(0);
}
}, 5000).unref();
})();
// ===== File log (when --log=path is provided) =====
// Detached mode disables stdout/stderr piping from C# side, so route logs
// to a file instead. Best-effort: silent failure if file can't be opened.
(function setupFileLog() {
const arg = (process.argv.find(a => typeof a === 'string' && a.startsWith('--log=')) || '');
const logPath = arg ? arg.split('=').slice(1).join('=') : '';
if (!logPath) return;
try {
const fs = require('fs');
const stream = fs.createWriteStream(logPath, { flags: 'a' });
const wrap = (orig) => (...args) => {
try {
const line = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
stream.write(`[${new Date().toISOString()}] ${line}\n`);
} catch (_) { /* ignore */ }
try { orig.apply(console, args); } catch (_) { /* detached: stdout may be closed */ }
};
console.log = wrap(console.log);
console.error = wrap(console.error);
console.warn = wrap(console.warn);
} catch (_e) {
/* ignore: log is best-effort */
}
})();
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
const server = http.createServer(app);
// ===== Tool Registry =====
let toolRegistry = {};
let toolCategories = {};
function loadToolRegistry() {
try {
const registryPath = path.join(__dirname, 'tool-registry.json');
if (fs.existsSync(registryPath)) {
toolRegistry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
// Build category index
toolCategories = {};
for (const [name, tool] of Object.entries(toolRegistry)) {
const cat = tool.category || 'Other';
if (!toolCategories[cat]) toolCategories[cat] = [];
toolCategories[cat].push({ name, ...tool });
}
console.error(`[HTTP] Loaded ${Object.keys(toolRegistry).length} tools in ${Object.keys(toolCategories).length} categories`);
} else {
console.error('[HTTP] Warning: tool-registry.json not found');
}
} catch (e) {
console.error(`[HTTP] Failed to load tool registry: ${e.message}`);
}
}
loadToolRegistry();
// ===== Unity WebSocket Connection =====
let unitySocket = null;
let wss = null;
const pendingRequests = new Map();
let requestCounter = 0;
function setupWebSocket() {
// Attach WebSocket to the same HTTP server (same port)
wss = new WebSocket.Server({ server });
wss.on('connection', (ws, req) => {
const isUnity = req.headers['x-client-type'] === 'unity' || req.url === '/unity';
if (isUnity || !req.url || req.url === '/') {
if (unitySocket) {
try { unitySocket.close(); } catch (e) {}
}
unitySocket = ws;
console.error(`[HTTP] Unity connected via WebSocket`);
ws.on('message', (message) => {
try {
const data = JSON.parse(message);
// Handle response from Unity
if (data.operationId && pendingRequests.has(data.operationId)) {
const { resolve } = pendingRequests.get(data.operationId);
pendingRequests.delete(data.operationId);
resolve(data);
}
} catch (e) {
console.error(`[HTTP] WebSocket message error: ${e.message}`);
}
});
ws.on('close', () => {
console.error('[HTTP] Unity disconnected');
unitySocket = null;
});
ws.on('error', (err) => {
console.error(`[HTTP] WebSocket error: ${err.message}`);
});
// ESC-0108 fix (reported by xvpower., 2026-05-22):
// Mono's `ClientWebSocket` (Unity) does NOT auto-respond to
// WebSocket protocol-level `ping` frames with `pong` — unlike
// .NET 5+ runtime. The previous `ws.ping() / ws.on('pong')`
// heartbeat therefore terminated the connection after ~30s on
// every Unity 6.3 LTS environment.
// Switch to last-message-seen tracking: Unity already emits its
// own application-level traffic (operation_response, debug logs)
// so we just observe the timestamp of any received frame.
ws.lastSeen = Date.now();
const recordSeen = () => { ws.lastSeen = Date.now(); };
ws.on('message', recordSeen);
ws.on('ping', recordSeen);
ws.on('pong', recordSeen);
}
});
// Heartbeat: declare a connection dead only if no inbound frame for N ms.
// Override via env (UNITY_STALE_TIMEOUT_MS); default 60 000 keeps a safe
// margin over Unity's slowest legitimate quiet period.
const STALE_TIMEOUT_MS = parseInt(process.env.UNITY_STALE_TIMEOUT_MS || '60000', 10);
const heartbeatInterval = setInterval(() => {
if (!wss.clients) return;
wss.clients.forEach((ws) => {
if (!ws.lastSeen) return;
if (Date.now() - ws.lastSeen > STALE_TIMEOUT_MS) {
console.error(`[HTTP] Unity stale (no frames for ${STALE_TIMEOUT_MS}ms) - closing connection`);
try { ws.terminate(); } catch {}
}
});
}, 30000);
wss.on('close', () => {
clearInterval(heartbeatInterval);
});
wss.on('error', (err) => {
console.error(`[HTTP] WebSocket server error: ${err.message}`);
});
console.error(`[HTTP] WebSocket server attached to HTTP server (port ${PORT})`);
}
// Execute tool via Unity WebSocket
function executeOnUnity(toolName, params, timeout = 30000) {
return new Promise((resolve, reject) => {
if (!unitySocket || unitySocket.readyState !== WebSocket.OPEN) {
reject(new Error('Unity not connected. Open Unity with Synaptic AI Pro project.'));
return;
}
const operationId = `http_${++requestCounter}_${Date.now()}`;
const operation = convertToolToOperation(toolName);
const message = {
type: 'operation',
operationType: operation,
operationId: operationId,
parameters: params || {}
};
const timer = setTimeout(() => {
pendingRequests.delete(operationId);
reject(new Error(`Timeout waiting for Unity response (${timeout}ms)`));
}, timeout);
pendingRequests.set(operationId, {
resolve: (data) => {
clearTimeout(timer);
resolve(data);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
}
});
unitySocket.send(JSON.stringify(message));
});
}
// Convert tool name to Unity operation type
function convertToolToOperation(toolName) {
// Remove unity_ prefix and convert to UPPER_SNAKE_CASE
let name = toolName;
if (name.startsWith('unity_')) {
name = name.substring(6);
}
return name.toUpperCase();
}
// ===== Prompt Generation =====
function getAIControlPrompt() {
return `# Synaptic AI Pro (Unity) HTTP Control Instructions
## Prerequisites
- Unity must be open with Synaptic AI Pro project loaded
- HTTP Server running: node http-server.js ${PORT}
- Unity connects via WebSocket automatically
## Endpoints
- GET / or /prompt - Get this AI control prompt + full tools reference
- GET /health - Server status and Unity connection
- GET /categories - List all tool categories
- GET /tools - Full tool registry
- GET /tools/category/:cat - List tools in category with inputSchema
- GET /tools/search?q=keyword - Search tools by name, description, parameters
- GET /tools/reference - Get ALL tools in Markdown format (TOKEN SAVER)
- GET /resources - List available resources (MCP-style)
- GET /resources/read?uri=synaptic://tools/reference - Read a resource
- POST /execute - Execute single tool (RECOMMENDED)
- POST /batch - Execute multiple tools at once (RECOMMENDED)
## Verify connection
curl http://localhost:${PORT}/health
## Tool discovery
curl http://localhost:${PORT}/categories
curl http://localhost:${PORT}/tools/category/scene
## Tool search
curl "http://localhost:${PORT}/tools/search?q=material"
curl "http://localhost:${PORT}/tools/search?q=color&category=Material&limit=10"
## Get full tools reference (RECOMMENDED at session start)
curl "http://localhost:${PORT}/tools/reference"
curl "http://localhost:${PORT}/tools/reference?format=compact"
## Single tool execution (RECOMMENDED)
curl -X POST http://localhost:${PORT}/execute -H "Content-Type: application/json" -d '{"tool":"unity_create_gameobject","params":{"name":"MyCube","type":"cube"}}'
## Batch execution (RECOMMENDED for multiple operations)
curl -X POST http://localhost:${PORT}/batch -H "Content-Type: application/json" -d '[
{"tool":"unity_create_gameobject","params":{"name":"Cube1","type":"cube"}},
{"tool":"unity_set_transform","params":{"name":"Cube1","position":"2,0,0"}},
{"tool":"unity_create_gameobject","params":{"name":"Sphere1","type":"sphere"}}
]'
## If connection fails
- "Unity not connected" → Open Unity project with Synaptic AI Pro
- Connection refused → HTTP server not running (node http-server.js)
## Notes
- All responses are JSON
- Use /batch for multiple operations (more efficient)
- 30 second timeout per request
`;
}
// ===== Tools Reference Generation =====
function getToolsReference(format = 'markdown', category = null) {
const tools = category
? (toolCategories[category] || [])
: Object.entries(toolRegistry).map(([name, t]) => ({ name, ...t }));
if (format === 'compact') {
// Compact format: name | description (one line per tool)
let output = '# Synaptic AI Pro - Tools Reference (Compact)\n\n';
output += `Total: ${tools.length} tools\n\n`;
const byCategory = {};
for (const tool of tools) {
const cat = tool.category || 'Other';
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(tool);
}
for (const [cat, catTools] of Object.entries(byCategory).sort()) {
output += `## ${cat} (${catTools.length})\n`;
for (const tool of catTools) {
const desc = (tool.description || '').split('.')[0].substring(0, 80);
output += `- ${tool.name}: ${desc}\n`;
}
output += '\n';
}
return output;
}
// Full markdown format with inputSchema
let output = '# Synaptic AI Pro - Tools Reference\n\n';
output += `Total: ${tools.length} tools\n\n`;
const byCategory = {};
for (const tool of tools) {
const cat = tool.category || 'Other';
if (!byCategory[cat]) byCategory[cat] = [];
byCategory[cat].push(tool);
}
for (const [cat, catTools] of Object.entries(byCategory).sort()) {
output += `## ${cat}\n\n`;
for (const tool of catTools) {
output += `### ${tool.name}\n`;
output += `${tool.description || 'No description'}\n\n`;
if (tool.inputSchema && tool.inputSchema.properties) {
output += '**Parameters:**\n';
for (const [param, schema] of Object.entries(tool.inputSchema.properties)) {
const required = (tool.inputSchema.required || []).includes(param) ? ' (required)' : '';
const desc = schema.description || '';
output += `- \`${param}\`${required}: ${desc}\n`;
}
output += '\n';
}
}
}
return output;
}
// ===== HTTP Routes =====
// Root - return prompt + full tools reference
app.get('/', (req, res) => {
const prompt = getAIControlPrompt();
const toolsRef = getToolsReference('markdown');
res.type('text/plain').send(prompt + '\n\n---\n\n' + toolsRef);
});
// Prompt endpoint
app.get('/prompt', (req, res) => {
const prompt = getAIControlPrompt();
const toolsRef = getToolsReference('markdown');
res.type('text/plain').send(prompt + '\n\n---\n\n' + toolsRef);
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
server: 'Synaptic AI Pro Unity HTTP Server',
port: PORT,
tools: Object.keys(toolRegistry).length,
unityConnected: unitySocket !== null && unitySocket.readyState === WebSocket.OPEN,
toolCount: Object.keys(toolRegistry).length,
categoryCount: Object.keys(toolCategories).length
});
});
// Categories list
app.get('/categories', (req, res) => {
const categories = Object.entries(toolCategories)
.map(([name, tools]) => ({ name, count: tools.length }))
.sort((a, b) => a.name.localeCompare(b.name));
res.json({ categories });
});
// Full tool registry
app.get('/tools', (req, res) => {
res.json(toolRegistry);
});
// Tool list (Synaptic Code compatible)
app.get('/tools/list', (req, res) => {
const toolNames = Object.keys(toolRegistry);
res.json({
tools: toolNames,
count: toolNames.length
});
});
// Tools by category
app.get('/tools/category/:category', (req, res) => {
const category = req.params.category;
const tools = toolCategories[category];
if (!tools) {
return res.status(404).json({
error: `Category not found: ${category}`,
available: Object.keys(toolCategories).sort()
});
}
res.json({ category, count: tools.length, tools });
});
// Tool search
app.get('/tools/search', (req, res) => {
const query = (req.query.q || '').toLowerCase();
const categoryFilter = req.query.category;
const limit = parseInt(req.query.limit) || 20;
if (!query) {
return res.status(400).json({ error: 'Missing query parameter: q' });
}
const results = [];
const keywords = query.split(/\s+/);
for (const [name, tool] of Object.entries(toolRegistry)) {
if (categoryFilter && tool.category !== categoryFilter) continue;
let score = 0;
const searchText = `${name} ${tool.title || ''} ${tool.description || ''}`.toLowerCase();
for (const kw of keywords) {
if (name.toLowerCase().includes(kw)) score += 10;
if ((tool.title || '').toLowerCase().includes(kw)) score += 5;
if ((tool.description || '').toLowerCase().includes(kw)) score += 2;
}
if (score > 0) {
results.push({ name, score, ...tool });
}
}
results.sort((a, b) => b.score - a.score);
const limited = results.slice(0, limit);
res.json({ query, count: limited.length, total: results.length, results: limited });
});
// Tools reference (markdown)
app.get('/tools/reference', (req, res) => {
const format = req.query.format || 'markdown';
const category = req.query.category || null;
const reference = getToolsReference(format, category);
res.type('text/plain').send(reference);
});
// Resources list (MCP-style)
app.get('/resources', (req, res) => {
res.json({
resources: [
{
uri: 'synaptic://tools/reference',
name: 'Tools Reference (Compact)',
description: 'Complete list of all Unity tools in compact format (~30KB)',
mimeType: 'text/markdown'
},
{
uri: 'synaptic://tools/reference/full',
name: 'Tools Reference (Full)',
description: 'Complete list of all Unity tools with full parameter details (~100KB)',
mimeType: 'text/markdown'
}
]
});
});
// Read resource
app.get('/resources/read', (req, res) => {
const uri = req.query.uri;
if (!uri) {
return res.status(400).json({ error: 'Missing uri parameter' });
}
let content = null;
let mimeType = 'text/markdown';
if (uri === 'synaptic://tools/reference') {
content = getToolsReference('compact');
} else if (uri === 'synaptic://tools/reference/full') {
content = getToolsReference('markdown');
} else {
return res.status(404).json({ error: `Unknown resource: ${uri}` });
}
res.json({
contents: [
{ uri, mimeType, text: content }
]
});
});
// Execute single tool
app.post('/execute', async (req, res) => {
try {
const { tool, params, timeout } = req.body;
if (!tool) {
return res.status(400).json({ error: 'Missing tool name' });
}
const result = await executeOnUnity(tool, params || {}, timeout || 30000);
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Batch execute
app.post('/batch', async (req, res) => {
try {
const operations = req.body;
if (!Array.isArray(operations)) {
return res.status(400).json({ error: 'Body must be an array of operations' });
}
const results = [];
for (const op of operations) {
try {
const result = await executeOnUnity(op.tool, op.params || {});
results.push({ tool: op.tool, success: true, result });
} catch (e) {
results.push({ tool: op.tool, success: false, error: e.message });
}
}
res.json({ count: results.length, results });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// Legacy: POST /tool/:name
app.post('/tool/:name', async (req, res) => {
try {
const tool = req.params.name;
const params = req.body || {};
const result = await executeOnUnity(tool, params);
res.json(result);
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// ===== Startup =====
server.listen(PORT, () => {
// Setup WebSocket after server starts (attached to same port)
setupWebSocket();
console.error(`
╔═══════════════════════════════════════════════════════════╗
║ Synaptic AI Pro - HTTP Server ║
╠═══════════════════════════════════════════════════════════╣
║ Port: ${PORT.toString().padEnd(5)}
║ HTTP: http://localhost:${PORT.toString().padEnd(5)}
║ WebSocket: ws://localhost:${PORT.toString().padEnd(5)}
╠═══════════════════════════════════════════════════════════╣
║ Endpoints: ║
║ GET / - AI prompt + tools reference ║
║ GET /health - Connection status ║
║ GET /tools - Full tool registry ║
║ GET /categories - Tool categories ║
║ POST /execute - Execute tool ║
║ POST /batch - Batch execute ║
╠═══════════════════════════════════════════════════════════╣
║ Waiting for Unity to connect... ║
╚═══════════════════════════════════════════════════════════╝
`);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.error('\n[HTTP] Shutting down...');
if (wss) wss.close();
server.close();
process.exit(0);
});
process.on('SIGTERM', () => {
console.error('\n[HTTP] Shutting down...');
if (wss) wss.close();
server.close();
process.exit(0);
});