Skip to content

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

Open
shanehull wants to merge 8 commits into
mainfrom
feat/mcp-server
Open

Add built-in MCP server (dexter mcp)#84
shanehull wants to merge 8 commits into
mainfrom
feat/mcp-server

Conversation

@shanehull

@shanehull shanehull commented Aug 20, 2026

Copy link
Copy Markdown

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 stats, stdlib status
dexter_search fuzzy workspace symbol search
dexter_definition definition with @doc/@spec and source snippet; follows defdelegate chains
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, staleness-immune)
dexter_implementations behaviour implementors and protocol defimpls
dexter_call_hierarchy incoming/outgoing calls
dexter_reindex forces an incremental reindex (the index also updates automatically via the file watcher)
dexter_rename_symbol workspace-wide rename of a module or function, with the same on-disk semantics as the editor rename: writes changes, moves convention-following files, updates the index, reports every file touched

Transports: stdio (dexter mcp), streamable HTTP (--listen), and attached mode on a running LSP (dexter lsp --mcp-listen=ADDR) sharing the live session's open buffers and caches. dexter mcp --instructions prints an agent-facing guide covering Elixir-specific behavior (modules vs files, defdelegate following, use-chain injection, behaviours vs protocols).

The headless server watches the project tree (fsnotify) so the index stays fresh without editor events; dexter_reindex remains as a manual force.

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.

Review guide

Everything is new leaf code except small, mechanical touches to existing files:

  • internal/mcp/ (new): one file per tool, gopls-style. Tools call the store's existing name-based queries and the exported LSP surface below.
  • internal/lsp/api.go (new): Serve (now takes a constructed *Server, so the same instance can back both LSP and MCP), CollectReferences (the References handler's collection logic, name-based), RenameFunction/RenameModule (the LSP rename machinery behind name-based entry points with the same validation; the only machinery change is that the two rename functions now also report which files they touched), and stdlib accessors.
  • cmd/main.go: adds the mcp command and --mcp-listen; extracts cmdLSP's open-with-recovery loop into openStoreForServer so both servers share it. init/reindex/lookup/references are untouched.
  • internal/lsp/server.go: backgroundReindex's body became reindexWorkspace with a blocking exported ReindexWorkspace (git diff -w shows the move; the body is unchanged), Serve moved to api.go with the *Server parameter, and readFileText/getFileLine/watchGitHead are exported by rename.
  • internal/store: two additive read-only queries (Stats, ListModuleCallbacks).

No index schema or parser changes, so IndexVersion stays at 12.

Testing

  • Unit tests per tool run a full in-memory MCP round trip through the SDK (schema inference and argument validation included), not just handler bodies. Rename tests assert the on-disk results: definitions, callers, @spec lines, module file moves, and that failed renames leave disk untouched.
  • Integration tests spawn the real binary: stdio handshake and tool calls, empty-index startup, and attached mode over HTTP while the LSP runs on stdio.
  • go test ./..., -race, and golangci-lint all green.
  • Manually exercised against a large codebase (400k+ definitions, 3M+ indexed references): startup incremental reindex 1s when fresh, definition 1ms, references 366ms, call hierarchy 29ms.

Note

Medium Risk
dexter_rename_symbol and attached-mode workspace/applyEdit can modify many project files and move paths on disk; behavior is heavily tested but agents can trigger broad edits without a human in the loop.

Overview
Adds a built-in Model Context Protocol server (dexter mcp) so agents can query the Dexter index by module/function name instead of file positions. Ten tools cover workspace overview, fuzzy search, definitions (with docs/specs and defdelegate following), references, module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, forced reindex, and workspace-wide rename with the same semantics as the editor.

CLI and transports: new mcp subcommand (stdio by default, --listen for streamable HTTP, --instructions for the agent guide). dexter lsp --mcp-listen=ADDR serves MCP from the live LSP process (shared buffers/caches). Headless mode runs startup/incremental reindex, git HEAD watching, and an fsnotify tree watcher (debounced, reindex-lock aware) because there are no editor save events.

LSP surface for MCP: new internal/lsp/api.go exports Serve on a constructed *Server, blocking ReindexWorkspace, CollectReferences, RenameFunction/RenameModule, and deliverEdits (disk in headless mode; raw workspace/applyEdit when attached so file renames survive). Rename helpers now return changed/moved file lists. Shared store open/recovery is factored into openStoreForServer.

Dependencies and store: modelcontextprotocol/go-sdk and fsnotify; additive Stats and ListModuleCallbacks queries. Docs, architecture notes, unit tests (in-memory MCP round-trips), and integration tests (stdio MCP, empty-index bootstrap, LSP+MCP HTTP) accompany the new internal/mcp/ package.

Reviewed by Cursor Bugbot for commit 0a3638e. Bugbot is set up for automated code reviews on this repo. Configure here.

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

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 0a3638e. Configure here.

Comment thread cmd/main.go
}()
}

serveErr := dexter_lsp.Serve(server, os.Stdin, os.Stdout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Attached MCP starts before LSP is ready

Medium Severity

The MCP HTTP listener starts before Serve assigns server.conn. deliverEdits treats a nil conn as headless and writes or moves editor-open files on disk, which is the failure the open-file rename path was written to avoid. conn is also written and read from different goroutines without synchronization.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a3638e. Configure here.

Comment thread cmd/main.go
log.Printf("MCP server error: %v", err)
}
}()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Attached mode never watches project files

Medium Severity

WatchFiles is started only for headless dexter mcp. Attached dexter lsp --mcp-listen relies on editor LSP events, but agents usually write files themselves. Server instructions still claim the tree is watched, so lookups stay stale after agent edits unless someone calls dexter_reindex.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a3638e. Configure here.

Comment thread internal/lsp/api.go
// The machinery reindexes what it wrote in the background; callers are
// promised an up-to-date index.
s.backgroundWork.Wait()
return RenameSummary{FilesChanged: files}, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Failed rename still writes closed files

Medium Severity

RenameFunction and RenameModule write closed files inside the edit builders, then call deliverEdits. If the editor rejects workspace/applyEdit, the call returns an error but those closed-file writes have already landed, leaving a half-renamed workspace.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0a3638e. Configure here.

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.

2 participants