AI Integration (MCP)
Lerd ships a Model Context Protocol server, letting AI assistants manage your dev environment directly: run migrations, start services, toggle queue workers, and inspect logs without leaving the chat.
Supported assistants: Claude Code, Cursor, JetBrains Junie, Codex CLI, Gemini CLI, GitHub Copilot (VS Code), Google Antigravity, OpenCode, Windsurf, and any other MCP-compatible tool.
Setting up MCP
There are two ways to connect lerd to your AI assistant: globally (recommended) or per-project.
Global registration (recommended)
Run once after installing lerd:
lerd mcp:enable-globalOr turn on AI assistants (MCP) under System in the web UI, which does the same and turns it off again with lerd mcp:disable-global's teardown.
This registers the lerd MCP server at user scope, available in every session regardless of which directory you open, and writes user-scope context files so the assistant knows what lerd tools are available and how to use them.
MCP server registration:
| Client | Registration |
|---|---|
| Claude Code | claude mcp add --scope user (CLI) |
| Cursor | ~/.cursor/mcp.json |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| JetBrains Junie | ~/.junie/mcp/mcp.json |
| Gemini CLI | ~/.gemini/settings.json |
| Codex CLI | ~/.codex/config.toml |
| GitHub Copilot (VS Code) | ~/.config/Code/User/mcp.json |
| Google Antigravity | ~/.gemini/config/mcp_config.json |
| OpenCode | ~/.config/opencode/opencode.json |
Context / instructions files:
| File | Purpose |
|---|---|
~/.claude/skills/lerd/SKILL.md | Claude Code user-scope skill |
~/.cursor/rules/lerd.mdc | Cursor user-scope rules |
~/.junie/guidelines.md | JetBrains Junie user-scope guidelines (merged, not overwritten) |
~/.gemini/GEMINI.md | Gemini CLI user-scope context (merged) |
~/.codex/AGENTS.md | Codex CLI user-scope context (merged) |
~/.config/opencode/AGENTS.md | OpenCode user-scope context (merged) |
All clients share a single canonical tool reference, so the guidance never drifts between assistants. When running globally, the server uses the directory the assistant is opened in as the site context: no further configuration is needed.
Claude Code is registered via its own
claude mcp addCLI rather than by editing~/.claude.jsondirectly, since that file holds all of Claude's user state. If you run Claude Code under a separate profile withCLAUDE_CONFIG_DIRset, runlerd mcp:enable-globalfrom a shell where it is set: the skill then goes to$CLAUDE_CONFIG_DIR/skills/lerd/SKILL.mdand the registration to that profile. Repeat it once per profile.
GitHub Copilot uses VS Code's
serverskey (each entry typedstdio), which differs from themcpServerskey the other clients use. Its instructions file (.github/copilot-instructions.md) is project-scoped only.
Google Antigravity registers at
~/.gemini/config/mcp_config.json(its project-scoped MCP config is not honoured, so it is global only). It auto-loadsGEMINI.mdandAGENTS.md, which the Gemini and Codex entries already write, so no separate Antigravity context file is needed.
OpenCode uses neither of the other two shapes: its entry sits under an
mcpkey, is typedlocalrather thanstdio, and takes the whole invocation as a singlecommandarray. It readsAGENTS.mdfrom the project root, which the Codex entry already writes, so only its own~/.config/opencode/AGENTS.mdis written separately. OpenCode also readsopencode.jsonc, and its own documentation asks for one format per directory, so where a.jsoncalready exists lerd merges into that file rather than leaving a.jsonbeside it that may never be read. The rewrite emits plain JSON, so comments in that file are not preserved.
Windows (WSL2): most assistants on a WSL install run as Windows apps, so
mcp:enable-globalalso registers lerd with the ones installed in your Windows profile: Claude Code through the Windowsclaude mcp add, and Cursor, VS Code, Windsurf, Junie, Gemini, Codex, Antigravity and OpenCode through their user configs there (VS Code's sits in%APPDATA%\Code\User\mcp.json). Their entries runlerd.exe mcpby its full path, and lerd.exe hands the folder the assistant is opened in to lerd, so a project opened through\\wsl.localhost\...resolves to its site. Only clients whose folder exists on Windows get an entry.wsl:setupdoes the same when global MCP is already on,lerd updaterefreshes their skills, andmcp:disable-globalorlerd uninstalltakes them back out.
During
lerd install: If Claude Code is detected, you'll be prompted to run this automatically.
During
lerd update: When MCP is globally registered, the context files that already exist are rewritten from the newly installed binary so they stay in sync with any added or renamed tools. Update never creates files for a client you haven't set up, to pick up a newly supported assistant, re-runlerd mcp:enable-global.
Project-scoped registration
To commit the lerd MCP config into a project so every teammate picks it up from git:
cd ~/Lerd/my-app
lerd mcp:injectThis writes MCP config and context files for every supported client into the project directory:
| File | Client |
|---|---|
.mcp.json | Claude Code MCP config |
.claude/skills/lerd/SKILL.md | Claude Code skill |
.cursor/mcp.json | Cursor MCP config |
.cursor/rules/lerd.mdc | Cursor rules |
.junie/mcp/mcp.json | JetBrains Junie MCP config |
.junie/guidelines.md | JetBrains Junie guidelines (merged) |
.gemini/settings.json | Gemini CLI MCP config |
GEMINI.md | Gemini CLI context (merged) |
.vscode/mcp.json | GitHub Copilot (VS Code) MCP config |
.github/copilot-instructions.md | GitHub Copilot instructions (merged) |
AGENTS.md | Codex CLI context, also read by OpenCode (merged) |
opencode.json | OpenCode MCP config |
The written entries carry no machine-specific data: the server resolves the site from the directory the assistant is opened in, exactly like a global registration. A committed .mcp.json therefore stays identical across every teammate's checkout, with no absolute path to drift or break on another machine. (An older lerd wrote a LERD_SITE_PATH absolute path into these files; it is still honoured if you set it by hand, but no longer written.)
Codex and Windsurf have no project-scoped MCP config (Codex reads only
~/.codex/config.toml, Windsurf only~/.codeium/windsurf/mcp_config.json), somcp:injectwrites their context but not a per-project server entry. Register them once withlerd mcp:enable-global. lerd used to write a Windsurf entry into.ai/mcp/mcp.json, but that path was never Windsurf's (it belongs to Laravel Boost), so lerd no longer touches it and strips any entry an older version left behind.
The command merges into existing configs; other MCP servers (e.g. laravel-boost, herd) and any existing instructions content are left untouched. Re-running it is safe.
To target a different directory:
lerd mcp:inject --path ~/Lerd/another-appTo reverse an injection, lerd mcp:eject (with an optional --path) strips every lerd-owned entry and skill file back out of the project, leaving other MCP servers and your own instructions content intact. The user-scope equivalent is lerd mcp:disable-global.
During
lerd update: Projects that previously ranmcp:injectare detected automatically (by the presence of.claude/skills/lerd/SKILL.md,.cursor/rules/lerd.mdc, or the lerd marker in.junie/guidelines.md) and refreshed in place. Only files that already exist for a client are rewritten, so update never drops new client files into your repo; re-runmcp:injectto add a newly supported assistant. Directories whose content already matches the new binary stay untouched, so git status stays clean. Projects that never opted in are skipped.
To opt a project out of this automatic refresh entirely, set mcp_inject: false in its .lerd.yaml. lerd then never rewrites that project's MCP config or skill files on a self-update, which is the right choice when you keep those files under version control and want them changed only when you say so. An explicit lerd mcp:inject still writes when you run it, since that is you asking directly.
Path resolution
Most actions accept an optional path argument. When omitted, the server resolves it in this order:
- Explicit
pathargument (highest priority) LERD_SITE_PATHenv var (honoured if set by hand; lerd no longer writes it)- Current working directory, the directory the assistant was opened in
Agent detection passthrough
PHP runs inside the container, so the AI agent environment variables your coding agent exports on the host (CLAUDECODE, AI_AGENT, CURSOR_AGENT, GEMINI_CLI, and the rest of the agent-detector set) would normally be lost at the container boundary. lerd forwards any that are present into the container for lerd php, lerd artisan, and tinker, so packages like laravel/pao still detect the agent and emit their compact JSON output.
Commands the exec MCP tool runs (artisan, composer, vendor_run for Pest/PHPUnit) go a step further: because reaching them through the MCP server proves an agent is driving the command, lerd injects a neutral AI_AGENT=lerd-mcp marker when no real agent variable is present. Pao therefore returns JSON for MCP-originated test runs even if the host environment carries nothing, while a real agent variable is still forwarded as-is when it exists. Manual terminal runs are never given the marker, so their output is unchanged.
Running your framework's own MCP server
This is separate from lerd's MCP server. Some frameworks ship their own local MCP server, started as a console command — Laravel MCP does, and it is what Laravel Boost builds on. Such a server has to run where the application runs: started from the host it resolves lerd-mysql or lerd-redis against nothing, and you end up maintaining a second set of host-only .env values just to keep it alive.
lerd's php shim already takes care of that. The installer puts lerd's bin directory ahead of the system one on your PATH, and the php there execs into the project's container, so the ordinary registration works unchanged:
{
"mcpServers": {
"weather": {
"command": "php",
"args": ["artisan", "mcp:start", "weather"]
}
}
}The server then runs on the same PHP build, the same extensions and the same container network as the site, and the hostnames in your .env resolve exactly as they do for a web request.
A client you launch from a desktop icon rather than a terminal may not inherit your login PATH, in which case it finds the system php instead of the shim. Name lerd explicitly there:
{
"mcpServers": {
"weather": {
"command": "lerd",
"args": ["artisan", "mcp:start", "weather"]
}
}
}Either form leaves stdout to the server: anything lerd needs to say while starting a container or a service goes to stderr, so it never lands in the middle of the JSON-RPC stream.
PHP code intelligence
lerd lsp runs the PHP language server that powers tinker's autocomplete, on stdio, in the current folder. An assistant that can talk to a language server gets exact answers to "where is this defined" and "who calls this" instead of guessing from grep, and because the server reads the project's vendor/, Laravel's facades, Eloquent statics, local scopes and relations resolve to real code. Macros registered at runtime, such as Route::inertia, are the gap.
In Claude Code, the lerd-php plugin from the lerd-env/claude-plugins marketplace wires it up. lerd mcp:enable-global installs it alongside the MCP registration and lerd mcp:disable-global removes it again; to add it by hand instead:
/plugin marketplace add lerd-env/claude-plugins
/plugin install lerd-php@lerdRestart Claude Code afterwards so the LSP tool appears. Other assistants need nothing from lerd here: OpenCode bundles its own PHP language server, editor-based ones such as Cursor or Copilot use the editor's PHP extension, and Codex and Gemini CLI have no way to attach one. The first lerd lsp on a machine downloads the server if lerd install has not already, reporting progress on stderr.
Having the tool is not enough on its own, because Claude Code defers it and an assistant reaches for grep out of habit. The server instructions tell it to load the LSP tool and ask it first for definitions, callers, implementations and types, keeping grep for strings, config, non-PHP files and macros. Two gaps make a closing grep worthwhile on a callers question: calls through a facade are found from the facade's @method docblock line rather than the method behind it, and calls on untyped properties, such as $this->x in a Pest test, have no type to resolve.
Available MCP tools
The MCP surface is fourteen grouped tools, each driven by an action argument. Always pass action; start by calling site with action: "list" to discover sites.
The server also sends short instructions when a client connects, telling the assistant to use these tools rather than the raw commands they wrap. Clients such as Claude Code load tool schemas only when needed, so without those instructions an assistant would see just a tool named worktree and run git worktree add itself, skipping the dependency install, branch domain and database isolation that add handles.
| Tool | Actions |
|---|---|
site | list (discover sites, call first), link, unlink, domain_add, domain_remove, group_assign, group_unassign, group_label, group_db, group_list, tls_enable, tls_disable, tls_renew, php, node, pause, unpause, restart, rebuild, runtime, nginx_read, nginx_write, nginx_reset, park, unpark |
service | start, stop, restart, pin, unpin, update, rollback, migrate, remove, reinstall, add, expose, port, env, config_read, config_write, config_restore, config_reset, config_list_backups, preset_list, preset_search, preset_install, check_updates, entities, entity_action |
db | list, set, move, create, export, import, snapshot, snapshots, restore, snapshot_delete, snapshot_keep, auto, auto_set, extension_list, extension_add |
env | setup, check, override |
runtime | versions, node_install, node_uninstall, node_manager, php_list, ext_list, ext_add, ext_remove, ports_list, ports_add, ports_remove, ini_read, ini_write, ini_reset |
worker | list (call first), start, stop, add, remove, health, heal, mode_get, mode_set, queue_start, queue_stop, horizon_start, horizon_stop, reverb_start, reverb_stop, schedule_start, schedule_stop, stripe_start, stripe_stop, stripe_config |
exec | artisan, console, composer, vendor_bins, vendor_run, commands_list, commands_run, command_add, command_remove |
framework | list, add, remove, prune, search, update, project_new, setup |
diag | status, doctor, doctor_fix, site_doctor, which, check, dns_diagnose, bug_report, analyze_queries, route_timing, optimize_route, dumps_recent, dumps_status, dumps_clear, dumps_toggle, dumps_buffer, browser_events, browser_toggle, profiler_toggle, profiler_status, profiler_clear, profiler_report, xdebug_on, xdebug_off, xdebug_status |
logs | sources, fetch |
request | list (a site's recent requests and their ids), lenses (events per lens for one request, plus the issues they hold: N+1 and slow queries first, then exceptions, error logs, failed HTTP, failed jobs and browser errors), lens (one lens's events, paged) |
worktree | list, add, remove, wait, db_isolate, db_share |
workspace | list, create, rename, delete, assign, move |
plugin | guide (how to build a plugin, read first), new, list, install (from a local folder) |
The injected context files document each action's arguments and the key conventions in full.
Installed plugins add tools of their own, named <plugin>_<tool>, and lerd mcp:inject writes each plugin's skill beside lerd's as .claude/skills/lerd-<plugin>/SKILL.md. Run it again after installing a plugin. To have an assistant build one, see building a plugin with an AI assistant.
Switching branches from an assistant
A plain git switch or git pull moves the code but leaves the database on the old branch's schema. The server instructions tell an assistant to keep the two in step with the tools it already has: a db snapshot before the switch, a copy of the database through worktree db_isolate for worktrees sharing it whenever a migration or a restore is about to change it, then a db restore of the snapshot taken on the new branch when there is one, and in either case a composer install through exec when composer.lock changed, ahead of the new branch's migrations. Snapshots taken through MCP record the site and the branch checked out, so db snapshots shows which branch's schema each one holds. When only the old branch had some migrations, the database is ahead of the code; the assistant says so and offers the snapshot rather than rolling anything back on its own.
Checking a browser test
The context files tell an assistant that drives a site in a browser (Playwright, Pest browser, a browser MCP) to check each run against lerd before calling it done. With debug capture on, every PHP response carries an X-Lerd-Rid header; the assistant passes that id to request lenses and treats any N+1, slow query or error it reports as unfinished work. The instructions also have the assistant turn on browser logs for the site before the run, which adds the page's JavaScript errors and failed fetches to the same request, and to read browser_events afterwards, since errors after a single-page navigation belong to a page view with no request id.
Creating a worktree from an assistant
worktree add blocks until the watcher's setup pipeline has finished and reports provisioned: true, because the pipeline starts the moment git writes the worktree entry, and an action that returned earlier would hand back a tree being written underneath the assistant. Two installers in one tree is how vendor/ ends up with packages extracted but no autoload.php, which then presents as a Composer autoload bug rather than a race.
Once dependencies are in, add finishes the worktree the way the dashboard's Add worktree form does: it builds the frontend assets (or starts the asset worker that replaces the build), wires the database, and runs any setup commands the framework definition declares, then reports ready: true. build (auto, skip, worker:<name>, script:<name>) and db (share, empty, clone-main, clone-<branch>) override the defaults, which are the automatic build pick and the parent's database; a framework that requires an isolated database gets one either way. When db is left out and the framework definition declares where its migrations live, add makes that choice itself by comparing the two checkouts' migration files, and reports it on a Database: line in setup_output. For a framework that declares no migrations folder, the server's instructions tell the assistant to choose db by comparing the new branch's migrations with the parent checkout's, meaning whichever branch the site's own folder has checked out, since that is the database sharing reuses and cloning copies, not necessarily git's main. Migrations only the new branch has call for a clone with those migrations run on it, migrations only the parent has mean the branch is behind (merge, or start from an empty database and migrate), and an identical set can share the parent's database. An empty database is migrated as part of add, using the migrate command the framework definition names. To start a new branch from another ref, pass branch with base (for example base: origin/main); git_args is for anything else and cannot be combined with branch.
wait is the same check on its own, for a worktree created with plain git or after add with wait=false. Both accept timeout_seconds (default 300) and report provisioned: false with a note rather than an error when setup is still running, since an unfinished install is a "not yet", not a failure.
An assistant should never infer readiness from the tree's contents. node_modules/ exists from the first extracted package, and composer's extraction phase fills existing vendor/<org>/ directories, so neither a file count nor an mtime moves during the longest stretch of an install. Both read as finished mid-install.
Workspaces are not site groups
The two are easy to confuse and an assistant reaching for the wrong one does the wrong thing.
A workspace (the workspace tool) is a display-only bucket that organises the site list in the dashboard sidebar and the TUI. It never touches nginx, domains, certificates or .env. Use it when someone wants their sites sorted by client or by project.
A site group (the site tool's group_* actions) nests a real site under another site's subdomain, at <label>.<main>.test, and regenerates vhosts and certificates to serve it. Use it when a site should actually be reachable at that address.
Service presets carry their own discovery metadata
preset_list returns each preset's category, icon, admin_for and suggest_for. admin_for names the services a preset's admin UI administers, and it is not depends_on: phpMyAdmin depends on mysql (satisfied by MariaDB via env_role) but administers both, and RedisInsight depends on redis (satisfied by Valkey) while administering both. To answer "which dashboard administers this database", read admin_for.
service start/stop/restart use the same serviceops path as the CLI, Web UI, and TUI: depends_on resolution (including family / env_role drop-ins), reverse-dependent start, soft stop cascade, and discover_family / pinned-dependency-host consumer regen. Stop failures surface as errors rather than a silent OK. Do not assume MCP is a thinner StartUnit wrapper.
Downloads are disclosed, not started
An assistant is not the one paying for the bandwidth, so the actions that have to fetch a container image (service start, preset_install, update, migrate, rollback, reinstall, and runtime ext_add, which rebuilds a PHP image) answer with what they would download instead of downloading it. The answer names the image and its size, read from the registry manifest without pulling anything, and nothing has been fetched at that point. Repeating the call with confirm: true goes ahead. An image already in the local store is never disclosed, so the usual case runs straight through. This is the disclosure half of what LERD_OFFLINE=1 does for the refusal half; see Image downloads.
Worker tuning comes from the framework definition
worker list reports each worker's tunable options: the placeholders its framework definition declares in tune_command, the default the definition runs for each, and whatever the project committed to .lerd.yaml. start (and queue_start) take them back as options: ["queue=emails", "tries=5"]. Nothing is named in the tool's own schema, so a store definition that makes another worker tunable reaches an assistant within the day, the same way the CLI grows a flag per placeholder. See Worker options.
Reading logs
The logs tool lets an assistant debug a site's logs without opening files by hand. Call logs with action: "sources" to list every queryable source for a site (app:<file> framework logs, fpm, worker:<name>) plus shared infrastructure (nginx, dns, watcher, ui, services, php<ver>), then action: "fetch" with a source and any of grep (regex or literal substring), since/until (relative like 15m/2h30m, or a timestamp), level (app logs only), and lines. Each fetch returns an opaque cursor; pass it back as since on the next call to receive only the new lines, which is how streaming is modelled over MCP's request/response transport. Passing a worktree's checkout as path reads that worktree's own app logs and the logs of the workers started in it. See the logs feature page for the full source list, filter semantics, and platform notes.
Grouped surface: earlier lerd versions exposed ~80 individual MCP tools (
sites,artisan,db_set, …). These were consolidated into the grouped tools above to cut the per-session token cost and sharpen the model's tool selection. Old flat tool names no longer exist; call the group with the matchingactioninstead (e.g.artisan→execwithaction: "artisan",db_set→dbwithaction: "set").
Example interactions
The path argument is omitted from most calls; the server resolves it from the directory the assistant was opened in.
You: create a new Laravel project and get it running
AI: → framework(action: "project_new", path: "/home/me/Code/myapp", framework: "laravel")
# scaffolds + runs composer install, returns with vendor/ populated
→ site(action: "link", path: "/home/me/Code/myapp")
→ env(action: "setup", path: "/home/me/Code/myapp")
# detects MySQL + Redis (or keeps sqlite), starts services, creates DB, generates APP_KEY
→ framework(action: "setup", path: "/home/me/Code/myapp")
# runs storage:link + migrate
✓ myapp -> myapp.test ready
You: run migrations
AI: → exec(action: "artisan", args: ["migrate"])
✓ Ran 3 migrations in 42ms
You: install sanctum and run its migrations
AI: → exec(action: "composer", args: ["require", "laravel/sanctum"])
→ exec(action: "artisan", args: ["vendor:publish", "--provider=Laravel\\Sanctum\\SanctumServiceProvider"])
→ exec(action: "artisan", args: ["migrate"])
You: add a MongoDB service
AI: → service(action: "add", name: "mongodb", image: "docker.io/library/mongo:7", ports: ["27017:27017"], data_dir: "/data/db")
→ service(action: "start", name: "mongodb")
✓ mongodb started
You: add phpMyAdmin, it needs MySQL to be running
AI: → service(action: "preset_install", name: "phpmyadmin")
→ service(action: "start", name: "phpmyadmin")
# starts mysql first (dependency), then phpmyadmin
✓ mysql started
✓ phpmyadmin started
You: what PHP and Node versions are installed?
AI: → runtime(action: "versions")
{ "php": { "installed": ["8.4", "8.5"], "default_version": "8.5" },
"node": { "installed": ["v20.11.0", "v18.20.4"], "default_version": "20" } }
You: set up the project I just cloned
AI: → site(action: "link")
→ exec(action: "composer", args: ["install"])
# runs BEFORE env setup so APP_KEY generation has vendor/
→ env(action: "setup")
# detects MySQL + Redis, starts them, creates database, generates APP_KEY
→ framework(action: "setup")
# framework migrations + storage:link (or doctrine:migrations:migrate for Symfony)
✓ whitewaters -> whitewaters.test ready
You: enable xdebug so I can step through a failing job
AI: → diag(action: "xdebug_status")
→ diag(action: "xdebug_on", version: "8.5")
✓ Xdebug enabled for PHP 8.5 (mode=debug, port 9003)
You: start the queue worker
AI: → worker(action: "list", site: "myapp")
→ worker(action: "queue_start", site: "myapp")
✓ queue worker started for myapp
You: the app is throwing 500s, check the logs
AI: → logs(action: "sources", site: "myapp")
→ logs(action: "fetch", source: "app:laravel.log", level: "error", since: "15m")
PHP Fatal error: Class "App\Jobs\ProcessOrder" not found ...
You: this site feels slow, optimize it
AI: → diag(action: "route_timing", site: "myapp")
# GET /reports/:id runs at 1080ms p95, 27x the site's 40ms median
→ diag(action: "dumps_toggle", enable: true)
# then hit the slow route a couple of times so queries are captured
→ diag(action: "optimize_route", site: "myapp")
{ "routes": [ { "route": "GET /reports/:id", "p95_millis": 1080,
"evidence": [ { "n_plus_one": [ { "count": 38,
"fingerprint": "select * from line_items where report_id = ?",
"caller": { "file": "app/Http/Controllers/ReportController.php", "line": 44 } } ] } ] } ] }
# the N+1 and its exact caller, from real traffic, not from reading code