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:
- 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"; }
- Call
mcp__mcpls__get_document_symbols("/.../probe.rs"). Returns version_marker, StaleProbeMarkerV1, broken_v1 at V1 line numbers (1, 6, 10 zero-indexed).
- 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 }
- Verify on disk:
head probe.rs shows V2 content, version_marker at line 10, no StaleProbeMarkerV1, no broken_v1.
- 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
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 singletextDocument/didOpen, and returns the cachedUrion every subsequent call without re-statting or re-reading. There is notextDocument/didChangepath anywhere in the codebase, no filesystem watcher, and no handling ofworkspace/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 checkoutcausing stale answers) are one trigger. The same bug fires through everyday flows: the MCP host writes to disk via its ownEdit/Writetools, formatters run on save, code generators emit files, etc. None of these go through mcpls, so none of them refresh the cachedDocumentState.get_cached_diagnostics(push-based, fed by the LSP'spublishDiagnostics) is not the broken path — it correctly reflects whatever the LSP last published. The broken paths are everything that goes throughensure_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. Plusworkspace_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:
<repo>/probe.rs:mcp__mcpls__get_document_symbols("/.../probe.rs"). Returnsversion_marker,StaleProbeMarkerV1,broken_v1at V1 line numbers (1, 6, 10 zero-indexed).head probe.rsshows V2 content,version_markerat line 10, noStaleProbeMarkerV1, nobroken_v1.mcp__mcpls__get_document_symbolsagain on the same path.Expected: V2 symbols at V2 line numbers.
Actual: Identical response to step 2 —
StaleProbeMarkerV1andbroken_v1reported as present, at V1 line numbers, even though neither symbol exists on disk anymore.The
git stash/git checkoutpath 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:Every translator handler that touches a file (
handle_hover,handle_definition,handle_diagnostics,handle_workspace_symbol, etc., all theensure_opencallsites inbridge/translator.rs) hits this short-circuit on the second and later calls. There is noupdate()callsite outside of tests; theDocumentState.contentfield is written but never compared against disk; notextDocument/didChangeis 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_diagnosticsis 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 cachedDocumentState's mtime (or content hash) matches, fast-path. Otherwise read fresh content, sendtextDocument/didClose+textDocument/didOpenwith 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_searchfor files mcpls has never opened.Tradeoffs: minimal blast radius, no new deps, ships today. The close+reopen approach is more robust than synthesizing a
didChangewith a full-text replacement and avoids subtle version-tracking issues.B.
reload_workspaceMCP 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:
lsp/transport.rs:107-115currently classifies any message with anidas aJsonRpcResponse, which would mishandle server-to-client requests. Add aRequestvariant toInboundMessageand dispatch onid+methodvsidonly.client/registerCapabilityandclient/unregisterCapabilityrequests (returnnullresult), maintain a registration store.workspace.did_change_watched_files.dynamic_registration: true(andrelative_pattern_support: truefor rust-analyzer).notify-debouncer-full, build a watcher per server using the registration's globs + workspace roots, translatenotify::EventKind→ LSPFileChangeType, batch intoworkspace/didChangeWatchedFilesnotifications. Filter.git//target//node_modules/cheaply before glob matching.DocumentStateso 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, anddocs/user-guide/tools-reference.mdsay 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 introubleshooting.mdwould help users understand which tools are affected and how to recover.Environment
0.3.61.95.0 (59807616 2026-04-14)26.5(build25F5042g)