Skip to main content
Glama
lsp-client.ts21.5 kB
/** * LSP Client management - spawning, initialization, and connection lifecycle * Based on working patterns from playground/dotnet.ts */ import * as cp from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as rpc from 'vscode-jsonrpc'; import which from 'which'; import { InitializeParams, WorkspaceFolder, PublishDiagnosticsParams, InitializeResult, } from 'vscode-languageserver-protocol'; import { LspClient, LspClientResult, LspConfig, Result, DiagnosticsStore, DiagnosticProviderStore, DiagnosticProvider, WindowLogStore, LogMessage, createLspError, ErrorCode, } from './types.js'; import { ParsedLspConfig } from './config/lsp-config.js'; import logger from './utils/logger.js'; import { createWorkspaceLoader } from './workspace/registry.js'; import { WorkspaceLoaderStore } from './types.js'; import { expandEnvVars } from './utils/env-expansion.js'; export function createLspClient( workspaceConfig: LspConfig, lspConfig: ParsedLspConfig, diagnosticsStore: DiagnosticsStore, diagnosticProviderStore: DiagnosticProviderStore, windowLogStore: WindowLogStore, workspaceLoaderStore: WorkspaceLoaderStore ): Result<LspClientResult> { try { logger.debug('Creating LSP client', { lspName: lspConfig.name, command: `${lspConfig.commandName} ${lspConfig.commandArgs.join(' ')}`, workspace: workspaceConfig.workspaceUri, environment: lspConfig.environment ? Object.keys(lspConfig.environment) : 'none', }); // Create expansion environment (temporary, for variable substitution in command/args) // Includes all env vars + YAML overrides + SYMBOLS_WORKSPACE_NAME for substitution const expansionEnv = { ...process.env, ...(lspConfig.environment || {}), SYMBOLS_WORKSPACE_NAME: workspaceConfig.workspaceName, }; logger.debug('Spawning LSP server process', { commandName: lspConfig.commandName, commandArgs: lspConfig.commandArgs, hasCustomEnv: !!lspConfig.environment, }); // Expand command and args using the expansion environment const processedCommandName = expandEnvVars( lspConfig.commandName.trim(), expansionEnv ); const processedCommandArgs = lspConfig.commandArgs.map((arg) => expandEnvVars(arg.trim(), expansionEnv) ); // Create clean LSP runtime environment (filters out SYMBOLS_* vars except those in YAML) // This ensures SYMBOLS_* vars used by the MCP server don't leak to LSP processes const filteredProcessEnv = Object.fromEntries( Object.entries(process.env).filter(([key]) => !key.startsWith('SYMBOLS_')) ); const lspEnv = lspConfig.environment ? { ...filteredProcessEnv, ...lspConfig.environment } : filteredProcessEnv; logger.debug('Processed LSP command with environment variables', { originalCommand: `${lspConfig.commandName} ${lspConfig.commandArgs.join(' ')}`, processedCommand: `${processedCommandName} ${processedCommandArgs.join(' ')}`, workspaceName: workspaceConfig.workspaceName, symbolsWorkspaceName: expansionEnv.SYMBOLS_WORKSPACE_NAME, }); // Check if command exists before spawning (comprehensive validation) logger.debug('Validating LSP server binary exists'); try { // Cross-platform path detection: absolute paths, relative paths, or paths with separators const isPath = path.isAbsolute(processedCommandName) || processedCommandName.includes(path.sep) || processedCommandName.includes('/'); if (isPath) { // Absolute or relative path - already processed (trimmed and env expanded) const expandedPath = processedCommandName; if (!fs.existsSync(expandedPath)) { throw new Error(`Binary not found: ${expandedPath}`); } // Check if it's executable try { fs.accessSync(expandedPath, fs.constants.X_OK); } catch { throw new Error(`Binary not executable: ${expandedPath}`); } } else { // Command name only - check if it exists in PATH (cross-platform using 'which' package) try { // Use the same PATH as the spawn will use to avoid false positives/negatives const resolvedPath = which.sync(processedCommandName, { path: lspEnv.PATH || process.env.PATH, nothrow: false, }); logger.debug('Found LSP server in PATH', { commandName: processedCommandName, resolvedPath, platform: process.platform, }); } catch { throw new Error( `Command '${processedCommandName}' not found in PATH` ); } } } catch (error) { logger.error('LSP server binary validation failed', { commandName: processedCommandName, error: error instanceof Error ? error.message : String(error), }); throw error; } // Command and args are already processed (trimmed and env-expanded) logger.info('Spawning LSP server', { originalCommand: lspConfig.commandName, processedCommand: processedCommandName, originalArgs: lspConfig.commandArgs, processedArgs: processedCommandArgs, workingDirectory: process.cwd(), hasCustomEnv: !!lspConfig.environment, }); // Spawn the configured Language Server with clean environment const serverProcess = cp.spawn(processedCommandName, processedCommandArgs, { env: lspEnv, // 1st stdin, 2nd stdout, 3rd stderr stdio: ['pipe', 'pipe', 'pipe'], }); logger.info(`LSP server process spawned with PID: ${serverProcess.pid}`); // Handle process errors and stderr with enhanced logging serverProcess.on('error', (error) => { logger.error('[LSP-ERROR]', { error: error.message, stack: error.stack, errno: (error as NodeJS.ErrnoException).errno, code: (error as NodeJS.ErrnoException).code, syscall: (error as NodeJS.ErrnoException).syscall, path: (error as NodeJS.ErrnoException).path, processedCommand: processedCommandName, processedArgs: processedCommandArgs, }); }); serverProcess.on('exit', (code, signal) => { logger.warn('[LSP-EXIT]', { code, signal, pid: serverProcess.pid, processedCommand: processedCommandName, processedArgs: processedCommandArgs, exitReason: code !== null ? `exit code ${code}` : `signal ${signal}`, }); }); // Check if process spawned successfully if (!serverProcess.pid) { throw new Error( `Failed to spawn LSP server process. ` + `Command: ${processedCommandName}, Args: [${processedCommandArgs.join(', ')}]. ` + `Check if the binary exists and is executable.` ); } // Log stderr output from LSP server if (serverProcess.stderr) { //serverProcess.stderr.setEncoding('utf8'); serverProcess.stderr.on('data', (data: Buffer | string) => { const message = typeof data === 'string' ? data.trim() : data.toString('utf8').trim(); if (message) { logger.debug('[LSP-STDERR]', { message }); } }); } logger.debug('Creating JSON-RPC connection over process streams'); // Create JSON-RPC connection using the overload that accepts streams directly // vscode-jsonrpc expects Readable/Writable but child_process streams are compatible const connection = rpc.createMessageConnection( // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument serverProcess.stdout as any, // Readable stream from child process // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument serverProcess.stdin as any // Writable stream to child process ); logger.debug('JSON-RPC connection created successfully'); // Add comprehensive message logging before setting up handlers logger.debug('Setting up comprehensive LSP message logging'); // Log all incoming notifications (including unhandled ones) connection.onNotification((method: string, params: unknown) => { logger.debug('LSP notification received', { method, params }); }); // Log all incoming requests (including unhandled ones) connection.onRequest((method: string, params: unknown) => { logger.debug('LSP request received', { method, params }); if (method === 'workspace/configuration') { return []; } // Return null instead of MethodNotFound error to prevent LSP crashes // Some LSPs (like pyright) crash when receiving MethodNotFound responses // for requests they send that we don't handle (e.g., textDocument/semanticTokens/full) return null; }); // Log connection errors connection.onError((error) => { logger.error('LSP connection error', { error: error instanceof Error ? error.message : JSON.stringify(error), stack: error instanceof Error ? error.stack : undefined, }); }); // Log connection close connection.onClose(() => { logger.warn('LSP connection closed'); }); // Set up notification handlers before listening logger.debug('Setting up LSP notification handlers'); setupNotificationHandlers( connection, diagnosticsStore, diagnosticProviderStore, windowLogStore, workspaceLoaderStore ); // Start listening logger.debug('Starting JSON-RPC connection listener'); connection.listen(); const client: LspClient = { connection, isInitialized: false, ...(serverProcess.pid !== undefined && { processId: serverProcess.pid }), }; const result: LspClientResult = { client, process: serverProcess, }; logger.debug('LSP client created successfully', { lspName: lspConfig.name, pid: serverProcess.pid, hasProcessId: client.processId !== undefined, }); return { ok: true, data: result }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Failed to create LSP client', { lspName: lspConfig.name, command: `${lspConfig.commandName} ${lspConfig.commandArgs.join(' ')}`, error: errorMessage, stack: error instanceof Error ? error.stack : undefined, }); return { ok: false, error: createLspError( ErrorCode.LSPError, errorMessage, error instanceof Error ? error : undefined ), }; } } export async function initializeLspClient( client: LspClient, config: LspConfig, diagnosticProviderStore: DiagnosticProviderStore, workspaceLoaderStore: WorkspaceLoaderStore, lspConfig: ParsedLspConfig ): Promise<Result<InitializeResult>> { try { // Initialize the server with proper workspace and capabilities const initParams: InitializeParams = { processId: client.processId || process.pid, rootUri: config.workspaceUri, workspaceFolders: [ { name: config.workspaceName, uri: config.workspaceUri, } as WorkspaceFolder, ], capabilities: { workspace: { // diagnostics capability disabled for now }, textDocument: { publishDiagnostics: { relatedInformation: true, versionSupport: true, codeDescriptionSupport: true, dataSupport: true, }, diagnostic: { dynamicRegistration: true, relatedDocumentSupport: true, }, synchronization: { didSave: true, }, semanticTokens: { dynamicRegistration: true, requests: { range: false, full: { delta: false, }, }, tokenTypes: [ 'namespace', 'type', 'class', 'enum', 'interface', 'struct', 'typeParameter', 'parameter', 'variable', 'property', 'enumMember', 'event', 'function', 'method', 'macro', 'keyword', 'modifier', 'comment', 'string', 'number', 'regexp', 'operator', 'decorator', ], tokenModifiers: [ 'declaration', 'definition', 'readonly', 'static', 'deprecated', 'abstract', 'async', 'modification', 'documentation', 'defaultLibrary', ], formats: ['relative'], }, }, ...config.clientCapabilities, }, }; const initResult: InitializeResult = await client.connection.sendRequest( 'initialize', initParams ); // Send initialized notification await client.connection.sendNotification('initialized', {}); client.isInitialized = true; client.serverCapabilities = initResult.capabilities; // Initialize workspace loader based on LSP configuration await initializeWorkspaceLoader( client, config, workspaceLoaderStore, lspConfig ); // Extract diagnostic providers from server capabilities extractDiagnosticProvidersFromCapabilities( initResult.capabilities, diagnosticProviderStore ); return { ok: true, data: initResult }; } catch (error) { return { ok: false, error: createLspError( ErrorCode.LSPError, `Failed to initialize LSP client: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error : undefined ), }; } } /** * Initialize workspace loader based on LSP configuration - Pure Functional Approach */ async function initializeWorkspaceLoader( client: LspClient, config: LspConfig, workspaceLoaderStore: WorkspaceLoaderStore, lspConfig: ParsedLspConfig ): Promise<void> { try { // Get workspace loader type from config, default to 'default' const loaderType = lspConfig.workspace_loader || 'default'; // Create workspace loader using factory const loader = createWorkspaceLoader(loaderType); workspaceLoaderStore.setLoader(loader); // Initialize workspace using pure functions const initialState = await loader.initialize(client, config); workspaceLoaderStore.setState(initialState); logger.debug('Workspace loader initialized', { loaderType, workspaceType: initialState.type, ready: initialState.ready, }); } catch (error) { logger.error('Failed to initialize workspace loader', { error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined, }); // On error, fall back to default loader in ready state const defaultLoader = createWorkspaceLoader('default'); workspaceLoaderStore.setLoader(defaultLoader); const fallbackState = await defaultLoader.initialize(client, config); workspaceLoaderStore.setState(fallbackState); } } export async function shutdownLspClient(client: LspClient): Promise<void> { try { if (client.isInitialized) { await client.connection.sendRequest('shutdown'); await client.connection.sendNotification('exit', null); } } catch { // Silent error handling - no console.log } } function extractDiagnosticProvidersFromCapabilities( capabilities: unknown, diagnosticProviderStore: DiagnosticProviderStore ): void { try { // Check if server supports diagnostics through capabilities const diagnosticProvider = ( capabilities as { diagnosticProvider?: unknown } ).diagnosticProvider; if (diagnosticProvider) { logger.debug('Found diagnostic provider in server capabilities', { provider: diagnosticProvider, }); const typedProvider = diagnosticProvider as { documentSelector?: DiagnosticProvider['documentSelector']; interFileDependencies?: boolean; workspaceDiagnostics?: boolean; }; const provider: DiagnosticProvider = { id: 'server-capabilities', ...(typedProvider.documentSelector && { documentSelector: typedProvider.documentSelector, }), ...(typedProvider.interFileDependencies !== undefined && { interFileDependencies: typedProvider.interFileDependencies, }), ...(typedProvider.workspaceDiagnostics !== undefined && { workspaceDiagnostics: typedProvider.workspaceDiagnostics, }), }; diagnosticProviderStore.addProvider(provider); logger.debug('Added diagnostic provider from server capabilities', { providerId: provider.id, workspaceDiagnostics: provider.workspaceDiagnostics, interFileDependencies: provider.interFileDependencies, }); } else { logger.debug('No diagnostic provider found in server capabilities'); } } catch (error) { logger.error('Failed to extract diagnostic providers from capabilities', { error: error instanceof Error ? error.message : String(error), }); } } function setupNotificationHandlers( connection: rpc.MessageConnection, diagnosticsStore: DiagnosticsStore, diagnosticProviderStore: DiagnosticProviderStore, windowLogStore: WindowLogStore, workspaceLoaderStore?: WorkspaceLoaderStore ): void { // Handle diagnostics publication (critical for getDiagnostics tool) connection.onNotification( 'textDocument/publishDiagnostics', (params: PublishDiagnosticsParams) => { diagnosticsStore.addDiagnostics(params.uri, params.diagnostics); } ); // Handle window log messages (for getWindowLogMessages tool) connection.onNotification('window/logMessage', (params: LogMessage) => { windowLogStore.addMessage(params); }); // Handle show message notifications (silent) connection.onNotification('window/showMessage', () => { // Store or ignore as needed }); // Handle capability registration requests connection.onRequest('client/registerCapability', (params: unknown) => { try { // Extract diagnostic providers from registration requests if (params && typeof params === 'object' && 'registrations' in params) { const registrations = ( params as { registrations: Array<{ id: string; method: string; registerOptions?: unknown; }>; } ).registrations; for (const registration of registrations) { if (registration.method === 'textDocument/diagnostic') { logger.debug('Found diagnostic provider in registration request', { registrationId: registration.id, registerOptions: registration.registerOptions, }); const registerOptions = registration.registerOptions as | { documentSelector?: DiagnosticProvider['documentSelector']; interFileDependencies?: boolean; workspaceDiagnostics?: boolean; identifier?: string; } | undefined; const provider: DiagnosticProvider = { id: registerOptions?.identifier || registration.id, ...(registerOptions?.documentSelector && { documentSelector: registerOptions.documentSelector, }), ...(registerOptions?.interFileDependencies !== undefined && { interFileDependencies: registerOptions.interFileDependencies, }), ...(registerOptions?.workspaceDiagnostics !== undefined && { workspaceDiagnostics: registerOptions.workspaceDiagnostics, }), }; diagnosticProviderStore.addProvider(provider); logger.debug('Added diagnostic provider from registration', { providerId: provider.id, method: registration.method, hasDocumentSelector: !!provider.documentSelector, }); } } } } catch (error) { logger.error('Error processing capability registration', { error: error instanceof Error ? error.message : String(error), params: JSON.stringify(params), }); } return {}; // Acknowledge }); // Handle workspace notifications using functional workspace loaders connection.onNotification('workspace/projectInitializationComplete', () => { if (workspaceLoaderStore) { workspaceLoaderStore.updateState( 'workspace/projectInitializationComplete' ); } }); // Handle C# Roslyn toast notifications (silent) connection.onNotification('window/_roslyn_showToast', (params: unknown) => { logger.debug('Received Roslyn toast notification', { params }); if (workspaceLoaderStore) { workspaceLoaderStore.updateState('window/_roslyn_showToast'); } }); // Handle other notifications silently connection.onNotification(() => { // Silent handling of other notifications }); }

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/p1va/symbols-mcp'

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