The typed multi-repo MCP server your AI coding agent needs.
Canopy is built for workspaces with multiple repos that share a feature lifecycle — backend + frontend, api + mobile, a monolith plus its services. That setting breaks coding agents in specific, fixable ways: shell state doesn't survive between tool calls, paths get constructed wrong, drift accumulates silently between repos, and PR review work pulls the agent across repo boundaries faster than its context can keep up.
Canopy gives the agent a typed contract for that setting — feature / repo / alias inputs, structured outputs, recoverable errors — so it can drive multi-repo feature work end-to-end without ever shelling cd /wrong/repo.
# Without canopy: brittle paths, parsed stderr, no shared state across repos.
bash("cd /Users/.../web/api && git status")
bash("cd /Users/.../web/ui && git status")
bash("gh pr list --author @me --json number,title")
# ... then per-thread "is this still actionable?" logic in the agent's head
# With canopy: one typed call, structured multi-repo response, recoverable error.
mcp__canopy__feature_state(feature="auth-flow")
# → { "state": "needs_work",
# "next_actions": ["address_review_comments"],
# "summary": {
# "ci_aggregate": "passing",
# "actionable_human_count": 2,
# "repos": {
# "api": { "dirty_file_count": 3, "ahead": 2, "behind": 0, "pr": {...} },
# "ui": { "dirty_file_count": 0, "ahead": 0, "behind": 0, "pr": {...} }
# }
# }
# }The CLI is the surface humans use to drive the same primitives. Same JSON, two consumers.
Each mcp__canopy__* tool closes one failure mode that agents reliably hit when the workspace has more than one repo:
| Failure mode | Canopy's fix |
|---|---|
Shell state evaporates between tool calls. cd /repo-a && command doesn't persist; the next call lands somewhere else. Multi-repo makes this worse because there's more than one "right" place to land. |
Every canopy tool takes feature / repo / alias as parameters; path resolution lives inside canopy. The agent has no surface area to type the path. |
Cross-repo state is invisible. git status in one repo doesn't tell you what's happening in the other. The agent has to query each repo separately and stitch the picture. |
mcp__canopy__feature_state(feature) returns the full multi-repo picture in one call: per-repo dirty/ahead/behind, PRs, CI, computed state, prioritized next actions. |
Drift between tool calls. The agent git checkout'd X in one repo, the next call assumes the OTHER repo is also on X; things go sideways. |
Per-repo post-checkout hooks write .canopy/state/heads.json (fcntl-locked, atomic-renamed). mcp__canopy__drift reads cached state in <50ms. The agent sees misalignments that happened between calls, even when it didn't cause them. |
Session re-derivation. Each new chat re-walks gh pr list, git status per repo, comment threads, CI status — burning context on bookkeeping the previous chat already did. |
mcp__canopy__feature_resume(alias) is one call: alias → switch focus if needed → refresh GH+Linear → return structured brief of what changed since last visit. Cross-session state via .canopy/state/visits.json + per-feature memory at .canopy/memory/<feature>.md. |
| PR review churn across repos. A feature with two PRs (one per repo) accumulates threads on both; the agent re-classifies "is this still actionable?" every turn. | mcp__canopy__github_get_pr_comments(alias) returns threads pre-bucketed as actionable / likely_resolved via temporal filtering (comment timestamp vs commits-on-file-since). Resolved threads carry by_canopy: true attribution when canopy itself closed them. |
Closing GH threads needs raw GraphQL. REST has no thread IDs; agents fumble with gh api graphql query strings. |
mcp__canopy__resolve_thread(thread_id), mcp__canopy__reply_to_thread(thread_id, body, resolve_after=True), and mcp__canopy__commit(address=<id>, resolve_thread=True) handle the wire format and log resolutions locally for attribution. |
Juggling 2–3 features in parallel loses in-progress work to forgotten stashes or breaks when one repo gets git checkout'd alone. |
The slot model (Wave 3.0): each feature lives in canonical / warm / cold. mcp__canopy__switch(feature) rotates focus atomically across every repo in the feature's lane, evacuating the previous canonical into a warm slot with stash → checkout → pop. |
| Errors come back as stderr text. Agents have to parse English failure messages to decide recovery. | Structured BlockerError(code, what, expected, actual, fix_actions), each fix carrying safe: bool so the agent knows what's auto-runnable vs needs human confirmation. |
Requires Python 3.10+.
pipx install git+https://github.com/ashmitb95/canopy.git
cd ~/your-multi-repo-workspace
canopy initIf you don't have pipx: brew install pipx && pipx ensurepath.
canopy init does four things:
- Discovers your git repos and writes
canopy.toml. - Installs the drift-detection post-checkout git hook in every repo.
- Wires the canopy MCP server into Claude Code by writing a
.mcp.jsonentry — this is what makes the agent surface live. - Installs the
using-canopyskill at~/.claude/skills/using-canopy/SKILL.mdso the agent knows when to reach for canopy tools.
Skip the agent bits with --no-agent if you're just using the CLI.
Every CLI command has an mcp__canopy__* MCP equivalent returning the same JSON. The MCP server is the load-bearing surface for agents; the CLI is the side benefit for humans. Tools by topic:
| Tool | What it does |
|---|---|
feature_resume(alias) |
The headline primitive. Resolves alias → switches canonical if needed → refreshes GitHub + Linear → returns the structured brief (since_last_visit, current_state, intent_hints). Call this first when a chat opens on a feature. |
feature_state(feature) |
9-state machine (drifted, needs_work, awaiting_bot_resolution, in_progress, ready_to_commit, ready_to_push, awaiting_review, approved, no_prs) + next_actions array. Drives the agent's decision tree. |
triage |
Cross-feature priority view. Returns features ordered by review-state urgency. |
slots(rich=True) |
Dashboard data — canonical + every warm slot with per-repo branch, dirty, ahead/behind, PR, CI, bot threads, Linear, computed feature_state. |
| Tool | What it does |
|---|---|
switch(feature) |
Promote a feature into the canonical slot. Previous canonical evacuates into a warm slot (active rotation, default) or goes cold with feature-tagged stash (release_current=True). Atomic across every repo in the feature's lane. |
slot_load(feature, slot_id?) |
Warm a cold feature into a slot without changing canonical. Use for pre-warming or inspecting a feature you're not ready to focus on. |
slot_clear(slot_id) |
Vacate a slot to cold (feature-tagged stash if dirty). The slot remains, just empty. |
slot_swap(slot_a, slot_b) |
Exchange the occupants of two warm slots. |
migrate_slots() |
One-shot migration from pre-3.0 layouts. |
| Tool | What it does |
|---|---|
github_get_pr_comments(alias) |
Returns actionable_threads + likely_resolved_threads per repo. Temporal filter has already classified what's worth the agent's attention. |
resolve_thread(thread_id, feature?) |
Close a GH review thread via GraphQL + log to .canopy/state/thread_resolutions.json for attribution. |
reply_to_thread(thread_id, body, feature?, resolve_after=False) |
Post a reply; optionally close after. |
commit(message, feature?, address=<id>, resolve_thread=False) |
Commit across the feature's repos. With address=<comment_id>, auto-suffixes the message + logs to bot_resolutions.json. With resolve_thread=True, closes the corresponding GH thread. |
bot_comments_status(feature) |
Per-PR bot-comment rollup: total / resolved / unresolved. |
draft_replies(feature) |
File-history-based addressed-comment detector + reply templates. |
| Tool | What it does |
|---|---|
preflight(feature?) |
Run each repo's preflight hooks (or [augments] preflight_cmd override). Records result for the state machine. |
push(feature?) |
Push across every repo in the feature's lane. set_upstream=True on first push. |
run(repo, command) |
Path-safe shell exec. Canopy resolves the cwd to the right repo dir; the agent never types a path. |
| Tool | What it does |
|---|---|
linear_get_issue(alias), linear_my_issues |
Linear issue data via the issue-provider abstraction. |
github_get_pr(alias), github_get_branch(alias) |
PR + branch data. |
issue_get(alias) |
Provider-agnostic issue read (Linear or GitHub Issues). |
Every read tool accepts the same alias forms:
- Feature name:
auth-flow - Linear issue ID:
TEAM-101(resolves via lane'slinear_issue) - Specific PR:
<repo>#<n>likeapi#142 - PR URL:
https://github.com/owner/repo/pull/142 - Specific branch:
<repo>:<branch> - Slot id:
worktree-1,worktree-2, ... (resolves to the slot's current occupant)
| Tool | What it does |
|---|---|
doctor |
21 diagnostic codes across 12 categories of state-file drift + install staleness. Each issue carries severity, expected / actual, and an auto_fixable flag. doctor(fix=True) runs the safe auto-fixes. First call when something feels off. |
version |
{cli_version, mcp_version, schema_version} handshake. Doctor reports cli_stale / mcp_stale when these drift. |
feature_memory(feature), historian_decide(feature, decisions), historian_pause(feature, reason), historian_defer_comment(feature, comment_id, reason), historian_compact(feature, keep_sessions) — persistent per-feature memory at .canopy/memory/<feature>.md. Auto-captured by commit --address and github_get_pr_comments. Read on switch to recover prior session context without re-deriving.
Full reference: docs/mcp.md.
Every feature lives in one of three states:
- canonical — checked out in the main repo dirs. Exactly one canonical feature at a time across all repos. This is the only place to run code. Worktrees are passive branch storage; never
cdinto them to launch the app. - warm — sits in a numbered slot at
.canopy/worktrees/worktree-N/<repo>/. Slot identity (worktree-1,worktree-2, ...) is stable across feature swaps; feature occupancy is transient. Capped by[workspace] slots = Nin canopy.toml (default 2). - cold — branch only, no checkout. Cheap and unlimited.
switch(Y) is the single primitive that moves features between these states:
- Active rotation (default): previous canonical evacuates into a warm slot via
stash → checkout → pop. Instant to switch back. - Wind-down (
release_current=True): previous goes cold with a feature-tagged stash.
When the cap fires, switch returns BlockerError(code='worktree_cap_reached') with explicit fix_actions (evict a specific slot, wind down the current focus, raise the cap). No silent eviction.
Full design: docs/concepts.md §4.
Every error is a typed payload — agents don't parse stderr.
{
"status": "blocked",
"code": "drift_detected",
"what": "branches don't match feature lane 'auth-flow'",
"expected": {"branches": {"api": "auth-flow", "ui": "auth-flow"}},
"actual": {"branches": {"api": "auth-flow", "ui": "main"}},
"fix_actions": [
{"action": "switch", "args": {"feature": "auth-flow"}, "safe": true,
"preview": "promote auth-flow to canonical across all repos"}
]
}The agent reads fix_actions[0], checks safe: true, calls mcp__canopy__switch(feature="auth-flow"). The CLI renders the same payload as colored multi-line output via cli/render.py. Single source of truth, two surfaces.
canopy init installs the using-canopy skill at ~/.claude/skills/using-canopy/SKILL.md (per-user) and writes .mcp.json so Claude Code spawns the canopy MCP server in this workspace. The skill teaches the agent when to reach for canopy:
- See a feature alias or issue ID as the first non-trivial token? Call
feature_resume(alias)before doing anything else. - About to
cd <repo> && command? Usemcp__canopy__run(repo, command)or the feature-aware verb. - About to
gh api graphqlfor thread mutations? Useresolve_thread/reply_to_thread/commit --address --resolve-thread. - See an unfamiliar error? Call
doctorfirst.
Opt-in extra skills via canopy setup-agent --skill <name>:
augment-canopy— teaches the agent thecanopy.toml [augments]schema so it can configurepreflight_cmd,review_bots,auto_resolve_threads_on_address, etc. on the user's behalf.
GitHub access works via the gh CLI fallback when no github MCP server is configured. Linear works via OAuth (browser flow once, no API key).
The same primitives are available as a CLI. Daily commands:
canopy resume <feature> # session start — print the brief
canopy switch <feature> # focus — promote to canonical
canopy status # workspace-wide rollup
canopy state <feature> # 9-state + next_actions
canopy triage # cross-feature priority
canopy preflight # run hooks across the feature's repos
canopy commit -m "..." # commit across repos at once
canopy push # push across repos at once
canopy slots --rich # dashboard data
canopy doctor # diagnose drift / stalenessThe CLI and MCP server are thin wrappers over the same actions — canopy state X and mcp__canopy__feature_state(feature='X') return identical bytes. There's also a VSCode extension (source at ashmitb95/canopy-dashboard) reading the same state the agent reads.
Full CLI reference: docs/commands.md.
- Concepts — the action framework, agent context contract, 9-state machine, slot model, resume brief
- Agents — skill install, integration recipes, the agent tool loop
- Commands — full CLI reference
- MCP — server tool list, client transports (stdio + HTTP/OAuth), gh fallback
- Workspace —
canopy.toml,features.json, state files - Architecture — module boundaries, runtime pathways, state files
- Providers — issue-provider abstraction (Linear, GitHub Issues)
git clone https://github.com/ashmitb95/canopy.git ~/projects/canopy
cd ~/projects/canopy
pip install -e ".[dev]"
pytest tests/ -v # 857 tests, ~225s, all use real temporary Git reposMIT