MCP Language Server

package lsp import ( "bufio" "context" "encoding/json" "fmt" "io" "os" "os/exec" "sync" "sync/atomic" "time" "github.com/isaacphi/mcp-language-server/internal/protocol" ) type Client struct { Cmd *exec.Cmd stdin io.WriteCloser stdout *bufio.Reader stderr io.ReadCloser // Request ID counter nextID atomic.Int32 // Response handlers handlers map[int32]chan *Message handlersMu sync.RWMutex // Server request handlers serverRequestHandlers map[string]ServerRequestHandler serverHandlersMu sync.RWMutex // Notification handlers notificationHandlers map[string]NotificationHandler notificationMu sync.RWMutex // Diagnostic cache diagnostics map[protocol.DocumentUri][]protocol.Diagnostic diagnosticsMu sync.RWMutex // Files are currently opened by the LSP openFiles map[string]*OpenFileInfo openFilesMu sync.RWMutex } func NewClient(command string, args ...string) (*Client, error) { cmd := exec.Command(command, args...) // Copy env cmd.Env = os.Environ() stdin, err := cmd.StdinPipe() if err != nil { return nil, fmt.Errorf("failed to create stdin pipe: %w", err) } stdout, err := cmd.StdoutPipe() if err != nil { return nil, fmt.Errorf("failed to create stdout pipe: %w", err) } stderr, err := cmd.StderrPipe() if err != nil { return nil, fmt.Errorf("failed to create stderr pipe: %w", err) } client := &Client{ Cmd: cmd, stdin: stdin, stdout: bufio.NewReader(stdout), stderr: stderr, handlers: make(map[int32]chan *Message), notificationHandlers: make(map[string]NotificationHandler), serverRequestHandlers: make(map[string]ServerRequestHandler), diagnostics: make(map[protocol.DocumentUri][]protocol.Diagnostic), openFiles: make(map[string]*OpenFileInfo), } // Start the LSP server process if err := cmd.Start(); err != nil { return nil, fmt.Errorf("failed to start LSP server: %w", err) } // Handle stderr in a separate goroutine go func() { scanner := bufio.NewScanner(stderr) for scanner.Scan() { fmt.Fprintf(os.Stderr, "LSP Server: %s\n", scanner.Text()) } if err := scanner.Err(); err != nil { fmt.Fprintf(os.Stderr, "Error reading stderr: %v\n", err) } }() // Start message handling loop go client.handleMessages() return client, nil } func (c *Client) RegisterNotificationHandler(method string, handler NotificationHandler) { c.notificationMu.Lock() defer c.notificationMu.Unlock() c.notificationHandlers[method] = handler } func (c *Client) RegisterServerRequestHandler(method string, handler ServerRequestHandler) { c.serverHandlersMu.Lock() defer c.serverHandlersMu.Unlock() c.serverRequestHandlers[method] = handler } func (c *Client) InitializeLSPClient(ctx context.Context, workspaceDir string) (*protocol.InitializeResult, error) { initParams := &protocol.InitializeParams{ WorkspaceFoldersInitializeParams: protocol.WorkspaceFoldersInitializeParams{ WorkspaceFolders: []protocol.WorkspaceFolder{ { URI: protocol.URI("file://" + workspaceDir), Name: workspaceDir, }, }, }, XInitializeParams: protocol.XInitializeParams{ ProcessID: int32(os.Getpid()), ClientInfo: &protocol.ClientInfo{ Name: "mcp-language-server", Version: "0.1.0", }, RootURI: protocol.DocumentUri("file://" + workspaceDir), Capabilities: protocol.ClientCapabilities{ Workspace: protocol.WorkspaceClientCapabilities{ Configuration: true, DidChangeConfiguration: protocol.DidChangeConfigurationClientCapabilities{ DynamicRegistration: true, }, DidChangeWatchedFiles: protocol.DidChangeWatchedFilesClientCapabilities{ DynamicRegistration: true, }, }, TextDocument: protocol.TextDocumentClientCapabilities{ Synchronization: &protocol.TextDocumentSyncClientCapabilities{ DynamicRegistration: true, DidSave: true, }, Completion: protocol.CompletionClientCapabilities{ CompletionItem: protocol.ClientCompletionItemOptions{}, }, CodeLens: &protocol.CodeLensClientCapabilities{ DynamicRegistration: true, }, DocumentSymbol: protocol.DocumentSymbolClientCapabilities{}, CodeAction: protocol.CodeActionClientCapabilities{ CodeActionLiteralSupport: protocol.ClientCodeActionLiteralOptions{ CodeActionKind: protocol.ClientCodeActionKindOptions{ ValueSet: []protocol.CodeActionKind{}, }, }, }, PublishDiagnostics: protocol.PublishDiagnosticsClientCapabilities{ VersionSupport: true, }, SemanticTokens: protocol.SemanticTokensClientCapabilities{ Requests: protocol.ClientSemanticTokensRequestOptions{ Range: &protocol.Or_ClientSemanticTokensRequestOptions_range{}, Full: &protocol.Or_ClientSemanticTokensRequestOptions_full{}, }, TokenTypes: []string{}, TokenModifiers: []string{}, Formats: []protocol.TokenFormat{}, }, }, Window: protocol.WindowClientCapabilities{}, }, InitializationOptions: map[string]interface{}{ "codelenses": map[string]bool{ "generate": true, "regenerate_cgo": true, "test": true, "tidy": true, "upgrade_dependency": true, "vendor": true, "vulncheck": false, }, }, }, } var result protocol.InitializeResult if err := c.Call(ctx, "initialize", initParams, &result); err != nil { return nil, fmt.Errorf("initialize failed: %w", err) } if err := c.Notify(ctx, "initialized", struct{}{}); err != nil { return nil, fmt.Errorf("initialized notification failed: %w", err) } // Register handlers c.RegisterServerRequestHandler("workspace/applyEdit", HandleApplyEdit) c.RegisterServerRequestHandler("workspace/configuration", HandleWorkspaceConfiguration) c.RegisterServerRequestHandler("client/registerCapability", HandleRegisterCapability) c.RegisterNotificationHandler("window/showMessage", HandleServerMessage) c.RegisterNotificationHandler("textDocument/publishDiagnostics", func(params json.RawMessage) { HandleDiagnostics(c, params) }) return &result, nil } func (c *Client) Close() error { // Close stdin first to signal the server if err := c.stdin.Close(); err != nil { return fmt.Errorf("failed to close stdin: %w", err) } // Use a channel to handle the Wait with timeout done := make(chan error, 1) go func() { done <- c.Cmd.Wait() }() // Wait for process to exit with timeout select { case err := <-done: return err case <-time.After(2 * time.Second): // If we timeout, try to kill the process if err := c.Cmd.Process.Kill(); err != nil { return fmt.Errorf("failed to kill process: %w", err) } return fmt.Errorf("process killed after timeout") } } type ServerState int const ( StateStarting ServerState = iota StateReady StateError ) func (c *Client) WaitForServerReady(ctx context.Context) error { // TODO: wait for specific messages or poll workspace/symbol time.Sleep(time.Second * 1) return nil } type OpenFileInfo struct { Version int32 URI protocol.DocumentUri } func (c *Client) OpenFile(ctx context.Context, filepath string) error { uri := fmt.Sprintf("file://%s", filepath) c.openFilesMu.Lock() if _, exists := c.openFiles[uri]; exists { c.openFilesMu.Unlock() return nil // Already open } c.openFilesMu.Unlock() content, err := os.ReadFile(filepath) if err != nil { return fmt.Errorf("error reading file: %w", err) } params := protocol.DidOpenTextDocumentParams{ TextDocument: protocol.TextDocumentItem{ URI: protocol.DocumentUri(uri), LanguageID: DetectLanguageID(uri), Version: 1, Text: string(content), }, } if err := c.Notify(ctx, "textDocument/didOpen", params); err != nil { return err } c.openFilesMu.Lock() c.openFiles[uri] = &OpenFileInfo{ Version: 1, URI: protocol.DocumentUri(uri), } c.openFilesMu.Unlock() return nil } func (c *Client) NotifyChange(ctx context.Context, filepath string) error { uri := fmt.Sprintf("file://%s", filepath) content, err := os.ReadFile(filepath) if err != nil { return fmt.Errorf("error reading file: %w", err) } c.openFilesMu.Lock() fileInfo, isOpen := c.openFiles[uri] if !isOpen { c.openFilesMu.Unlock() return fmt.Errorf("cannot notify change for unopened file: %s", filepath) } // Increment version fileInfo.Version++ version := fileInfo.Version c.openFilesMu.Unlock() params := protocol.DidChangeTextDocumentParams{ TextDocument: protocol.VersionedTextDocumentIdentifier{ TextDocumentIdentifier: protocol.TextDocumentIdentifier{ URI: protocol.DocumentUri(uri), }, Version: version, }, ContentChanges: []protocol.TextDocumentContentChangeEvent{ { Value: protocol.TextDocumentContentChangeWholeDocument{ Text: string(content), }, }, }, } return c.Notify(ctx, "textDocument/didChange", params) } func (c *Client) CloseFile(ctx context.Context, filepath string) error { uri := fmt.Sprintf("file://%s", filepath) c.openFilesMu.Lock() if _, exists := c.openFiles[uri]; !exists { c.openFilesMu.Unlock() return nil // Already closed } c.openFilesMu.Unlock() params := protocol.DidCloseTextDocumentParams{ TextDocument: protocol.TextDocumentIdentifier{ URI: protocol.DocumentUri(uri), }, } if err := c.Notify(ctx, "textDocument/didClose", params); err != nil { return err } c.openFilesMu.Lock() delete(c.openFiles, uri) c.openFilesMu.Unlock() return nil } func (c *Client) IsFileOpen(filepath string) bool { uri := fmt.Sprintf("file://%s", filepath) c.openFilesMu.RLock() defer c.openFilesMu.RUnlock() _, exists := c.openFiles[uri] return exists } func (c *Client) GetFileDiagnostics(uri protocol.DocumentUri) []protocol.Diagnostic { c.diagnosticsMu.RLock() defer c.diagnosticsMu.RUnlock() return c.diagnostics[uri] }