lds — local-develop-server
Unified MCP server for AI-driven coding agents. Bundles git read/write,
just-based recipe execution, and file-sandbox operations into one process
backed by a shared Session state — so an agent can open a repository once
with session_start and then run git, recipe, and sandbox tools against the
same project root without re-establishing context per tool call.
Architecture
Claude Code / Agent
│
│ stdio (MCP JSON-RPC)
▼
┌─────────────────────────────────┐
│ LdsServer │
│ Arc<RwLock<Inner>> │
│ #[tool_router] + stdio │
└──────────┬──────────────────────┘
│
┌─────┼──────────┐
▼ ▼ ▼
GitModule RecipeModule SandboxModule
git2-rs just CLI fs + snapshot
│ │ │
└─────────┴─────────────┘
│
▼
Session (core)
root / session_id / timeout / max_output / global_recipe_dirs
Session — Smart Inject Env
session_start(root) injects the project root into every module in one call.
Each module reads root / timeout / max_output from the shared Session.
The git module additionally tracks write scope (owned_worktrees) internally,
separately from Session.
Auto session-start: when the server is launched inside a ProjectRoot
(a directory containing .git or justfile), the first tool call
automatically starts a session using the startup CWD — session_start is
optional in that case. It remains available for switching to a different
project root explicitly. Auto-started calls include an auto_session_start
field in their response.
No-session error: when a tool is called without an active session, every
handler returns JSON-RPC error code -32603 with the message "no session".
Session root gone error: when the session root is removed after
session_start (e.g. a worktree was deleted while the session was still
active), recipe-family tools (recipe_run / recipe_list /
recipe_list_plugins) return
"session root path no longer exists, please call session_start again: <path>".
Re-invoking session_start with a valid root recovers the state.
Resolve Chain (recipe)
Dotenv-style hierarchical resolution. Justfiles are scanned in the priority order below (low → high) and merged per recipe; later sources win on name collision (Project has highest priority).
| Priority | Source | Notes |
|---|---|---|
| lowest | ~/.config/lds/justfile (default global) |
always scanned |
| ↑ | config.toml recipes.dirs |
additional directories declared in ~/.config/lds/config.toml |
| ↑ | LDS_RECIPE_GLOBAL_DIRS env var |
colon-separated dirs; legacy / CI |
| highest | Project ({root}/justfile) |
project justfile at the session root |
Each recipe carries ResolveInfo { level, source_path } so its source layer
is traceable. Adding a new layer (e.g. Worktree) only requires extending the
ResolveLevel enum with a new variant.
Output Safety
- timeout:
tokio::time::timeoutis applied to recipe / sandbox execution (default 60s) - truncation: when stdout / stderr exceeds
max_output(default 100KB) it is truncated to a head + tail pair, respecting UTF-8 character boundaries
Crate Structure
crates/
├── core/ lds-core Session, SessionConfig, LdsState, truncate_output
├── git/ lds-git GitModule (git2-rs, write scope tracking)
├── gh/ lds-gh GhModule (gh CLI subprocess wrapper, read-only API, auth fail-fast)
├── recipe/ lds-recipe RecipeModule (just CLI, resolve chain, content args)
├── sandbox/ lds-sandbox SandboxModule (file-scoped read/append, snapshot/rollback)
├── journal/ lds-journal JournalModule (journal-mcp-rmcp SDK consumer, `journal_*` tools forwarded)
├── outline/ lds-outline OutlineModule (outline-mcp-rmcp SDK consumer, prefixed `outline_*` tools)
├── pack/ lds-pack Whole-project archives (`pack_*` MCP tools + `lds pack` CLI)
└── lds/ lds MCP binary (rmcp v1.7, stdio transport)
Tools
Session
| Tool | Description |
|---|---|
session_start |
Initialize session with project root. Optional when the server was launched inside a ProjectRoot (directory containing .git or justfile) — the first tool call auto-starts the session using the startup CWD. Call session_start explicitly to use a different root. |
Git (read)
| Tool | Description |
|---|---|
git_status |
Working tree status |
git_log |
Commit log (configurable max_count) |
git_diff |
Diff working tree vs HEAD |
Gh (read)
GitHub CLI (gh) wrapper. Requires gh auth login before use; every tool
invocation checks gh auth status and returns a typed error if unauthenticated.
| Tool | Description |
|---|---|
gh_auth_status |
Check gh CLI authentication status |
gh_pr_list |
List PRs as JSON (number/title/state/author). limit optional (default 30). |
gh_pr_view |
View a single PR as JSON. Requires number. |
gh_pr_diff |
Show diff of a PR. Requires number. |
gh_issue_list |
List issues as JSON (number/title/state). limit optional (default 30). |
gh_issue_view |
View a single issue as JSON. Requires number. |
gh_repo_view |
Repository metadata as JSON (name/owner/defaultBranchRef). |
gh_run_list |
List Actions workflow runs as JSON. limit optional (default 30). |
gh_run_view |
View a single workflow run as JSON (status/conclusion/jobs/...). Requires run_id. |
gh_run_log_failed |
View failed-step logs of a workflow run, parsed into {failed_steps: [{job_name, step_name, log_tail}]}. |
gh_run_jobs |
List jobs of a workflow run as JSON. Requires run_id. |
gh_release_view |
View a single release (tag, assets, body) as JSON. Requires tag. |
gh_release_list |
List releases as JSON. limit optional (default 30). |
gh_workflow_list |
List workflows as JSON (name/state/id). |
gh_workflow_view |
View a single workflow (path/state/latest_run) as JSON. Requires name_or_id. |
gh_pr_checks |
List CI check results of a PR as JSON. Requires number. |
Write operations not exposed: gh pr create / gh issue create /
gh release create / gh pr merge are deliberately not exposed as MCP tools.
gh run cancel (write op) and gh run watch (long-polling, incompatible with
MCP request-response semantics — use gh_run_view + polling instead) are also
absent. gh search repos is out of scope for this release.
Users invoke write operations via shell directly.
Git (write)
Session-scoped write operations: worktree_add registers the created worktree in
the session's owned_worktrees set, and subsequent write tools (commit,
merge, worktree_remove, branch_delete) refuse to operate on paths /
branches that are not session-owned. This prevents one agent from destroying
another's work.
| Tool | Description |
|---|---|
git_commit |
Stage and commit changes in a session-owned working directory |
git_worktree_add |
Create a worktree on a new branch (session-owned). Placed under SessionConfig::worktrees_dir → env LDS_WORKTREES_DIR → <root>/.worktrees (default; parent repo must gitignore this path) |
git_worktree_remove |
Remove a session-owned worktree |
git_worktree_list |
List worktrees with session-ownership annotation |
git_merge |
Merge a branch into another in a session-owned working directory |
git_branch_delete |
Delete a session-owned branch |
Recipe
| Tool | Description |
|---|---|
recipe_list |
List allow-agent recipes (with ResolveInfo source tracking) |
recipe_run |
Run recipe with args + content env vars, timeout + truncation |
Outline
Forwarded from the upstream outline-mcp-rmcp server. Each tool is exposed
under the outline_ prefix (e.g. mcp__lds__outline_shelf). Shelf root
defaults to $HOME/.config/outline-mcp/books, overridable via
LDS_OUTLINE_SHELF_DIR. See the upstream outline-mcp README
for full tool semantics; the surface below lists the wire names.
| Tool | Description |
|---|---|
outline_shelf |
List available books on the shelf |
outline_select_book |
Select a book (by slug or number) to operate on |
outline_init |
Create a new book |
outline_toc |
Table of contents (numbered IDs) for the selected book |
outline_checklist |
Export a section as a Markdown checklist |
outline_dump |
Full JSON dump of a book |
outline_node_create |
Add a node under a parent (or root) |
outline_node_update |
Edit a node's title/body/type/placeholder |
outline_node_move |
Move a node under a new parent |
outline_node_batch_move |
Bulk move (UUID required) |
outline_node_batch_update |
Bulk update (UUID required) |
outline_node_history |
Change history for a single node |
outline_book_history |
Change history for the whole book |
outline_snapshot_create |
Snapshot the current book state |
outline_snapshot_list |
List snapshots |
outline_snapshot_restore |
Restore a snapshot |
outline_snapshot_tag |
Tag a snapshot with a label |
outline_snapshot_diff |
Diff two snapshots |
outline_snapshot_dump |
Dump one snapshot |
outline_snapshot_dump_all |
Dump every snapshot |
outline_import |
Import a book from JSON |
outline_gen_routing |
Generate routing metadata |
Bundled guide resources are surfaced verbatim under the outline://guides/*
URI scheme via list_resources / read_resource.
Migrating from legacy [[export]] route = "outline" (pre-v0.7.0 users)
Before v0.7.0 the recommended way to reach outline-mcp through lds was to
declare a [[route]] name = "outline" and re-expose its snapshot / history
tools via [[export]] in ~/.config/lds/config.toml (or the project-local
equivalent). Since v0.7.0 the outline surface is provided by the in-process
lds-outline SDK integration, so the export block is no longer needed —
and, if left in place, it silently shadows the SDK path for exported
tool names.
Concretely: lds's call_tool dispatch checks [[export]] before the
direct-embed outline delegation, so a config like
[[]]
= "outline"
= "outline-mcp"
[[]]
= "outline"
= ["snapshot_create", "snapshot_list", "snapshot_dump",
"snapshot_dump_all", "snapshot_tag", "snapshot_diff",
"book_history"]
routes outline_snapshot_* and outline_book_history to a subprocess
outline-mcp binary while outline_shelf / outline_select_book /
outline_toc / outline_dump / outline_node_* go through the direct
SDK. The subprocess and the in-process OutlineMcpServer hold separate
selected state, so outline_select_book from the SDK path never reaches
the subprocess and the exported snapshot tools return
-32602: No book selected even right after a successful outline_select_book.
Migration: remove the [[export]] route = "outline" block (and, if you
had no other reason to keep it, the [[route]] name = "outline" block as
well) from your lds config, then restart your MCP client. All outline_*
tools will flow through the in-process SDK and share a single selected
state.
Roadmap
| Stage | Scope | Status |
|---|---|---|
| S1 | Git write ops (commit / merge / worktree_{add,remove,list} / branch_delete) with session-scoped write safety |
✅ done |
| S2 | Recipe schema validation (typed content-key contract for recipe_run) |
planned |
| S3 | Sandbox extensions — optional container / subprocess isolation backends for the sandbox module | planned |
Why one process?
Each MCP server an agent has to talk to is one more process to install, one
more session_start call to make, and one more reference to thread through
prompts. Folding git, recipe, and sandbox into a single binary backed by a
shared Session collapses the install surface to one target, the per-task
session call to one invocation, and lets every module read the same project
root / timeout / output limits without duplicate configuration.
Usage
# .mcp.json
{
}
Pack
| Tool | Description |
|---|---|
pack_create |
Pack a whole project (.git + untracked local state) into one archive. root defaults to the session root; dry_run classifies without writing |
pack_restore |
Restore a pack, rewriting worktree gitdir wiring for the new location. dry_run predicts (what gets replaced / what remains / what dangles) without writing |
pack_inspect |
Read a pack's manifest without unpacking; files also lists every packed path |
Each runs on a blocking thread (spawn_blocking), so packing a large tree
never occupies a tokio worker. The same operations are also available as CLI
subcommands — see below.
lds pack — moving a whole project
A pack carries a project as it exists on this machine: the .git directory
itself, plus the untracked local state that normally never leaves — a
workspace/ tree, journal databases, .mcp.json, sandbox snapshots.
Both halves have a dry run, and they answer different questions. create --dry-run asks what would travel; restore --dry-run asks what would
happen here — which existing files get replaced, which survive, which symlinks
land dangling on this machine, which worktree pointers get rewritten. An
existing destination is not an error during a preview, since reporting the
collision is the whole point:
)
) )
) )
restore refuses a destination that already exists unless --force is given.
--force overwrites, it does not wipe: entries in the pack replace their
counterparts, and files already in the destination that the pack does not carry
are left alone. Restoring a backup over a damaged working copy therefore brings
back everything the pack holds without deleting anything it never knew about —
so unrelated leftovers stay behind, and the destination can end up dirtier than
the machine the pack came from.
The destination path is otherwise unconstrained: deeply nested, containing spaces, non-ASCII, or given relative to the current directory all work, and the worktree pointers are rewritten to match wherever it lands.
Why the .git directory and not git bundle. A bundle is built from an
enumerated set of refs, so anything outside that set does not make the trip:
stashes, the reflog, local-only branches, objects no ref points at. Copying the
directory removes the question — whatever git had, the pack has. On this
repository, a round trip preserves the dangling commit git fsck reports, all
local-only branches, the reflog, and uncommitted work in progress.
Three classes are handled deliberately:
| class | treatment |
|---|---|
secrets (.env, *.pem, id_rsa, …) |
not packed, reported — moving credentials stays yours to do |
caches (target/, node_modules/, .venv/, …) |
not packed, recorded — regenerable by definition |
| symlinks | packed as links, never followed; recorded so a restore elsewhere names what dangles |
.claude/ is packed verbatim as its own layer, with its links counted and their
shared root summarized rather than listed one by one. State outside the project
root (~/.config/...) is not collected.
Teaching it your own secret names
Secret file names are open-ended — every ecosystem invents its own
(credentials.toml, terraform.tfvars, service-account.json), and a project
can always have one nobody has heard of. The built-in list covers the common
ones; extend it in ~/.config/lds/config.toml:
[]
= ["my-app-keys.json", "*.vault"] # also treat these as secrets
= ["dist"] # also treat these as caches
= [".npmrc"] # pack these despite a built-in rule
Globs match the file name, not the path. secret_globs and cache_dirs
add to the built-ins rather than replacing them, so declaring one
project-specific name cannot silently disable the rest of the protection.
keep is the only subtractive list, for when a built-in rule is wrong for this
project. A malformed glob is a hard error naming itself — a typo must not
silently match nothing.
Use --dry-run to see the effect before writing an archive:
)
()
)
)
)
)
Registered worktrees travel too. Both halves of a worktree's wiring —
.git/worktrees/<name>/gitdir and the worktree's own .git file — are absolute
paths, so restore rewrites them for the new location; a plain tar extract
would leave both aimed at the machine the pack came from.
Plugin Recipes
Justfile recipes tagged with [group('lds-plugin')] are auto-registered
as MCP tools at startup. Drop a justfile at ~/.config/lds/justfile
(global) or in your project root (project-scoped) and each plugin
recipe becomes mcp__lds__<name>.
Quick bootstrap:
# restart Claude Code so the MCP server re-reads the global plugin set
The skeleton ships with complexity / search-excluding /
remote-url / text-stats / greet. See
docs/plugin-recipe-authoring.md for
the full IF contract, parameter mapping, shebang recipes, and the
macOS-awk / CWD pitfalls. The same doc carries the
Plugin vs AllowAgent decision flowchart
and the
naming-collision guide
for picking the right group.
config.toml (Recommended)
The preferred way to configure persistent global recipe directories is
~/.config/lds/config.toml:
[]
= ["/opt/shared-recipes", "~/team-recipes"]
[]
= "~/.config/lds/justfile"
Use the lds recipe-dir CLI to manage recipes.dirs without hand-editing:
Tilde expansion:
lds recipe-dir add ~/team-recipesexpands the path to an absolute path before writing it toconfig.toml. Existing comments and other sections inconfig.tomlare preserved (patch-safe write).
Resolution priority (low → high):
~/.config/lds/justfile (default) → config.toml recipes.dirs →
LDS_RECIPE_GLOBAL_DIRS env → project justfile
Restart required: config.toml is read once at process startup.
Changes to config.toml require restarting the lds process to take effect.
SIGHUP-based reload is not implemented (tracked as a separate issue).
Additional Global Recipe Directories — Legacy (LDS_RECIPE_GLOBAL_DIRS)
Legacy: prefer
config.toml+lds recipe-dir add(above) for new setups.LDS_RECIPE_GLOBAL_DIRScontinues to work and is useful for CI / ephemeral environments where a config file is inconvenient.
Set LDS_RECIPE_GLOBAL_DIRS to a colon-separated list of directories
(PATH-style) to load additional global justfiles beyond ~/.config/lds/:
# .mcp.json
{
}
When both config.toml and LDS_RECIPE_GLOBAL_DIRS are set, directories
from LDS_RECIPE_GLOBAL_DIRS take precedence over config.toml on name
collision — env is loaded after config in the resolution chain, following
the standard CLI convention (cargo, git, gh: env overrides file config).
Same-name recipes in later entries override earlier ones; the project
justfile always wins.
Alternative: import '<abs>/justfile'
Add an import statement to ~/.config/lds/justfile to pull in another
justfile directly:
import '/opt/shared/shared-recipes.just'
This approach requires editing ~/.config/lds/justfile by hand and does
not appear in lds recipe-dir list. It is provided for compatibility with
existing setups.
Global Recipe Contract
Consumer-facing IF for serving recipes via lds. The five points below are the contract; they are not optional behaviors.
-
Discovery paths: lds reads
~/.config/lds/justfile(default global), every directory listed inconfig.tomlrecipes.dirs, and every directory listed inLDS_RECIPE_GLOBAL_DIRS. Recipes brought in by just's nativeimport '<path>'from any of those justfiles are also served — there is no separate registration step for imported recipes. -
Group filter (mutually exclusive routing):
Tag Routing [group('lds-plugin')]Registered as a dedicated MCP tool at startup ( mcp__lds__<name>) and listed byrecipe_list/ runnable viarecipe_run. Intended for global utilities.[group('allow-agent')]Listed by recipe_listand runnable viarecipe_runonly. Not exposed as an individual MCP tool. Intended for project/task recipes invoked throughrecipe_run.no group Excluded. Not served at all (legacy # [allow-agent]doc comment is still honored for backward compatibility). -
Dedup: When the same recipe arrives through two paths (e.g. env injection + root
import),just --dumpdedupes by recipe name; lds does not error and serves a single entry. -
Restart required: lds resolves the global justfile set at process startup using
config.tomlandLDS_RECIPE_GLOBAL_DIRS.recipe_list/recipe_runre-parse justfiles live, but changes toconfig.toml, env vars, or newly added global directories require a Claude Code restart to take effect. SIGHUP reload is not implemented. -
Three coexisting routes for adding global recipes: (a) declare in
config.tomlrecipes.dirsvialds recipe-dir add(recommended), (b) inject viaLDS_RECIPE_GLOBAL_DIRSenv (legacy / CI), or (c) addimport '<abs>/justfile'to~/.config/lds/justfile(manual). All three are supported simultaneously.
License
Licensed under either of Apache License, Version 2.0 or MIT license at your option.