Skip to main content
Glama
lsp-client.ts8.84 kB
import { Socket } from 'net'; import { EventEmitter } from 'events'; import { fileURLToPath } from 'url'; import { LSPPublishDiagnosticsParams } from './types.js'; import { DiagnosticCache, transformDiagnostics } from './diagnostics.js'; /** * LSP Client for connecting to Godot's Language Server */ export class LSPClient extends EventEmitter { private socket: Socket | null = null; private cache = new DiagnosticCache(); private buffer = ''; private readonly host: string; private workspacePath: string | null = null; private readonly debug: boolean; private isShuttingDown = false; private readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB constructor(host = '127.0.0.1', debug = false) { super(); this.host = host; this.debug = debug; } private log(...args: unknown[]): void { if (this.debug) { console.error('[LSP]', ...args); } } /** * Get Godot LSP ports to try (env var, then defaults) */ private getPortsToTry(): number[] { const envPort = process.env.GODOT_LSP_PORT; if (envPort) { const port = parseInt(envPort, 10); if (isNaN(port) || port < 1 || port > 65535) { throw new Error(`Invalid GODOT_LSP_PORT: ${envPort} (must be 1-65535)`); } return [port]; } return [6007, 6005, 6008]; // Try 6007 first (Godot 4.x), then others } /** * Connect to Godot LSP server (tries multiple ports) */ async connect(): Promise<void> { const ports = this.getPortsToTry(); for (const port of ports) { try { await this.tryConnect(port); console.error(`Connected to Godot LSP on port ${port}`); return; } catch { // Try next port } } throw new Error(`Could not connect to Godot LSP on any port: ${ports.join(', ')}`); } /** * Try connecting to a specific port */ private async tryConnect(port: number): Promise<void> { return new Promise((resolve, reject) => { const socket = new Socket(); this.socket = socket; socket.on('data', (data) => { this.log('Received', data.length, 'bytes'); this.handleData(data); }); socket.once('error', reject); socket.on('close', () => { // Only clear this.socket if THIS socket is closing (not a previous attempt) if (this.socket === socket) { this.emit('close'); this.socket = null; } }); socket.connect(port, this.host, () => { this.sendInitialize(); resolve(); }); }); } /** * Disconnect from LSP server */ disconnect(): void { this.isShuttingDown = true; if (this.socket) { this.socket.destroy(); this.socket = null; } this.cache.clearAll(); } /** * Check if reconnection is allowed */ shouldReconnect(): boolean { return !this.isShuttingDown; } /** * Open a file in the LSP to request diagnostics */ async openFile(filePath: string, fileContent: string): Promise<void> { const uri = `file://${filePath}`; const didOpenNotification = { jsonrpc: '2.0', method: 'textDocument/didOpen', params: { textDocument: { uri, languageId: 'gdscript', version: 1, text: fileContent, }, }, }; this.log('Sending didOpen for:', uri); this.sendMessage(didOpenNotification); // Godot requires didSave with includeText to trigger diagnostics const didSaveNotification = { jsonrpc: '2.0', method: 'textDocument/didSave', params: { textDocument: { uri, }, text: fileContent, }, }; this.log('Sending didSave for:', uri); this.sendMessage(didSaveNotification); // Wait a bit for diagnostics to arrive await new Promise((resolve) => setTimeout(resolve, 500)); } /** * Get diagnostics for a file path */ getDiagnostics(filePath: string) { return this.cache.get(filePath); } /** * Get all diagnostics for all files */ getAllDiagnostics() { return this.cache.getAll(); } /** * Send LSP initialize request */ private sendInitialize(): void { // Use environment variable for workspace path, or null to let Godot tell us const workspacePath = process.env.GODOT_WORKSPACE_PATH; const workspaceUri = workspacePath ? `file://${workspacePath}` : null; const workspaceName = workspacePath ? workspacePath.split('/').pop() : undefined; const initializeRequest = { jsonrpc: '2.0', id: 1, method: 'initialize', params: { processId: process.pid, clientInfo: { name: 'minimal-godot-mcp', version: '0.1.0', }, rootUri: workspaceUri, workspaceFolders: workspaceUri ? [ { uri: workspaceUri, name: workspaceName, }, ] : null, capabilities: { textDocument: { publishDiagnostics: {}, }, workspace: { workspaceFolders: true, }, }, }, }; if (workspacePath) { this.log('Initializing with workspace:', workspacePath); } else { this.log('Initializing without workspace (will use Godot notification)'); } this.sendMessage(initializeRequest); } /** * Send LSP message */ private sendMessage(message: unknown): void { if (!this.socket) { console.error('[LSP] ERROR: Cannot send message - no socket connection'); return; } const content = JSON.stringify(message); const header = `Content-Length: ${content.length}\r\n\r\n`; this.socket.write(header + content); } /** * Handle incoming data from LSP server */ private handleData(data: Buffer): void { // Prevent unbounded buffer growth from malformed messages if (this.buffer.length + data.length > this.MAX_BUFFER_SIZE) { console.error('[LSP] Buffer size exceeded, disconnecting'); this.disconnect(); return; } this.buffer += data.toString(); while (true) { const headerEnd = this.buffer.indexOf('\r\n\r\n'); if (headerEnd === -1) break; const header = this.buffer.slice(0, headerEnd); const contentLengthMatch = header.match(/Content-Length: (\d+)/); if (!contentLengthMatch) break; const contentLength = parseInt(contentLengthMatch[1], 10); const messageStart = headerEnd + 4; const messageEnd = messageStart + contentLength; if (this.buffer.length < messageEnd) break; const messageContent = this.buffer.slice(messageStart, messageEnd); this.buffer = this.buffer.slice(messageEnd); try { this.handleMessage(JSON.parse(messageContent)); } catch (err) { console.error('[LSP] Failed to parse message:', err); } } } /** * Handle parsed LSP message */ private handleMessage(message: { id?: number; method?: string; params?: unknown; result?: unknown }): void { this.log('Message:', message.method || `response(id=${message.id})`); // Handle initialize response - must send initialized notification if (message.id === 1 && message.result) { this.log('Sending initialized notification'); this.sendMessage({ jsonrpc: '2.0', method: 'initialized', params: {}, }); } if (message.method === 'textDocument/publishDiagnostics') { const params = message.params as LSPPublishDiagnosticsParams; this.handlePublishDiagnostics(params); } if (message.method === 'gdscript_client/changeWorkspace') { this.handleWorkspaceChange(message.params); } } /** * Handle publishDiagnostics notification */ private handlePublishDiagnostics(params: LSPPublishDiagnosticsParams): void { this.log('Diagnostics received:', params.uri, `(${params.diagnostics.length} items)`); const filePath = fileURLToPath(params.uri); // Transform and cache diagnostics const diagnostics = transformDiagnostics(params.diagnostics); this.cache.set(filePath, diagnostics); // Emit event for subscribers this.emit('diagnostics', filePath, diagnostics); } /** * Handle workspace change notification from Godot */ private handleWorkspaceChange(params: unknown): void { this.log('Workspace changed:', params); // Store workspace path if (params && typeof params === 'object' && 'path' in params) { this.workspacePath = params.path as string; } // Clear all cached diagnostics when workspace changes this.cache.clearAll(); // Emit workspace change event this.emit('workspaceChange', params); } /** * Get current workspace path */ getWorkspacePath(): string | null { return this.workspacePath; } }

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/ryanmazzolini/minimal-godot-mcp'

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