Repository navigation
Add built-in MCP server (dexter mcp) - #84
Conversation
Expose the index to AI agents over the Model Context Protocol, modeled on gopls mcp. Nine tools, addressed by module/function name rather than file positions because Elixir modules are not tied to files: - dexter_workspace, dexter_search, dexter_definition, dexter_references, dexter_module_api, dexter_file_outline, dexter_implementations, dexter_call_hierarchy, dexter_reindex Transports: stdio (dexter mcp), streamable HTTP (dexter mcp --listen), and attached mode on a running LSP session (dexter lsp --mcp-listen) sharing open buffers and caches. dexter mcp --instructions prints an agent-facing usage guide. Reuses the LSP server internals: reindexing via the extracted Server.ReindexWorkspace (backgroundReindex body, now also callable blocking), reference collection via Server.CollectReferences, and doc extraction via the tokenizer. No index schema or parser changes. Uses the official github.com/modelcontextprotocol/go-sdk.
|
Thank you very much, @shanehull! It's a shame that we need an MCP to get an AI tool to properly use LSPs, but that seems to be the state of harnesses today outside of OpenCode. Will test this out and get back to you. |
|
@shanehull I'm still testing, but my initial concern is that the lookup flow after edits is basically "ask the agent nicely to run Unfortunately, if somebody is using an AI harness with no editor open, we won't get these events. I think in MCP mode we should add |
Headless MCP servers get no editor LSP events, so lookups went stale until an agent chose to call dexter_reindex. Watch the project tree with fsnotify instead: file writes reindex the changed file (debounced), deletes drop entries (including whole directories), and new directories are watched and indexed as they appear. deps/, _build/, node_modules, .git, and .dexter are not watched; deps change only through mix and are covered by the startup reindex. On watcher overflow the workspace is reindexed incrementally; if watching is unavailable the server logs a warning and degrades to branch-switch detection plus dexter_reindex.
|
That makes sense @JesseHerrick . Now that I think of it this was an awkward bit for Claude. It seemed to muddle through after it encountered the need to reindex once, but a "watcher" should avoid it altogether. Added in 012d62a. |
Workspace-wide rename of a module or function with the same on-disk semantics as the editor rename: changes are written to disk, files following the naming convention are moved, and the index is updated. The tool reports every file changed and moved; git provides review and revert. The exported RenameFunction/RenameModule wrappers carry the same validation as the LSP handler and reuse its machinery unchanged, except that edits the LSP would hand to an editor as TextEdits (open buffers in attached mode) are also written to disk, since an MCP caller has no editor to deliver them to.
Three fixes for the MCP integration, none touching LSP behavior: The file watcher now holds the reindex lock while writing to the index. Without it, a file created after a concurrent workspace reindex's walk had passed its directory could be indexed by the watcher and then removed by the reindex's prune, with the create event already consumed, leaving the symbol missing until the file changed again. Open-buffer rename edits from an MCP rename are forwarded to a live LSP client as workspace/applyEdit (attached mode), so the editor applies them and stays in sync, exactly as an editor-initiated rename would. Writing those files behind the editor's back left the buffer stale and a later save would have reverted the rename. Without a client they are written to disk directly; headless servers have no open buffers. RenameFunction and RenameModule wait for the rename's background reindex before returning, so the reported "index is updated" is true when the tool call completes rather than eventually.
workspace/applyEdit responses carry an applied flag; a rename whose open-buffer edits the editor refused was still reported as complete. deliverEdits now surfaces the rejection as an error.
|
@JesseHerrick as discussed offline, I've closed the 2nd PR and unstacked them, everything is now contained in this PR. I've tested each tool end to end and it's ready for your review. |
Renaming a module from the file that defines it moved that file on disk
while the editor still held the buffer, and handed the editor TextEdits
for the path just deleted. Neovim applied them to the stale buffer, so
the next save recreated the old file holding the new module name: two
files defining the same module, and the project then couldn't compile
due to duplicate modules.
Open files are now moved by the client, through a rename resource
operation ordered right after that file's own TextEdits so the edited
buffer travels to the new path; the server touches neither path. Closed
files still move server-side, which is what keeps large renames off the
wire. Clients without resourceOperations rename the module in place and
leave the file where it is, so nothing is deleted under a live buffer.
go.lsp.dev/protocol types documentChanges as []TextDocumentEdit and
cannot carry resource operations, so workspace_edit.go defines the wire
types and renameHandler answers textDocument/rename ahead of the
generated dispatcher.
Two other bugs also fixed:
- `alias Old.{A, B}` names the module once as the prefix while the index
records one reference per member, so a member's full name never
appears on the line and the group kept pointing at the old module.
- Every member on such a line resolves to the same prefix edit. TextEdits
are relative to the original buffer, so emitting it once per member
made the editor apply it repeatedly (Old -> NewNewNew...). Overlapping
edits are now dropped; the on-disk path rewrites the line as it goes
and never sees the second match.
Merges fix/rename-open-file-moves, where an open file a module rename renames is moved by the editor through a rename resource operation rather than by the server behind the editor's back. Resolved against this branch's rename changes: - Serve, now in api.go, wraps the handler with renameHandler. Without it the generated dispatcher answers textDocument/rename with a protocol.WorkspaceEdit, which has nowhere to put a resource operation, and every file move is silently dropped. - renameModuleEdits keeps its moved/files returns and loses the ctx and trigger-path parameters, which existed only for the showDocument dance the fix removes. RenameSummary.FilesMoved now also reports the moves the client performs, since the caller is told what the rename moves, not what dexter moved itself. - renameFunctionEdits keeps its files return with the new edit type. - deliverEdits takes the new edit type and handles documentChanges. A client honouring documentChanges ignores changes entirely, so once a file moves, reading only Changes would deliver nothing. Attached to a live session it forwards the whole edit over the raw connection — protocol.ApplyWorkspaceEditParams drops resource operations for the same reason — and headless it applies the edits and performs the moves on disk itself. Headless MCP has no open buffers, so it never produces a client-side move; that branch is defensive. The attached case is real: an agent renaming a module whose file the user has open in the editor. api_test's fake is now a connection rather than a protocol.Client, so the assertions see the JSON that actually goes over the wire — the only place the resource operations survive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # docs/architecture.md # internal/lsp/server.go # internal/lsp/workspace_edit.go
# Conflicts: # cmd/main.go # internal/lsp/server.go # internal/store/store_test.go
|
Hey @shanehull, I have pushed a few things fixing potential bugs and merge conflicts, but there's one more meatier problem here that I don't have time to deal with right now: LSP root negotiation. The MCP server currently determines its workspace from the process argument/current working directory before the MCP session starts. This can bind it to the wrong repository if you start the AI harness in a different directory from your project. The MCP should obtain the client workspace through the MCP protocol's
This prevents Dexter from creating an index relative to an unrelated launch directory or accidentally selecting a directory above the intended workspace. The standalone MCP server should also handle For MCP attached to Root discovery must happen before indexing, but indexing itself can remain asynchronous if MCP tool calls are gated appropriately or clearly report that the workspace is still initializing. |
|
Makes sense @JesseHerrick, I'll spend some on it over the next few days. One extra bit: --listen can carry sessions from different projects, so I'll keep a workspace per resolved root. Sessions sharing a root share one store/watcher, and a workspace is torn down when its last session leaves. And stdio stays one workspace as you described. |
An orphaned workspace closes in the background, and its close can wait out a running initial index build. Rebinding that root meanwhile opened a second store over the same database while the first was still bulk writing, violating the indexer's single-writer contract. Roots now drain: a rebind waits for the old workspace's close to finish. Resolved roots are also symlink-canonicalized, since path aliases of one directory (such as /tmp vs /private/tmp) would otherwise key two live workspaces onto one database with the same effect.
Negotiated roots resolve symlinks before walking for project markers, but the fallback walked the launch directory's logical path first and canonicalized after, so a marker above a symlink's target was invisible and the two mechanisms could key different workspaces for one directory.
The fallback still walked with the CLI's extra mix.exs marker while negotiated roots use the LSP's markers only, so a no-roots session and a roots session for one directory could key different workspaces in a markerless tree. Explicit paths keep the CLI resolution.
# Conflicts: # internal/lsp/server.go
applyTextEdits sliced lines by Position.Character as a byte offset, but the rename machinery emits columns in the client's encoding, UTF-16 by default since the encoding negotiation landed. A non-ASCII character left of an edited token made the headless rename write the wrong span, and the attached-mode index snapshot diverge from what the editor applied. Columns now convert through the same helpers every inbound position uses, whose clamping also covers out-of-range columns.
# Conflicts: # CHANGELOG.md # internal/store/store.go # internal/store/store_test.go
Index writes from delivered edits now hold the reindex lock so a concurrent workspace reindex's prune cannot drop rows written after its walk. CollectReferences gained the injected-alias collection the References handler acquired upstream, restoring the mirrored-results promise. moveConventionalFiles checks the same-path guard before the deliverAll branch, so a namespace-only rename no longer encodes a path-to-itself move.
WithReindexLock already holds indexWrites for reading; reindexPaths and recordDeliveredEdit retook it on the same goroutine, which deadlocks once a writer is waiting (RWMutex is not reentrant). They now rely on the wrapper's locks, using indexOneFileLocked like other pre-locked callers.
Refs #100 and #84. Issue #100 asks for CLI parity with the LSP and floats a background daemon; this is that daemon, and the CLI now shares one index with the editor. The remaining half of #100 — name-based CLI answers that use the LSP's resolution, not just the store — is the follow-up below. PR #84's MCP server is the other frontend this was built for: after it is rebased, it should attach through `daemon.Ensure` and the control-method registries instead of owning its own store, watchers, and initial index pass (`docs/daemon.md` maps the ten tools onto the recommended daemon seams). Every dexter frontend — the editor, the CLI, and future MCP clients — now attaches to one per-workspace daemon that owns the SQLite index, the file and Git watchers, the mutation queue, and the language caches. The daemon belongs to the workspace rather than to whichever frontend started it; `dexter lsp` is a raw stdio proxy onto it, and `lookup`, `references`, `reindex`, and `stop` are control clients. Each frontend used to open its own store handle, run its own watchers and caches, and index the same tree twice. A shell reindex could race the editor's writer, each editor session duplicated the warm caches, and an editor restart lost them. There is no in-process editor mode any more: one workspace has one owner, so every frontend sees the same index and the same answers. ## Highlights - Ownership is an advisory kernel lock held for the process lifetime, so a crash releases it at once. The socket binds before the first index pass, so opening an editor never waits on a cold build, and the daemon exits after 15 minutes with no clients (`DEXTER_DAEMON_IDLE_TIMEOUT`, `0` keeps it). - The handshake carries a single contract version, bumped together with `IndexVersion` only when an older daemon must not keep serving. A newer frontend replaces the daemon — which exits itself under the contract, or is signaled by the pid in its refusal — and a newer daemon refuses an older frontend with a message to restart it. No client process is ever killed by the daemon, because an editor or agent owns those. - `dexter stop [path] [--force]` ends a workspace daemon by workspace, not by pid. Plain `stop` goes through the protocol and refuses while clients are attached; `--force` skips the handshake, locates the process by command line (uid-filtered), and escalates SIGTERM to SIGKILL after a grace period. - `--root`/`-C` on every command runs it as if started in that directory, so agents, scripts, and editor wrappers can target a project from anywhere. Relative paths, a `reindex` target included, resolve from the named root. - A guard refuses to treat a directory with no `mix.exs`, `.git`, or `.dexter` as a workspace unless `-y/--yes` is passed, so a mistyped command in the home directory cannot build an index over everything. `lsp` warns and serves instead, because an editor is authoritative about what the user opened. ## Resilience - One unwatchable directory no longer disables the whole native watcher; the runtime adds a periodic reconciliation whenever watching is missing or partial, which keeps a daemon-only workspace fresh without an editor. - Runtime files prefer `$XDG_RUNTIME_DIR/dexter`, then `/tmp/dexter-<uid>`, then `$TMPDIR/dexter-<uid>`. `$TMPDIR` is deliberately last: it is per process, so preferring it could split the workspace lock between two frontends. Candidates that are not real directories owned by this user, are symlinks, or make the socket path too long for `sockaddr_un` are skipped. - Elixir and mix detection also searches the standard mise, asdf, and Homebrew locations and falls back to a login shell, so an editor-launched daemon with a stripped PATH no longer disables stdlib indexing or formatting. - Spawned daemons are reaped; a panic in one connection is contained; a daemon whose socket disappears exits so a replacement can start; transient accept errors back off instead of taking the daemon down; CLI control calls are bounded so an unavailable filesystem cannot hang a shell forever. ## Follow-ups - The CLI's name-based `lookup` remains store-level. Editor-grade resolution (BEAM-generated functions, use-chain helpers) is available through the daemon's LSP endpoint; wiring `workspace/lookup` to the shared language services is the next step, alongside the MCP control methods sketched in `docs/daemon.md`. - `Version` and `IndexVersion` are intentionally not bumped here; the release PR bumps them together with `daemon.ContractVersion`. ## Testing `go test ./...`, `go test -race ./...`, `golangci-lint`, and `gofmt` are clean. New coverage includes the daemon transport and registries, ownership and symlink identity, contract-mismatch directions and replacement, forced stop with SIGKILL escalation, the non-project guard, socket-disappearance exit, connection-panic isolation, watch degradation and the periodic fallback, and end-to-end CLI/LSP runs against real daemons.
|
Plan for this branch on top of the workspace daemon (#105) #105 is green and lands first. This branch should be adapted onto it rather than rebased mechanically: the tools and the name-based APIs are the durable part here, and the workspace-ownership code is what #105 centralizes. Per the discussion, Keep
Replace with daemon attachment
Delete
Numbers and notes
Sequencing
|
Refs #100 and #84. Issue #100 asks for CLI parity with the LSP and floats a background daemon; this is that daemon, and the CLI now shares one index with the editor. The remaining half of #100 — name-based CLI answers that use the LSP's resolution, not just the store — is the follow-up below. PR #84's MCP server is the other frontend this was built for: after it is rebased, it should attach through `daemon.Ensure` and the control-method registries instead of owning its own store, watchers, and initial index pass (`docs/daemon.md` maps the ten tools onto the recommended daemon seams). Every dexter frontend — the editor, the CLI, and future MCP clients — now attaches to one per-workspace daemon that owns the SQLite index, the file and Git watchers, the mutation queue, and the language caches. The daemon belongs to the workspace rather than to whichever frontend started it; `dexter lsp` is a raw stdio proxy onto it, and `lookup`, `references`, `reindex`, and `stop` are control clients. Each frontend used to open its own store handle, run its own watchers and caches, and index the same tree twice. A shell reindex could race the editor's writer, each editor session duplicated the warm caches, and an editor restart lost them. There is no in-process editor mode any more: one workspace has one owner, so every frontend sees the same index and the same answers. ## Highlights - Ownership is an advisory kernel lock held for the process lifetime, so a crash releases it at once. The socket binds before the first index pass, so opening an editor never waits on a cold build, and the daemon exits after 15 minutes with no clients (`DEXTER_DAEMON_IDLE_TIMEOUT`, `0` keeps it). - The handshake carries a single contract version, bumped together with `IndexVersion` only when an older daemon must not keep serving. A newer frontend replaces the daemon — which exits itself under the contract, or is signaled by the pid in its refusal — and a newer daemon refuses an older frontend with a message to restart it. No client process is ever killed by the daemon, because an editor or agent owns those. - `dexter stop [path] [--force]` ends a workspace daemon by workspace, not by pid. Plain `stop` goes through the protocol and refuses while clients are attached; `--force` skips the handshake, locates the process by command line (uid-filtered), and escalates SIGTERM to SIGKILL after a grace period. - `--root`/`-C` on every command runs it as if started in that directory, so agents, scripts, and editor wrappers can target a project from anywhere. Relative paths, a `reindex` target included, resolve from the named root. - A guard refuses to treat a directory with no `mix.exs`, `.git`, or `.dexter` as a workspace unless `-y/--yes` is passed, so a mistyped command in the home directory cannot build an index over everything. `lsp` warns and serves instead, because an editor is authoritative about what the user opened. ## Resilience - One unwatchable directory no longer disables the whole native watcher; the runtime adds a periodic reconciliation whenever watching is missing or partial, which keeps a daemon-only workspace fresh without an editor. - Runtime files prefer `$XDG_RUNTIME_DIR/dexter`, then `/tmp/dexter-<uid>`, then `$TMPDIR/dexter-<uid>`. `$TMPDIR` is deliberately last: it is per process, so preferring it could split the workspace lock between two frontends. Candidates that are not real directories owned by this user, are symlinks, or make the socket path too long for `sockaddr_un` are skipped. - Elixir and mix detection also searches the standard mise, asdf, and Homebrew locations and falls back to a login shell, so an editor-launched daemon with a stripped PATH no longer disables stdlib indexing or formatting. - Spawned daemons are reaped; a panic in one connection is contained; a daemon whose socket disappears exits so a replacement can start; transient accept errors back off instead of taking the daemon down; CLI control calls are bounded so an unavailable filesystem cannot hang a shell forever. ## Follow-ups - The CLI's name-based `lookup` remains store-level. Editor-grade resolution (BEAM-generated functions, use-chain helpers) is available through the daemon's LSP endpoint; wiring `workspace/lookup` to the shared language services is the next step, alongside the MCP control methods sketched in `docs/daemon.md`. - `Version` and `IndexVersion` are intentionally not bumped here; the release PR bumps them together with `daemon.ContractVersion`.
Main now has one workspace daemon for every frontend (#105) and one name navigation for the CLI and the editor (#107). This merge makes `dexter mcp` a frontend of that daemon, like `dexter lsp` and the CLI: - The frontend keeps the MCP protocol and the root negotiation. It opens no store and starts no watcher. For each root it holds one control connection to the daemon (daemon.Ensure), and sends each tool call as the new control method "mcp/tool". - The tool bodies run in the daemon, against its store and its headless language service. Definitions and references use LookupName and ReferenceNames, the same navigation as the editor and `dexter lookup`. - A tool call waits for a cold index for a short time, then answers with a note when the index is still building or has a warning or an error condition (#114). The rename tool refuses until the index is complete. - dexter_reindex uses the daemon's reindex barrier. - Negotiated and fallback roots resolve with the CLI's project-root search. A launch directory that is not a project is refused as a fallback. Removed because the daemon now does this work: the MCP file watcher, the per-root store bindings, the git HEAD watch stop, the blocking reindex, attached mode (`dexter lsp --mcp-listen`, which needs an in-process LSP), deliverEdits and its text-edit applier, CollectReferences, and Serve. The rename tools keep the editor rename machinery. RenameFunction and RenameModule run it on the headless language service, report the files changed, moved, and not written, and return when the index shows the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Module rename: when only one member of `alias Prefix.{A, B}` moves to
another namespace, the member leaves the group and gets its own alias
line. Before, the shared prefix was rewritten, so the other members
named modules that do not exist.
- Module rename: a group whose members continue on the next lines is
rewritten on its opening line when the prefix module is renamed. Before,
the opening line had no member, so the prefix stayed.
- MCP roots: on Windows, a file URI keeps the drive letter after a leading
slash (file:///C:/project). The slash is removed, so the root is an
absolute path.
- MCP roots: the fallback root and client roots go through one function
(mcpConfig) with the CLI's project-root search.
Tests: regression tests for each item, an MCP rename with non-ASCII text
left of the name, dexter_references through an alias that __using__
injects, and `dexter mcp --listen` over HTTP.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A client can list a stale or deleted directory, or a file, before the project root. Root negotiation stopped at the first unusable file:// root, so the session failed although a later root was a real workspace. The first usable root now wins; when no file:// root is usable, the first error is reported, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Renames: a workspace-wide renameSerial mutex on IndexCoordinator keeps editor and MCP renames one at a time, from the first read of the affected files to the end of the writes. Two parallel renames no longer write over each other's edits. File reads: dexter_file_outline accepts only a path inside the project root after symlinks are resolved, and only a regular file of at most 10 MB, so /dev/zero, a FIFO, or a file outside the project is refused. Tools that read file text use the newest unsaved buffer of an attached editor when it differs from the disk, map index lines into it, and say so in the answer. dexter_definition shows at most 20 clauses and reads and tokenizes each file once per call. Cancel: the control protocol gets $/cancel and a context per request, so a canceled call frees its slot and its index wait ends. The MCP frontend runs at most 32 calls per workspace connection, below the daemon's 64. A canceled rename, or a call that a roots change ended, says what happened. ContractVersion is 4, so an older daemon is replaced instead of used with a protocol it does not know. HTTP: --listen accepts only a loopback address unless --listen-unsafe is given; cross-origin requests are refused, bodies are capped at 4 MB, and an idle session closes after 30 minutes. Roots: a client root that is not a project, or is the home directory, is refused like the launch directory, and a non-project root is not warmed. Docs: recovery hints say dexter stop --force; the changelog, the agent instructions, the README, and docs/daemon.md match the code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A call canceled while it waited for the index no longer runs, and the rename tool checks the context once more before its first write, so a rename does not start after the client was told it may have been applied. A rename now waits, under renameSerial, until the index shows the last rename, and checks again that the new function name is free. Two renames to one new name could both pass the check that ran before the lock and both write. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The wait for the previous rename's index update now ends with the request context, and a rename that gets renameSerial after its context ended returns without a write. RenameFunctionContext and RenameModuleContext carry the context; the MCP rename tool uses them, and the editor rename passes its request context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dies A buffer that an editor holds open replaced the disk whenever the two differed, so a clean buffer that the editor had not reloaded yet hid the agent's own edits and was reported as unsaved work. The document store now marks a buffer dirty on didChange, with the time of the change, and clean on didOpen and didSave. MCP tools read a buffer only when it is dirty and the file on disk did not change after it. When both changed, the disk is read and the answer warns that the editor's unsaved changes may conflict. Index lines are mapped into a buffer with a bounded Myers line diff that ignores line endings, so only changed lines are marked and unchanged lines map exactly. A 10,000-line file costs about 0.3 ms. An HTTP body over 4 MB now gets 413 instead of 400. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 36e9fe1. Configure here.

What
A built-in MCP server, modeled on gopls mcp, so AI agents can navigate Elixir codebases through dexter's index instead of grep:
Ten tools, deliberately coarse and agent-oriented rather than 1:1 LSP methods, and addressed by module/function name rather than file+position (Elixir modules are not tied to files, which makes name-based addressing the natural fit for agents):
dexter_workspacedexter_searchdexter_definition@doc/@specand source snippet; follows defdelegate chains; at most 20 clauses showndexter_referencesdexter_module_apidexter_file_outlinedexter_implementationsdexter_call_hierarchydexter_reindexdexter_rename_symbolTransports: stdio (
dexter mcp) and streamable HTTP (dexter mcp --listen ADDR).dexter mcp --instructionsprints an agent-facing guide covering Elixir-specific behavior (modules vs files, defdelegate following, use-chain injection, behaviours vs protocols).Uses the official
github.com/modelcontextprotocol/go-sdk(v1.6.1, stable), the same SDK gopls uses. Tool input schemas are inferred from Go param structs.Why a built-in MCP server rather than an LSP bridge?
Agent frontends can already drive
dexter lspthrough a generic LSP bridge (Claude Code's LSP tool, for example), so the real question is what built-in tools add over bridging.A bridge inherits LSP's request shapes. Apart from workspace symbol search, every operation is position-based: the agent must find the file, locate the exact line and column, make the call, then open each returned location. Every step is a round trip, and a wrong position silently returns nothing. A bridge also cannot expose anything the protocol does not define.
Built-in tools have neither limit:
dexter_definition {module: MyApp.Accounts, function: fetch_user}answers directly.dexter_module_apisummarizes a whole module in one call; references include source lines, so no second pass.Editors keep the LSP; both share the same index.
How it works: a frontend of the workspace daemon
dexter mcpis a frontend of the shared workspace daemon (#105), likedexter lspand the CLI.daemon.Ensure), which also keeps the daemon alive while the session is open. Freshness comes from the daemon's file watcher and git HEAD poll. When the daemon goes away, the frontend reconnects once per call; a rename is never sent twice.mcp/tool, with the tool name and its arguments. The daemon runs the tool against its store and its headless language service. One method (instead of one per tool) keeps each tool's parameter type in one place, shared by the input schema and the body.$/cancelto the daemon, which cancels that request's context and frees its slot. A frontend runs at most 32 calls at a time per workspace, so one agent cannot fill the daemon's request limit that editors share. The daemonContractVersionis now 4: a newer frontend replaces an older daemon, and an older frontend gets a clear upgrade error from a newer daemon.dexter_definitionanddexter_referencesuseLookupNameandReferenceNames, the same navigation as go-to-definition, find-references, anddexter lookup(use chains, injected aliases, generated functions and their declaring lines).--listenaccepts only loopback addresses unless--listen-unsafeis given (which logs a warning). The handler refuses cross-origin requests and keeps the SDK's localhost Host check. Request bodies are limited to 4 MB (413), and an idle HTTP session ends after 30 minutes.renameSerial), so two parallel renames cannot lose each other's edits. A canceled rename stops if it has not started to write; one canceled during its writes completes, and the agent is told to checkgit status. The MCP rename does not yet apply the shared rule for buffers that an editor has open (never write over unsaved editor work); the README asks users to save before an agent renames, and the rename tool adopts that rule when it lands ininternal/lsp.dexter stop --force, which works while frontends are attached.Review guide
internal/mcp/(new): one file per tool, gopls-style;frontend.go/daemon.go(the frontend and its daemon connection),roots.go(root negotiation),serve.go(stdio and HTTP),source.go/linemap.go(disk or unsaved-buffer text and line mapping).internal/lsp/api.go(new): name-basedRenameFunction/RenameModule(and…Contextvariants) on the editor rename machinery, reporting files changed, moved and not written; file-text and unsaved-buffer accessors.internal/daemon: themcp/toolcontrol method,$/cancelwith per-request contexts,ContractVersion4.internal/lsp: a dirty flag on documents (didChangesets it,didOpen/didSaveclear it),renameSerial, grouped-alias fixes in module rename.cmd/main.go: themcpcommand (--listen,--listen-unsafe,--root,--instructions).internal/store: two additive read-only queries (Stats,ListModuleCallbacks) andNonProjectRootError, shared by MCP and the CLI.No index schema or parser changes, so
IndexVersionis unchanged.Testing
@speclines, module file moves, failed renames leave disk untouched, and two parallel renames keep both edits.dexter mcp(stdio and--listen) against the real daemon, check that the CLI and MCP share one daemon, and see an agent-created file through the daemon's watcher.go test ./...,-race, andgolangci-lintare green on macOS and Linux.