Skip to main content
Glama
server.ts9.14 kB
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 };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Snack-JPG/Godot-Sentinel-MCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server