Skip to content

Document tracker never re-reads files after first didOpen — every external edit yields stale results #102

Description

@flukejones

Summary

mcpls' DocumentTracker.ensure_open (crates/mcpls-core/src/bridge/state.rs:168-171) reads a file from disk exactly once per session per path, sends a single textDocument/didOpen, and returns the cached Uri on every subsequent call without re-statting or re-reading. There is no textDocument/didChange path anywhere in the codebase, no filesystem watcher, and no handling of workspace/didChangeWatchedFiles. As a result, any modification to a tracked file made outside mcpls — including by the MCP host's own editing tools — is invisible to both mcpls and the LSP server until the MCP server is restarted.

The original symptoms I reported (git stash / git checkout causing stale answers) are one trigger. The same bug fires through everyday flows: the MCP host writes to disk via its own Edit/Write tools, formatters run on save, code generators emit files, etc. None of these go through mcpls, so none of them refresh the cached DocumentState.

get_cached_diagnostics (push-based, fed by the LSP's publishDiagnostics) is not the broken path — it correctly reflects whatever the LSP last published. The broken paths are everything that goes through ensure_open: get_hover, get_definition, get_references, get_document_symbols, get_diagnostics (pull), get_completions, get_code_actions, format_document, rename_symbol, prepare_call_hierarchy. Plus workspace_symbol_search, which is served from rust-analyzer's workspace index and goes stale because rust-analyzer is never told the file changed.

Verified repro (no editor needed)

Inside the mcpls workspace, with mcpls 0.3.6 + rust-analyzer running:

  1. Write V1 content to <repo>/probe.rs:
    pub fn version_marker() -> &'static str { "v1" }
    pub struct StaleProbeMarkerV1 { pub label: i32 }
    pub fn broken_v1() { let _x: i32 = "this won't compile in v1"; }
  2. Call mcp__mcpls__get_document_symbols("/.../probe.rs"). Returns version_marker, StaleProbeMarkerV1, broken_v1 at V1 line numbers (1, 6, 10 zero-indexed).
  3. From a shell (or any non-mcpls writer), overwrite the file with V2 content — different symbols, different line numbers, no compile error:
    // 9 lines of leading comments to shift line numbers
    pub fn version_marker() -> &'static str { "v2" }
    pub struct StaleProbeMarkerV2 { pub label: i32 }
  4. Verify on disk: head probe.rs shows V2 content, version_marker at line 10, no StaleProbeMarkerV1, no broken_v1.
  5. Call mcp__mcpls__get_document_symbols again on the same path.

Expected: V2 symbols at V2 line numbers.
Actual: Identical response to step 2 — StaleProbeMarkerV1 and broken_v1 reported as present, at V1 line numbers, even though neither symbol exists on disk anymore.

The git stash / git checkout path in the original report is a special case of this. Any external write reproduces it.

Root cause (line-level)

crates/mcpls-core/src/bridge/state.rs:168-171:

pub async fn ensure_open(&mut self, path: &Path, lsp_client: &LspClient) -> Result<Uri> {
    if let Some(state) = self.documents.get(path) {
        return Ok(state.uri.clone());        // <-- short-circuit, no freshness check
    }
    let content = tokio::fs::read_to_string(path).await?;   // first time only
    // ...sends a single didOpen, never sends didChange
}

Every translator handler that touches a file (handle_hover, handle_definition, handle_diagnostics, handle_workspace_symbol, etc., all the ensure_open callsites in bridge/translator.rs) hits this short-circuit on the second and later calls. There is no update() callsite outside of tests; the DocumentState.content field is written but never compared against disk; no textDocument/didChange is ever sent; no filesystem watcher exists.

Affected tools (per-file path — broken via ensure_open)

get_hover, get_definition, get_references, get_document_symbols, get_diagnostics, get_completions, get_code_actions, format_document, rename_symbol, prepare_call_hierarchy, get_incoming_calls, get_outgoing_calls.

Affected tools (workspace-wide — broken because LSP server is never notified)

workspace_symbol_search (rust-analyzer's workspace index lags behind disk). get_cached_diagnostics is not affected.

Fix options

These are not mutually exclusive. Doing all three is the right end state.

A. Stat-on-access + close/reopen on change (mcpls-side, ~50 LOC)

In ensure_open: stat the file on every call. If the cached DocumentState's mtime (or content hash) matches, fast-path. Otherwise read fresh content, send textDocument/didClose + textDocument/didOpen with a bumped version, replace the cached state. Add a small in-memory debounce (~250ms cached stat) so back-to-back calls don't pound the FS.

Fixes every per-file tool. Does not fix workspace_symbol_search for files mcpls has never opened.

Tradeoffs: minimal blast radius, no new deps, ships today. The close+reopen approach is more robust than synthesizing a didChange with a full-text replacement and avoids subtle version-tracking issues.

B. reload_workspace MCP tool (~30 LOC)

Manual escape hatch: a tool that calls rust-analyzer/reloadWorkspace (RA-specific) and/or closes+reopens every tracked document. Lets the user recover without restarting the MCP host session. Useful regardless of A or C.

C. workspace/didChangeWatchedFiles + filesystem watcher (long-term)

The only option that keeps the LSP's workspace index live without explicit user action. Requires:

  • Transport-level fix: lsp/transport.rs:107-115 currently classifies any message with an id as a JsonRpcResponse, which would mishandle server-to-client requests. Add a Request variant to InboundMessage and dispatch on id+method vs id only.
  • Client dispatcher: handle inbound client/registerCapability and client/unregisterCapability requests (return null result), maintain a registration store.
  • Capabilities: declare workspace.did_change_watched_files.dynamic_registration: true (and relative_pattern_support: true for rust-analyzer).
  • Watcher: add notify-debouncer-full, build a watcher per server using the registration's globs + workspace roots, translate notify::EventKind → LSP FileChangeType, batch into workspace/didChangeWatchedFiles notifications. Filter .git/ / target/ / node_modules/ cheaply before glob matching.
  • Internal sync: when the watcher fires for a tracked path, also tear down or refresh the cached DocumentState so option A's invariant still holds.

Suggested order: A + B first, document the limit, then C.

Documentation gap

docs/user-guide/troubleshooting.md, docs/user-guide/configuration.md, and docs/user-guide/tools-reference.md say nothing about external file changes, file watching, or document-cache invalidation. Once a fix lands (or as an interim, until C is done), an "External file changes" subsection in troubleshooting.md would help users understand which tools are affected and how to recover.

Environment

  • mcpls: 0.3.6
  • rust-analyzer: 1.95.0 (59807616 2026-04-14)
  • macOS: 26.5 (build 25F5042g)
  • MCP host: Claude Code

Metadata

Metadata

Assignees

Labels

P2Medium: suboptimal behavior, minor inconsistencybugSomething isn't workingmcpls-coremcpls-core crate changes

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions