import express from 'express';
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs/promises';
import { GodotShell } from './adapters/godot_shell';
import { Patcher } from './adapters/patcher';
import { Context } from './adapters/context';
import { ParserGodot4 } from './adapters/parser_godot4';
dotenv.config();
interface MCPTool {
name: string;
description: string;
inputSchema: any;
}
interface MCPRequest {
id?: string;
method: string;
params?: any;
}
interface MCPResponse {
id?: string;
result?: any;
error?: any;
}
class SentinelMCPServer {
private app = express();
private server = createServer(this.app);
private wss = new WebSocketServer({ server: this.server });
private godotShell: GodotShell;
private patcher: Patcher;
private context: Context;
private parser: ParserGodot4;
private port: number;
private projectRoot: string;
constructor() {
this.port = parseInt(process.env.SENTINEL_PORT || '8787');
this.projectRoot = path.resolve(process.env.GODOT_PROJECT_ROOT || '../game');
this.godotShell = new GodotShell(this.projectRoot);
this.patcher = new Patcher(this.projectRoot);
this.context = new Context(this.projectRoot);
this.parser = new ParserGodot4();
this.setupMiddleware();
this.setupRoutes();
this.setupWebSocket();
}
private setupMiddleware(): void {
this.app.use(express.json());
this.app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'Content-Type');
next();
});
}
private setupRoutes(): void {
// MCP protocol over HTTP
this.app.post('/mcp', async (req, res) => {
try {
const response = await this.handleMCPRequest(req.body);
res.json(response);
} catch (error) {
res.status(500).json({
error: {
code: -32603,
message: 'Internal error',
data: error instanceof Error ? error.message : String(error)
}
});
}
});
// Events endpoint for Godot emitter
this.app.post('/events', async (req, res) => {
try {
const event = req.body;
this.broadcastEvent(event);
res.json({ received: true });
} catch (error) {
res.status(400).json({ error: 'Invalid event format' });
}
});
// Health check
this.app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
}
private setupWebSocket(): void {
this.wss.on('connection', (ws) => {
console.log('WebSocket client connected');
ws.on('message', async (data) => {
try {
const request: MCPRequest = JSON.parse(data.toString());
const response = await this.handleMCPRequest(request);
ws.send(JSON.stringify(response));
} catch (error) {
ws.send(JSON.stringify({
error: {
code: -32700,
message: 'Parse error',
data: error instanceof Error ? error.message : String(error)
}
}));
}
});
ws.on('close', () => {
console.log('WebSocket client disconnected');
});
});
}
private async handleMCPRequest(request: MCPRequest): Promise<MCPResponse> {
const { method, params, id } = request;
try {
let result: any;
switch (method) {
case 'tools/list':
result = this.listTools();
break;
case 'tools/call':
result = await this.callTool(params.name, params.arguments || {});
break;
case 'run_tests':
result = await this.godotShell.runTests();
break;
case 'run_game':
result = await this.godotShell.runGame(params);
break;
case 'tail_errors':
result = await this.startErrorStream();
break;
case 'get_context':
result = await this.context.getContext(params.file, params.line, params.radius);
break;
case 'apply_patch':
result = await this.patcher.applyPatch(params.unified_diff);
break;
case 'read_file':
result = await this.context.readFile(params.path);
break;
case 'write_file':
result = await this.context.writeFile(params.path, params.content);
break;
case 'list_movesets':
result = await this.context.listMovesets();
break;
case 'read_moveset':
result = await this.context.readMoveset(params.name);
break;
case 'write_moveset':
result = await this.context.writeMoveset(params.name, params.json);
break;
case 'project_map':
result = await this.context.getProjectMap();
break;
case 'set_autorun':
result = await this.setAutorun(params.enabled);
break;
default:
throw new Error(`Unknown method: ${method}`);
}
return { id, result };
} catch (error) {
return {
id,
error: {
code: -32602,
message: 'Invalid params',
data: error instanceof Error ? error.message : String(error)
}
};
}
}
private listTools(): { tools: MCPTool[] } {
return {
tools: [
{
name: 'run_tests',
description: 'Run Godot tests and return results with first error if any',
inputSchema: {
type: 'object',
properties: {},
required: []
}
},
{
name: 'run_game',
description: 'Launch the Godot game with optional scene override',
inputSchema: {
type: 'object',
properties: {
scene: { type: 'string', description: 'Optional scene path to launch' }
}
}
},
{
name: 'get_context',
description: 'Get numbered code snippet around a specific file and line',
inputSchema: {
type: 'object',
properties: {
file: { type: 'string', description: 'File path relative to project root' },
line: { type: 'number', description: 'Line number' },
radius: { type: 'number', description: 'Lines before/after to include (default 20)' }
},
required: ['file', 'line']
}
},
{
name: 'apply_patch',
description: 'Apply a unified diff patch on a new git branch',
inputSchema: {
type: 'object',
properties: {
unified_diff: { type: 'string', description: 'Git-compatible unified diff' }
},
required: ['unified_diff']
}
},
{
name: 'read_file',
description: 'Read a file from the project',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path relative to project root' }
},
required: ['path']
}
},
{
name: 'write_file',
description: 'Write content to a file in the project',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'File path relative to project root' },
content: { type: 'string', description: 'File content' }
},
required: ['path', 'content']
}
}
]
};
}
private async callTool(name: string, args: any): Promise<any> {
return this.handleMCPRequest({ method: name, params: args });
}
private async startErrorStream(): Promise<{ streaming: boolean }> {
// Start monitoring for errors and broadcast via WebSocket
this.godotShell.onError((error) => {
this.broadcastEvent({ type: 'script_error', ...error });
});
return { streaming: true };
}
private async setAutorun(enabled: boolean): Promise<{ autorun: boolean }> {
process.env.AUTORUN = enabled ? 'true' : 'false';
return { autorun: enabled };
}
private broadcastEvent(event: any): void {
const message = JSON.stringify(event);
this.wss.clients.forEach((client) => {
if (client.readyState === client.OPEN) {
client.send(message);
}
});
}
public async start(): Promise<void> {
// Ensure sentinel directory exists
const sentinelDir = path.join(process.env.HOME || '', '.sentinel');
await fs.mkdir(sentinelDir, { recursive: true });
return new Promise((resolve) => {
this.server.listen(this.port, () => {
console.log(`Sentinel MCP server listening on port ${this.port}`);
console.log(`Project root: ${this.projectRoot}`);
resolve();
});
});
}
}
if (require.main === module) {
const server = new SentinelMCPServer();
server.start().catch(console.error);
}
export { SentinelMCPServer };