MCP Language Server
by isaacphi
- internal
- lsp
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]
}