Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b887331
Add built-in MCP server (dexter mcp)
shanehull Jul 14, 2026
012d62a
Watch files in MCP mode (fsnotify)
shanehull Sep 1, 2026
811a3f1
Add dexter_rename_symbol MCP tool
shanehull Sep 1, 2026
84b9383
Serialize watcher writes and fix MCP rename delivery
shanehull Sep 1, 2026
b5a28a6
Report editor-rejected rename edits as errors
shanehull Sep 1, 2026
ea514c8
Let the editor move files a module rename renames
JesseHerrick Sep 5, 2026
b72578b
Merge rename file-move fix and adapt MCP rename delivery
JesseHerrick Sep 5, 2026
0a3638e
Merge remote-tracking branch 'origin/main' into feat/mcp-server
JesseHerrick Sep 6, 2026
a786974
Merge remote-tracking branch 'origin/main' into pr-84
JesseHerrick Sep 7, 2026
6cd8f4c
Document attached MCP watcher and atomic renames
JesseHerrick Sep 7, 2026
91f63af
Fix attached MCP capability handling
JesseHerrick Sep 7, 2026
2536cb7
Initialize LSP in attached MCP integration test
JesseHerrick Sep 7, 2026
a5c2214
Stabilize sequential module rename test
JesseHerrick Sep 7, 2026
70d832e
Negotiate MCP workspace roots per session
shanehull Sep 8, 2026
0db125d
Merge remote-tracking branch 'origin/main' into feat/mcp-server
shanehull Sep 8, 2026
36790b2
Log MCP workspace bind and teardown
shanehull Sep 9, 2026
676539d
Check store close error in negotiation test
shanehull Sep 9, 2026
4c0ae5c
Address review findings on root negotiation
shanehull Sep 9, 2026
f9f5b2c
Serialize same-root workspace turnover and canonicalize roots
shanehull Sep 9, 2026
6e03346
Resolve the fallback root's symlinks before the marker walk
shanehull Sep 9, 2026
c4faac7
Resolve the fallback root exactly like a negotiated root
shanehull Sep 9, 2026
d604d4e
Merge remote-tracking branch 'origin/main' into feat/mcp-server
shanehull Sep 10, 2026
5967ffa
Decode text edit columns from the client encoding before slicing
shanehull Sep 11, 2026
7ef6e2a
Merge remote-tracking branch 'origin/main' into feat/mcp-server
shanehull Sep 21, 2026
59a1aa7
Address review findings on rename delivery and reference parity
shanehull Sep 21, 2026
bb4852f
Do not retake the index write lock inside WithReindexLock
shanehull Sep 21, 2026
f80ded0
Merge remote-tracking branch 'origin/main' into feat/mcp-server
JesseHerrick Oct 4, 2026
dde480e
Fix review findings on grouped aliases, root URIs, and MCP roots
JesseHerrick Oct 4, 2026
02dc12c
Skip an unusable MCP root when a later root is usable
JesseHerrick Oct 4, 2026
ec2b016
Fix MCP review findings: rename races, file reads, cancel, HTTP
JesseHerrick Oct 4, 2026
5856a02
Stop canceled renames and same-name rename races
JesseHerrick Oct 4, 2026
b8067ee
Let a canceled rename stop while it waits for the rename lock
JesseHerrick Oct 4, 2026
36e9fe1
Read only dirty editor buffers; map lines with a diff; 413 for big bo…
JesseHerrick Oct 4, 2026
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Added

- **Built-in MCP server** — `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Ten tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, reindexing, and workspace-wide rename with the same on-disk semantics as the editor rename. Like `dexter lsp` and the CLI, `dexter mcp` is a frontend of the workspace daemon: it opens no index and starts no watcher of its own, and every tool runs in the daemon against the same index, watchers, and caches as the editor, so an agent and an editor never index the same tree twice. Definitions and references use the same name navigation as go-to-definition, find-references, and `dexter lookup`. A tool call waits for a cold index for a short time, then answers with a note that the index is still building; a rebuilding or degraded index is stated in the answer, and a rename is refused until the index is complete. Each session's workspace root comes from MCP roots and resolves like the CLI's (with the launch directory as the fallback; a root that is not a project, or is the home directory, is refused); an explicit path argument (`dexter mcp <path>`) or `--root` fixes it. Tools read the newest unsaved editor buffer of a file when an attached editor holds one, and say so. `--listen` serves only a loopback address unless `--listen-unsafe` is given. `dexter mcp --instructions` prints an agent-facing usage guide

- **Go-to-definition reaches the line that declared a generated function** — a function a macro generated used to resolve to the top of its module. Dexter now reads the line from the compiled module's debug info, which is standard compiler output, so no framework is special-cased. A generator that expands each function at the line of the call that declared it, or stamps it with `@file {file, line}`, sends definition, call hierarchy, the references declaration, and `dexter lookup` (including `--strict`) to that line. The line is used only when it was compiled from the file being opened; a BEAM older than the source still gives its line, because Dexter cannot compile the project and the last compile's line is closer than the module line; only edits to the declaring file move it, and the next compile makes it exact. A function whose only recorded line is the module line, such as one a `@before_compile` hook made, goes to the call in its module that declares it by name. When several calls spell the name, as an Ash action and the code interface that runs it do, the macro whose calls name the most of the module's generated functions wins, and a tie keeps the module line. A function with a clause per DSL call goes to every clause. A line past the end of the file is never returned. A module compiled without debug info falls back to its Docs chunk annotation, which is often, but not always, the same line. A generated module with no source of its own, such as one `Module.create` made or a Spark DSL entity, goes to the file it was compiled from, rebased onto the project when it was built elsewhere, and so does go-to-definition on its name. A module a macro made with `defmodule` and a name it computed records no line of its own, so its name goes to its first function's line. A bare call to a generated function of an imported module now resolves. Ash code interfaces go to their `define` line with released Ash, and from the recorded line with an Ash release that includes [ash-project/ash#2971](https://github.com/ash-project/ash/pull/2971) ([#108](https://github.com/remoteoss/dexter/issues/108))

- **Failures and degraded states are shown in the editor** — before, almost every problem went only to the log, so the editor showed a language server that did nothing. Now each condition that stops Dexter from working, or makes it work with less, is a `window/showMessage` in every attached editor: an index written by a newer or older Dexter build, a damaged index that is being rebuilt, an index that another process holds locked or that cannot be opened for another cause, an index that cannot be used, a fast build that fell back to the slow path, files that could not be indexed (one aggregate message), a root that is the home directory or not a project, file watching that is unavailable or does not cover some directories, FSEvents falling back to fsnotify, a workspace with no Elixir standard library, a session with no `mix` (told to that editor only), a Mix project whose formatter cannot run (each project on its own), an Elixir/OTP mismatch that stops the fast formatter (told once; Dexter then formats through `mix format` and does not start the failing BEAM again on each save), and a rename that could not change some files. Each message says what happened, what Dexter does about it, and what to do. Conditions that stop say so, and an editor that attaches later receives the conditions that are still active. Cold builds, rebuilds, and large incremental passes show LSP work-done progress where the editor supports it. `lookup`, `references`, and `reindex` state a rebuilding or unusable index on stderr, and `workspace/status` lists every active condition
Expand All @@ -18,7 +20,7 @@

- **`dexter stop`** — stops a workspace daemon on demand instead of hunting for its pid. It finds the process by workspace, reports whether one was running, and is a no-op when nothing was. A plain stop refuses while editors or CLI clients are attached, so a shared workspace is not yanked out from under them; `--force` skips the handshake and locates the daemon process directly, escalating to a kill if it will not exit, which is the manual way out for a daemon that is stuck or built by an older version

- **One workspace daemon, shared by every frontend** — the editor and the CLI now attach to a single per-workspace process that owns the index, the file and Git watchers, and the language caches, instead of each keeping its own copy of all three and indexing the same tree twice. It is also the foundation an MCP frontend will attach to, so MCP can drop its own store, watchers, and caches rather than index the same tree a second time. The daemon belongs to the workspace rather than to whichever frontend started it, and it starts on demand: it listens on a socket under `/tmp/dexter-<uid>` and exits after 15 minutes with no clients; `DEXTER_DAEMON_IDLE_TIMEOUT` changes that (`0` keeps it forever). Ownership is an advisory kernel lock held for the process lifetime, so a crash or `kill -9` releases it at once — there is no stale lock to clear and no PID file to go wrong. The socket binds before the first index pass, so opening an editor never waits on a cold build, and a CLI call reuses whatever caches the editor already warmed. The handshake carries a contract version: when an upgrade leaves a daemon from an older build owning the workspace, the first current-build frontend replaces it automatically — the old daemon shuts itself down when it understands the contract, or is signaled by the pid its refusal carries when it predates it — and a frontend older than the daemon is refused with a message telling its user to restart it. `IndexVersion` remains the store's own rebuild trigger, bumped together with the contract. See `docs/daemon.md`
- **One workspace daemon, shared by every frontend** — the editor and the CLI now attach to a single per-workspace process that owns the index, the file and Git watchers, and the language caches, instead of each keeping its own copy of all three and indexing the same tree twice. The daemon belongs to the workspace rather than to whichever frontend started it, and it starts on demand: it listens on a socket under `/tmp/dexter-<uid>` and exits after 15 minutes with no clients; `DEXTER_DAEMON_IDLE_TIMEOUT` changes that (`0` keeps it forever). Ownership is an advisory kernel lock held for the process lifetime, so a crash or `kill -9` releases it at once — there is no stale lock to clear and no PID file to go wrong. The socket binds before the first index pass, so opening an editor never waits on a cold build, and a CLI call reuses whatever caches the editor already warmed. The handshake carries a contract version: when an upgrade leaves a daemon from an older build owning the workspace, the first current-build frontend replaces it automatically — the old daemon shuts itself down when it understands the contract, or is signaled by the pid its refusal carries when it predates it — and a frontend older than the daemon is refused with a message telling its user to restart it. `IndexVersion` remains the store's own rebuild trigger, bumped together with the contract. See `docs/daemon.md`

- **Completion and hover for macro-generated functions** — Dexter now reads compiled BEAM exports and documentation to surface public functions and macros that do not exist in source. This includes generated functions and macros in application modules, entirely generated application modules such as Phoenix route helpers (including `alias ..., as: Routes`), Oban constructors hidden from generated documentation, exported introspection APIs such as `__schema__`, and generated dependency DSLs. Source indexing remains authoritative and compilation remains optional: stale BEAMs can contribute genuinely generated names, while an absent BEAM leaves the existing source-only behavior unchanged. Generated Spark/Ash DSL macros are resolved from persisted extension attributes and narrowed to the modules in scope at the cursor's nested block path: section macros at module level, an entity's macro inside its section body, and an entity's own option macros inside its body — including when a language form such as `for` or `if` sits in the block path. Hover on any of them renders the compiled signature. Both the OTP 24–27 and OTP 28+ atom-table layouts are supported. In a monorepo whose libraries are compiled as path dependencies of another Mix project, Dexter finds the workspace's builds and looks for each library's application in them, so a library without a `_build` of its own, and modules generated beneath its modules, still resolve; when a library is compiled in more than one build, the most recently compiled BEAM is used

Expand All @@ -32,6 +34,8 @@

### Fixed

- **Grouped aliases in a module rename** — moving one member of `alias Old.{A, B}` to another namespace rewrote the shared prefix, so the other members named modules that do not exist; the moved member now leaves the group and gets its own `alias`. A group whose members continue on the next lines (`alias Old.{` on its own line) kept the old prefix when the prefix module was renamed; it is now rewritten

- **A worktree moved into the project, or a directory that cannot be read, is no longer indexed by mistake** — `git worktree move` renames the directory and then writes its `.git` file again in place, so a watcher or a walk could find the file empty and index the whole worktree. Such a directory is now treated as a worktree until git is done, by the watchers and by both index walks, and each check reads the `.git` file once: two reads could see the empty file and then the complete one, and answer that a worktree was neither a worktree nor still being written. This happened with git 2.48 and later, which write the file again after the move; older git does not. A directory that the watcher cannot read (for example when the process has no file descriptor left) used to be skipped silently: it was not watched, not retried, and its files were reported as if it were a plain directory. It is now marked as not covered, so the coverage report says so and the retry reads it again, and its files are indexed only once Dexter knows what it is

- **A def inside a macro's `quote` is no longer indexed as a function of the macro's module** — `defmacro route(...) do quote do def handle(...) end end` made the index say that the DSL module defines `handle/2`, which it does not. A consumer that imports the DSL then resolved `Consumer.handle` into the macro's body. Such a def is now skipped, so the call goes to the line in the consumer that declared it, from the compiled BEAM. What `__using__` injects, and a quote in a helper function, are still indexed as before. The index is rebuilt once after the upgrade
Expand Down
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ A fast, full-featured Elixir LSP optimized for large Elixir codebases.
- [Look up definitions](#look-up-definitions)
- [Find references](#find-references)
- [Reindexing files manually](#reindexing-files-manually)
- [MCP server](#mcp-server)
- [Hover documentation](#hover-documentation)
- [Cursor-position-aware resolution](#cursor-position-aware-resolution)
- [Rename](#rename)
Expand Down Expand Up @@ -476,6 +477,31 @@ When running as an LSP server, dexter automatically:
- Runs an incremental reindex on startup
- Watches `.git/HEAD` for branch switches and reindexes when detected

## MCP server

Dexter includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) server, modeled on `gopls mcp`, so AI agents can navigate Elixir codebases through the index instead of grep. Tools cover symbol search, definitions with docs and specs, references, module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename.

Register it with your MCP client. For Claude Code:

```sh
claude mcp add dexter -- dexter mcp
```

Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server obtains its workspace from the client through MCP roots and resolves it the way the CLI does, so it binds the project the client is working in rather than the directory it was launched from. Clients that provide no roots get the launch directory (when it is a project), and an explicit path argument (`dexter mcp <path>`) overrides negotiation entirely. One session serves one workspace: when a client gives several roots, the session uses the first one that is a usable project (an existing directory inside an Elixir project, not the home directory) and ignores the others. In `--listen` mode each resolved root gets its own workspace, so sessions from different projects can share one server.

`dexter mcp` is a frontend of the workspace daemon, like `dexter lsp` and the CLI: it starts the daemon when necessary and keeps no index of its own. The tools answer from the same index, watchers, and caches as the editor, so edits made directly by an agent are indexed by the daemon's file watcher, and a `dexter_reindex` tool forces an immediate update if a lookup ever seems stale. A tool that answers from an index that is still building or degraded says so in its answer. The tools read file text the way the user sees it: when an editor attached to the same daemon holds a file open with changes it has not saved (and the file on disk has not changed since), outlines, definition snippets, and reference lines come from that buffer (with its line numbers), and the answer says so. The rename tool writes on disk and does not yet look at editor buffers, so save your editor's changes before an agent renames.

Useful variants:

```sh
# Serve over streamable HTTP instead of stdio (loopback addresses only; the
# server has no authentication, so another address needs --listen-unsafe)
dexter mcp --listen localhost:8092

# Print the agent-facing usage guide (save as context for clients that want it)
dexter mcp --instructions
```

## Hover documentation

Dexter serves hover docs (`textDocument/hover`) for functions, modules, and types. When you hover over a symbol, it looks up the definition in the index and reads the `@doc`, `@moduledoc`, `@typedoc`, or `@spec` annotations from the source file.
Expand Down Expand Up @@ -599,7 +625,7 @@ dexter init .
One background process per project owns the index, so your editor and the CLI
see the same fresh index instead of each maintaining their own.

The first `dexter lsp`, `dexter lookup`, `dexter references`, or `dexter reindex`
The first `dexter lsp`, `dexter mcp`, `dexter lookup`, `dexter references`, or `dexter reindex`
starts it, and it exits on its own after 15 minutes with no clients. Nothing
about editor configuration changes: `dexter lsp` still speaks LSP over stdio, it
just proxies to the daemon, and the protocol bytes are copied rather than
Expand All @@ -608,8 +634,8 @@ re-parsed.
The daemon belongs to the workspace, not to whichever frontend started it. No
editor owns the index for another: a second editor and a lookup in a shell both
attach to the same daemon and share its index and resolution caches, so neither
can see a different, staler answer. It is also the process an MCP frontend will
attach to, so every frontend answers from one index.
can see a different, staler answer. `dexter mcp` attaches to it too, so every
frontend answers from one index.

The daemon owns the SQLite index in `.dexter/` (one writer), the file watchers and
`.git/HEAD` poll that drive incremental reindexes, stdlib and dependency
Expand Down Expand Up @@ -660,7 +686,7 @@ dexter init --force ~/code/my-elixir-project
If the issue persists, enable debug mode to get verbose logs. You can do this in two ways:

1. Set the `debug` option in your editor's LSP `initializationOptions` (see [LSP options](#lsp-options)). It applies to that editor session as soon as it connects.
2. Or set the `DEXTER_DEBUG=true` environment variable for the editor or CLI command that starts the workspace daemon. The daemon reads it when it starts, so if one is already running, run `dexter stop` first. This is also how to debug CLI commands such as `dexter lookup`.
2. Or set the `DEXTER_DEBUG=true` environment variable for the editor or CLI command that starts the workspace daemon. The daemon reads it when it starts, so if one is already running, run `dexter stop --force` first (a plain stop is refused while an editor or an MCP session is attached). This is also how to debug CLI commands such as `dexter lookup`.

Debug mode logs timing and resolution details for every definition, hover, references, and rename request. Each editor receives the lines for its own requests in its LSP log (in Neovim usually `~/.local/state/nvim/lsp.log`, in VS Code Output > Dexter). Every editor and CLI command for a workspace shares one daemon, and all of its lines, including those for CLI commands, also go to the daemon's log file: `<key>.log` in its runtime directory (`/tmp/dexter-<uid>` on macOS and Linux; see [docs/daemon.md](docs/daemon.md)). The first line `dexter lsp` writes to your editor's log names that file.

Expand Down
Loading
Loading