Skip to content

Add built-in MCP server (dexter mcp) - #84

Merged
JesseHerrick merged 33 commits into
mainfrom
feat/mcp-server
Oct 4, 2026
Merged

JesseHerrick merged 33 commits into
mainfrom
feat/mcp-server

Conversation

@shanehull

@shanehull shanehull commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

What

A built-in MCP server, modeled on gopls mcp, so AI agents can navigate Elixir codebases through dexter's index instead of grep:

claude mcp add dexter -- dexter mcp

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):

Tool What it does
dexter_workspace project layout, index state and active conditions, stdlib status
dexter_search fuzzy workspace symbol search
dexter_definition definition with @doc/@spec and source snippet; follows defdelegate chains; at most 20 clauses shown
dexter_references references including use-chain injected call sites
dexter_module_api moduledoc, public functions with signatures and doc summaries, delegates, types, callbacks, submodules
dexter_file_outline modules/functions a file defines (fresh parse); only regular files inside the project, up to 10 MB
dexter_implementations behaviour implementors and protocol defimpls
dexter_call_hierarchy incoming/outgoing calls
dexter_reindex forces a reindex through the daemon (the index also updates automatically via the daemon's file watcher)
dexter_rename_symbol workspace-wide rename of a module or function with the editor rename machinery: writes changes, moves convention-following files, reports every file touched, returns when the index shows the rename

Transports: stdio (dexter mcp) and streamable HTTP (dexter mcp --listen ADDR). dexter mcp --instructions prints 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 lsp through 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:

  • Name-based: dexter_definition {module: MyApp.Accounts, function: fetch_user} answers directly.
  • Coarse: dexter_module_api summarizes a whole module in one call; references include source lines, so no second pass.
  • Beyond LSP's surface: workspace overview and explicit reindexing have no LSP method to bridge.
  • Elixir-aware: server instructions cover defdelegate, use-chain injection, and modules vs files.
  • Client-agnostic: one-line registration in anything that speaks MCP; bridges exist only in some clients.

Editors keep the LSP; both share the same index.

How it works: a frontend of the workspace daemon

dexter mcp is a frontend of the shared workspace daemon (#105), like dexter lsp and the CLI.

  • No workspace state in the MCP process. It opens no index and starts no watcher. For each workspace root it holds one control connection to the daemon (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.
  • Tool bodies run in the daemon. Each tool call goes to the daemon as one control method, 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.
  • Cancellation. A canceled tool call sends $/cancel to 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 daemon ContractVersion is now 4: a newer frontend replaces an older daemon, and an older frontend gets a clear upgrade error from a newer daemon.
  • Shared navigation. dexter_definition and dexter_references use LookupName and ReferenceNames, the same navigation as go-to-definition, find-references, and dexter lookup (use chains, injected aliases, generated functions and their declaring lines).
  • Index state in the answers (Show failures and degraded states in the editor #114). A tool call waits up to 30 s for a cold index (bounded by the call's context), then answers from what is indexed. An answer from an index that is still building, or that has an active warning or error condition, ends with a note. The rename tool refuses until the index is complete.
  • Unsaved editor buffers. Tools that show source text use an editor's buffer for a file only when that buffer has unsaved edits and the file on disk is not newer than those edits. Otherwise they read the disk; when both changed, the answer says that an editor also has unsaved changes. Index lines are mapped into the buffer with a bounded line diff, and the answer names the files that came from unsaved buffers.
  • Roots. Client roots and the fallback root resolve with the CLI's project-root search, so every frontend reaches the same daemon. A root that is not a project (for example a home directory) is refused, for client roots and for the launch directory. When a daemon already serves the directory through another spelling, the frontend uses the daemon's root. A session serves the first usable root.
  • HTTP mode. --listen accepts only loopback addresses unless --listen-unsafe is 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.
  • Rename. Renames from every frontend run one at a time (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 check git 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 in internal/lsp.
  • Recovery hints say 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-based RenameFunction/RenameModule (and …Context variants) on the editor rename machinery, reporting files changed, moved and not written; file-text and unsaved-buffer accessors.
  • internal/daemon: the mcp/tool control method, $/cancel with per-request contexts, ContractVersion 4.
  • internal/lsp: a dirty flag on documents (didChange sets it, didOpen/didSave clear it), renameSerial, grouped-alias fixes in module rename.
  • cmd/main.go: the mcp command (--listen, --listen-unsafe, --root, --instructions).
  • internal/store: two additive read-only queries (Stats, ListModuleCallbacks) and NonProjectRootError, shared by MCP and the CLI.

No index schema or parser changes, so IndexVersion is unchanged.

Testing

  • Unit tests per tool run a full in-memory MCP round trip through the SDK over a real workspace runtime (schema inference and argument validation included). Rename tests assert the on-disk results: definitions, callers, @spec lines, module file moves, failed renames leave disk untouched, and two parallel renames keep both edits.
  • Tests for root negotiation with fake daemon connections, cancellation (canceled calls free their slots; the per-frontend limit), path refusals (outside the root, FIFO, over 10 MB), HTTP limits (non-loopback refused, cross-origin refused, 413, idle session timeout), the contract bump, unsaved-buffer selection (clean stale buffer, dirty buffer, dirty buffer with a newer disk, save), and the line diff against random edits.
  • Integration tests start 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, and golangci-lint are green on macOS and Linux.

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.
@JesseHerrick

Copy link
Copy Markdown
Member

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.

@JesseHerrick

Copy link
Copy Markdown
Member

@shanehull I'm still testing, but my initial concern is that the lookup flow after edits is basically "ask the agent nicely to run dexter_reindex", which I don't love when we could be deterministic about it. We've taken a few different approaches to watching files since Dexter was released. Initially we were doing a full walk, watch, plus polling, but I was able to simplify this quite a bit to instead doing a reindex on startup and then watching for editor LSP events of file changes, letting the editor do the hard work for us.

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 fsnotify file watching so that we can have guarantees that the MCP isn't pulling stale data. What do you think?

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.
@shanehull

Copy link
Copy Markdown
Contributor Author

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.
Comment thread internal/mcp/watch.go Outdated
Comment thread internal/lsp/api.go
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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/mcp/watch.go Outdated
Comment thread internal/lsp/api.go Outdated
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.
@shanehull

Copy link
Copy Markdown
Contributor Author

@JesseHerrick as discussed offline, dexter_rename_symbol now handles the edits.

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.

JesseHerrick and others added 2 commits September 5, 2026 18:33
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/server.go
Comment thread internal/lsp/rename.go
# Conflicts:
#	CHANGELOG.md
#	docs/architecture.md
#	internal/lsp/server.go
#	internal/lsp/workspace_edit.go

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread cmd/main.go Outdated
Comment thread cmd/main.go
Comment thread internal/lsp/api.go Outdated
# Conflicts:
#	cmd/main.go
#	internal/lsp/server.go
#	internal/store/store_test.go

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/server.go
Comment thread internal/mcp/implementations.go
Comment thread internal/mcp/file_outline.go

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/server.go Outdated
@JesseHerrick

Copy link
Copy Markdown
Member

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 roots/list capability and use the same root-resolution strategy as the LSP (ideally the same code paths if possible):

  1. Start from the MCP-provided workspace root.
  2. Look upward for an existing .dexter/dexter.db first, so an existing index is reused.
  3. Otherwise use the repository marker (.git) as the project root.
  4. Only then open/create the store and begin indexing.

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 notifications/roots/list_changed by replacing its workspace session: stop the old watcher, close its store, resolve the new root, open the appropriate index, and start the new watcher/index pass. An explicit CLI path can remain an override for clients that do not advertise roots.

For MCP attached to dexter lsp --mcp-listen, the LSP’s resolved project root should remain authoritative because that process owns the editor buffers and other workspace state. The attached MCP endpoint should not independently retarget it.

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.

@shanehull

Copy link
Copy Markdown
Contributor Author

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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/mcp/roots.go Outdated
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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread cmd/main.go
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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/api.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

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/server.go Outdated
Comment thread internal/lsp/api.go
Comment thread internal/lsp/api.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.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/api.go Outdated
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.
JesseHerrick added a commit that referenced this pull request Sep 22, 2026
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.
@JesseHerrick

Copy link
Copy Markdown
Member

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, --mcp-listen attached mode is dropped.

Keep

  • internal/mcp/ and the ten tools. They are already name-based, which is the daemon's vocabulary.
  • Tool schemas, the instructions text, and MCP roots negotiation.
  • The name-based pieces from internal/lsp/api.go: CollectReferences, RenameFunction/RenameModule, the stdlib accessors, and readFileText/getFileLine. The CLI-parity half of CLI Parity with LSP #100 needs exactly these, so they should land here and then back workspace/lookup/workspace/references too.
  • internal/store's ListModuleCallbacks. (Stats already landed in One workspace daemon shared by every frontend #105.)

Replace with daemon attachment

  • dexter mcp (stdio and --listen) becomes a normal frontend: daemon.Ensure(ctx, root) for each negotiated root, one control connection per root, workspace/status with waitReadyMs as the cold-start gate, client.Watch if the tools want change notifications, and client.Close on shutdown. Open control connections are leases, so the daemon stays up while MCP runs.
  • Follow the shape recommended in docs/daemon.md: keep the MCP protocol server in the frontend process and add one control method per tool backend (workspace/definition, workspace/references, workspace/moduleAPI, workspace/outline, workspace/search, workspace/implementations, workspace/callHierarchy, workspace/rename, workspace/reindex, workspace/status), registered with daemon.RegisterMethod, each a thin wrapper over the name-based LSP calls against mc.LSP().

Delete

  • Store ownership, the fsnotify tree, the initial index pass, and the private index barrier. Freshness comes from the daemon, so there is no MCP-side watcher even in headless mode.
  • Attached mode: --mcp-listen on dexter lsp, the session-id plumbing that fed it, and the HTTP listener inside the LSP process. dexter mcp --listen stays in the MCP command. The cost is that MCP answers from disk state, not an editor's unsaved buffers; adding that back later is additive, because the handshake already returns a session id and mc.LSP() already resolves one.
  • openStoreForServer and the cmdLSP extraction: the proxy no longer opens a store at all.
  • watchGitHead usage and Server.SetStdlibRoot. The runtime owns the Git poll, and ServerOptions.InitialStdlibRoot / Initialize replace the latter.
  • The duplicate ReindexWorkspace; Server.ReindexWorkspace is already exported in One workspace daemon shared by every frontend #105.

Numbers and notes

  • IndexVersion is 14 on main after One workspace daemon shared by every frontend #105, and there are no schema or parser changes here.
  • lsp.Serve/ServeServer no longer exist; daemon sessions use lsp.ServeStream(server, stream), which is the "constructed server" entry api.go was reaching for.
  • The attached-mode integration test goes away with the feature; the in-memory SDK round-trip tests stay, and the new control methods should be covered alongside internal/daemon/registry_test.go and internal/daemon/server_test.go.

Sequencing

  1. Rebase onto main once One workspace daemon shared by every frontend #105 merges, or onto workspace-daemon now to preview.
  2. Land the name-based LSP APIs and the control methods, switching the tools over in the same PR.
  3. Delete the ownership and attached code, and update the docs that describe MCP startup.

JesseHerrick added a commit that referenced this pull request Sep 22, 2026
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`.
JesseHerrick and others added 2 commits October 3, 2026 21:55
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/mcp/roots.go
JesseHerrick and others added 2 commits October 3, 2026 22:08
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/mcp/tools.go
Comment thread internal/lsp/api.go
Comment thread internal/lsp/api.go
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>

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread internal/lsp/server.go
JesseHerrick and others added 2 commits October 3, 2026 23:47
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread internal/mcp/source.go
@JesseHerrick
JesseHerrick merged commit 662b9c9 into main Oct 4, 2026
5 checks passed
@JesseHerrick
JesseHerrick deleted the feat/mcp-server branch October 4, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants