Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/lsp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,18 @@ func (c *Client) NotifyChange(ctx context.Context, filepath string) error {
return c.Notify(ctx, "textDocument/didChange", params)
}

func (c *Client) NotifySave(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)

params := protocol.DidSaveTextDocumentParams{
TextDocument: protocol.TextDocumentIdentifier{
URI: protocol.DocumentUri(uri),
},
}

return c.Notify(ctx, "textDocument/didSave", params)
}

func (c *Client) CloseFile(ctx context.Context, filepath string) error {
uri := fmt.Sprintf("file://%s", filepath)

Expand Down
8 changes: 8 additions & 0 deletions internal/watcher/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ type LSPClient interface {
// NotifyChange notifies the server of a file change
NotifyChange(ctx context.Context, path string) error

// NotifySave notifies the server that a file was saved (triggers diagnostics re-run)
NotifySave(ctx context.Context, path string) error

// DidChangeWatchedFiles sends watched file events to the server
DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error
}
Expand All @@ -27,6 +30,11 @@ type WatcherConfig struct {
// DebounceTime is the duration to wait before sending file change events
DebounceTime time.Duration

// OpenFilesOnRegistration controls whether all matching workspace files are opened
// via textDocument/didOpen when file watchers are registered. Required for some
// language servers (e.g. typescript-language-server).
OpenFilesOnRegistration bool

// ExcludedDirs are directory names that should be excluded from watching
ExcludedDirs map[string]bool

Expand Down
5 changes: 5 additions & 0 deletions internal/watcher/testing/mock_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ func (m *MockLSPClient) NotifyChange(ctx context.Context, path string) error {
return nil
}

// NotifySave mocks notifying the server that a file was saved
func (m *MockLSPClient) NotifySave(ctx context.Context, path string) error {
return nil
}

// DidChangeWatchedFiles mocks sending watched file events to the server
func (m *MockLSPClient) DidChangeWatchedFiles(ctx context.Context, params protocol.DidChangeWatchedFilesParams) error {
m.mu.Lock()
Expand Down
39 changes: 31 additions & 8 deletions internal/watcher/watcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ func (w *WorkspaceWatcher) AddRegistrations(ctx context.Context, id string, watc
}
}

// Find and open all existing files that match the newly registered patterns
// TODO: not all language servers require this, but typescript does. Make this configurable
// Find and open all existing files that match the newly registered patterns.
// Only enabled for language servers that require it (e.g. typescript-language-server).
if !w.config.OpenFilesOnRegistration {
return
}
go func() {
startTime := time.Now()
filesOpened := 0
Expand Down Expand Up @@ -531,17 +534,37 @@ func (w *WorkspaceWatcher) debounceHandleFileEvent(ctx context.Context, uri stri

// handleFileEvent sends file change notifications
func (w *WorkspaceWatcher) handleFileEvent(ctx context.Context, uri string, changeType protocol.FileChangeType) {
// If the file is open and it's a change event, use didChange notification
filePath := uri[7:] // Remove "file://" prefix
if changeType == protocol.FileChangeType(protocol.Changed) && w.client.IsFileOpen(filePath) {
err := w.client.NotifyChange(ctx, filePath)
if err != nil {
watcherLogger.Error("Error notifying change: %v", err)

// Atomic writes (write-to-temp, rename) appear as Created events, so Changed and
// Created need the same treatment.
if changeType == protocol.FileChangeType(protocol.Changed) ||
changeType == protocol.FileChangeType(protocol.Created) {
if w.client.IsFileOpen(filePath) {
// didChange: delivers the new file content to the server so it can update
// its in-memory document and run incremental analysis (syntax, basic linting).
if err := w.client.NotifyChange(ctx, filePath); err != nil {
watcherLogger.Error("Error notifying change: %v", err)
return
}
// didSave: signals that the content was persisted to disk, triggering
// expensive server-side work such as type checking and full diagnostics.
// An external tool writing a file is act like a save,
// so this should be semantically correct.
if err := w.client.NotifySave(ctx, filePath); err != nil {
watcherLogger.Error("Error notifying save: %v", err)
}
return
}
// File is not open: use workspace/didChangeWatchedFiles so the server
// reloads from disk.
if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
watcherLogger.Error("Error notifying LSP server about file event: %v", err)
}
return
}

// Notify LSP server about the file event using didChangeWatchedFiles
// For delete events, use workspace/didChangeWatchedFiles
if err := w.notifyFileEvent(ctx, uri, changeType); err != nil {
watcherLogger.Error("Error notifying LSP server about file event: %v", err)
}
Expand Down
18 changes: 17 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ func newServer(config *config) (*mcpServer, error) {
}, nil
}

// requiresOpenFilesOnRegistration returns true for language servers that need all
// workspace files opened via didOpen before they can provide correct results.
func requiresOpenFilesOnRegistration(serverName string) bool {
switch serverName {
case "typescript-language-server":
return true
default:
return false
}
}

func (s *mcpServer) initializeLSP() error {
if err := os.Chdir(s.config.workspaceDir); err != nil {
return fmt.Errorf("failed to change to workspace directory: %v", err)
Expand All @@ -90,7 +101,6 @@ func (s *mcpServer) initializeLSP() error {
return fmt.Errorf("failed to create LSP client: %v", err)
}
s.lspClient = client
s.workspaceWatcher = watcher.NewWorkspaceWatcher(client)

initResult, err := client.InitializeLSPClient(s.ctx, s.config.workspaceDir)
if err != nil {
Expand All @@ -99,6 +109,12 @@ func (s *mcpServer) initializeLSP() error {

coreLogger.Debug("Server capabilities: %+v", initResult.Capabilities)

watcherConfig := watcher.DefaultWatcherConfig()
if initResult.ServerInfo != nil {
watcherConfig.OpenFilesOnRegistration = requiresOpenFilesOnRegistration(initResult.ServerInfo.Name)
}
s.workspaceWatcher = watcher.NewWorkspaceWatcherWithConfig(client, watcherConfig)

go s.workspaceWatcher.WatchWorkspace(s.ctx, s.config.workspaceDir)
return client.WaitForServerReady(s.ctx)
}
Expand Down