willow-mcp
Agent-neutral MCP server with persistent memory and task execution. Works with any MCP client: Claude Code, Claude Desktop, Cursor, or any custom agent that speaks stdio MCP.
Corpus memory: the whole Willow constellation — the code, human, and collaboration corpora assembled and queryable — lives in the sovereign
willow-composerepo. The hub calls it; it doesn't live here (keeping the hub lean).
Three storage backends in one server:
- SOIL store — SQLite-backed local key/value store with full-text search and soft delete
- Postgres knowledge base — multi-keyword searchable knowledge graph
- Kart task queue — sandboxed task executor for shell commands and scripts
Every tool call is authorized via a filesystem-based manifest ACL — no ACL database, no external auth service. See Authorization.
Install
pip install willow-mcp
Requires Python 3.11+. Postgres is optional — SOIL store works standalone.
willow-mcp-init # scaffold $WILLOW_HOME (idempotent)
willow-mcp-compile --force # compile manifests (use product venv — see below)
willow-mcp-sign-seed hanuman # ratify home seed + detach-sign (operator terminal only)
willow-mcp-compile-persona hanuman # seed → personas/hanuman.md (AS-7)
Local sandbox (one command)
To take a fresh clone to a working stdio server — venv, editable install,scaffolded $WILLOW_HOME, compiled manifests, and (best-effort) a localPostgres with every table created — run:
bash scripts/sandbox-bootstrap.sh # idempotent; ends with a live diagnostic_summary
On a bootstrapped sandbox the schema mappings for the tables the script itselfjust created are auto-confirmed (so task_* and knowledge writes workimmediately), behind three guards: existing mapping artifacts are nevertouched, every field must resolve exact, and the live columns must equal therepo's own DDL — an adopted/foreign database always falls through to thehuman schema_confirm_mapping path (see src/willow_mcp/sandbox_confirm.py).
It scaffolds a repo-local, gitignored .willow/ so the sandbox never touchesyour real fleet state. Postgres is optional and handled best-effort (the SOILstore stands alone); pass WILLOW_SKIP_PG=1 for a SOIL-only stand-up, orWILLOW_PG_BOOTSTRAP_ROLE=1 on a bare cluster where your OS user has noPostgres role yet.
A fresh Postgres database needs willow-mcp's tables. On a shared fleet DB theyalready exist; on a standalone install, apply the DDL indocs/schema/ (knowledge, agents, routing_decisions,tasks — the four diagnostic_summary checks for, plus frank_ledger for theFRANK governance chain). The bootstrap script applies all of them for you. Eachknowledge/tasks write path stays locked behind schema_confirm_mappinguntil you confirm the mapping once.
The fleet (one command up)
sandbox-bootstrap.sh proves this server works alone. Two sibling packagesattach to it — jeles, the verified-corpusorgan this package already depends on for institutional search, andnestor, which mirrors its hash-chainedledger into FRANK — and standing all three up together is a different claim:
bash scripts/fleet-standup.sh # idempotent; ends with six seam checks
It runs the sandbox bootstrap, installs the jeles and nestor checkoutseditable into that same venv (across two venvs their imports silentlyresolve to whatever PyPI last published), seats nestor in the gate, writes$WILLOW_HOME/fleet.env, and then checks that the seams actually join:
| Seam | What crosses |
|---|---|
| co-install | one venv, all three resolving to the checkouts |
| shared SOIL store | jeles' corpus and this server's Store on one SQLite file |
| gap forward | a jeles corpus miss → gap_log, through the manifest ACL |
| institutional search | this server's willow_institutional_search → jeles' ~60 collections |
| FRANK mirror | a nestor ledger entry → frank_append → the hash chain |
| nugget bridge | a jeles nugget → nestor, as a draft — never a seal |
Point JELES_REPO / NESTOR_REPO at the checkouts if they are not siblings ofthis one. Re-check any time with .venv/bin/python scripts/fleet_seams.py(--json for machine output); every seam it reports as passing was exercisedby writing real data through the real path, because a seam that is onlyimported is a seam that has not been tested.
PATH note:
~/.local/bin/willow-mcpis often the fleet shim (sap_mcp.py), not thisproduct. Use the venv binary from wherever you ranpip install willow-mcp(orpip install -e .in a clone) — e.g..venv/bin/willow-mcp-compile --forceor.venv/bin/willow-mcp compile-agents --force— not a barewillow-mcponPATH.
Runtime layout: docs/design/product-layout.md (LOCKED).
Tools
| Tool | Description |
|---|---|
store_put |
Write record (JSON object) to SQLite store |
store_get |
Read record by record_id |
store_list |
List all records in a collection |
store_update |
Update an existing record |
store_search |
Multi-keyword AND search in a collection |
store_delete |
Soft-delete a record by record_id |
store_search_all |
Search across all collections |
store_collections |
List the SOIL collections you can see (narrowed to your store_scope) — learn the collection names without running a search |
store_purge_collection |
Bulk soft-delete every record in a collection (e.g. leftover test/scratch data). Reversible (archive-don't-delete — the store.db is kept); requires confirm=<collection name> and stays within your store_scope |
store_stats |
Per-collection live-record counts (within your store_scope), largest first, plus store-wide totals — the numeric companion to store_collections for spotting a bloated or polluted collection |
knowledge_ingest |
Add a knowledge atom (requires a confirmed schema mapping — see schema_confirm_mapping) |
knowledge_search |
Multi-keyword search in the Postgres knowledge base |
kb_at |
Fetch a single knowledge atom by ID |
kb_promote |
Change an atom's domain (requires a confirmed schema mapping) |
knowledge_flag |
Attach an integrity flag to an existing atom (knowledge_curate; tags-based, idempotent) |
knowledge_retract |
Tombstone an atom in place — hidden from default search, still readable via kb_at (knowledge_curate) |
kb_journal |
Add a journal-domain knowledge atom (requires a confirmed schema mapping) |
kb_startup_continuity |
Fetch atoms tagged/domained for startup continuity |
schema_confirm_mapping |
Confirm (optionally correct) a table's column mapping, unlocking its write tools. preview=True dry-runs it and renders a sample row so you can see what each field actually resolves to before trusting a name match — see docs/design/schema-adaptation.md |
gap_log |
Log or bump a "we don't know this yet" entry (fleet-wide backlog, SOIL-only, no Postgres needed) — see docs/design/gap-backlog.md |
gap_list |
List gaps, most-asked first — filter by topic and/or status (open/resolved/promoted) |
gap_resolve |
Mark a gap as being worked or answered — bookkeeping only, does not write to the knowledge base |
gap_delete |
Soft-delete a single gap by id — clear junk/test entries without disturbing real gaps. Reversible (archive-don't-delete) |
gap_purge_topic |
Soft-delete every gap under an exact topic in one call — bulk cleanup without the per-call rate limit. Promoted gaps (they point at a landed atom) are left intact; requires confirm=<topic> |
gap_promote |
Turn a resolved gap into a knowledge atom. Requires answer, at least one source, and confirmed_by; writes through the same schema-confirmation gate as knowledge_ingest and closes the gap out |
nest_scan |
Walk a drop folder, extract + classify its files by meaning, and write a canonical SQLite Nest DB. Returns counts only; dry_run=True (default) reports without writing — see docs/NEST.md |
nest_status |
Counts for a seeded Nest DB — sources by status, fragments by type, topical categories by size. Structure only; filename-labels are walled and counted as uncategorised |
nest_digest |
A one-page Markdown map of a Nest DB — the walled view (person names, the date timeline, and filenames suppressed). The full digest is a local-CLI affordance only, never returned over MCP |
nest_promote |
Promote a Nest's structure — counts, curated category names, redacted secret kinds, never content — into the knowledge base via the same core write as knowledge_ingest. dry_run=True returns the atoms that would be promoted |
nest_intake_scan |
Live drop-folder router: classify new files in a drop zone by filename into a track and stage a review queue. Non-destructive — nothing moves until nest_intake_file |
nest_intake_queue |
List the pending review queue with the track the classifier predicted for each file |
nest_intake_file |
File a staged item: move the file to its predicted track's destination, or override_dest to correct it. An override feeds the correction counter |
nest_intake_skip |
Skip a staged item — leave the file, record the decision |
nest_intake_flags |
List open rule-delta flags — patterns overridden often enough that the classifier proposes a rules change (a human ratifies) |
task_submit |
Submit task to Kart queue |
task_status |
Check task status |
task_list |
List pending tasks |
agent_route |
Route a task to a target agent, recording the decision |
agent_dispatch_result |
Record the result of a dispatched agent task |
dispatch_send |
Create dispatch packet (meta.json + assignment.md) |
dispatch_read |
Read dispatch assignment and status |
dispatch_list |
List dispatch packets |
dispatch_accept |
Specialist accepts packet (pending → working) |
handoff_write_v4 |
Complete work — handoff.json + closeout.md |
handoff_read |
Read handoff for a dispatch |
verify_handoff |
Orchestrator verifies completion |
agent_clear |
Clear specialist for next packet |
session_read |
Read thin session state file |
fleet_status |
Return the canonical charter fleet.json roster plus Postgres drift diagnostics |
fleet_health |
Task queue counts by status, live worker heartbeats, and whether the queue is stranded |
frank_read / frank_verify |
Read and verify the existing Postgres FRANK hash chain |
frank_append |
Append an established-shape FRANK event (separately gated) |
envelope_apply |
Match an active constitutional grant and write its FRANK citation before returning authority |
grove_list_channels |
List active Grove channels (name, type, description) |
grove_get_history |
Message history from a channel, with since_id polling and limit (max 200) |
grove_search |
Case-insensitive substring search across Grove messages, optionally scoped to one channel |
grove_watch / grove_watch_all |
Non-blocking poll for new messages in one channel, or many at once via a {channel: since_id} cursor map |
grove_get_thread |
A message plus its flags and all its replies |
grove_bus_receive |
Structured bus messages addressed to an agent (or broadcast), priority-ordered |
grove_inbox |
Fleet inbox: @mentions, bus messages addressed to you, and your dedicated #<agent> channel, merged and deduped |
grove_flagged |
Messages carrying a given flag (needs-reply/starred/read/urgent/resolved), across all channels or one |
grove_get_identity |
Your own Grove identity — app_id, resolved grove_sender, registered role/display name |
grove_agents |
Fleet agents by most-recent HEARTBEAT, newest first |
grove_fleet_status |
Presence plus what each agent is doing — ui_state, a content peek, and whether it's blocked on a reply |
grove_human_required |
The human-required queue: work that pauses automation until a person acts, priority-first |
grove_send_message |
Post to a channel (creates it if missing). sender defaults to your resolved grove_sender, never a literal "Auto"; posting as a different identity requires grove_relay |
grove_reply |
Reply in a thread; clears the parent's needs-reply flag |
grove_flag / grove_unflag |
Set or clear a flag on a message |
grove_bus_send |
Post a structured, addressed, typed, prioritized bus message (COMMAND/EVENT/HEARTBEAT/…) |
grove_ack |
Acknowledge a received message; clears needs-reply, marks it read |
grove_heartbeat |
Broadcast "I am alive" to #general |
context_save |
Save ephemeral per-identity working state under a key, with an optional TTL (SOIL-backed, no Postgres) |
context_get |
Read a saved context; expired (and purged) once its TTL passes |
context_list |
List your saved context keys and expiry times (expired ones skipped) |
context_expire |
Delete a saved context before its TTL |
integration_list |
The integration ledger: every outbound adapter, live or declared stub, with credential source (never the value) |
integration_status |
Offline readiness readout for one adapter — live/stub, credential presence, and whether the egress gate would pass. No network call |
integration_call |
Call an external API through a registered adapter — behind the three-key egress gate, keyed on integration_net (own line, never implied by task_net or full_access) |
willow_web_search |
Open-web search with the results run through external-guard — the guarded replacement for a client's native web tool. web_read + web_net + consent.internet + a live lease |
willow_web_fetch |
Fetch one URL through the destination guard: the host is resolved and every address tested (not just literals), every redirect hop re-checked, body scanned by external-guard and sandwich-wrapped. Returns the redirects chain actually followed |
willow_institutional_search |
Fan a query across jeles' registered institutional/academic collections (arXiv, PubMed, Crossref, OpenAlex, …) — citable sources rather than open web. Same web_read line as the two above, so one grant covers all three |
federation_discover |
Shadow-IT scan: .mcp.json files not yet owned by the ratified registry. Read-only, never connects |
federation_list_servers |
List every operator-ratified downstream MCP server: id, launch command, the environment-variable names it receives (never values) |
federation_call |
Call one tool on one ratified downstream MCP server — behind the fourth egress class, mcp_federation (own line), a per-downstream-tool namespaced grant, and the operator's ratification ceiling |
receipts_tail |
Read your own most-recent tool-call receipts — a self-audit trail scoped to your app_id |
whoami |
Report your own identity and effective permissions — app_id, role, permission groups, the resolved set of tools you can call (minus deny_tools), and your store_scope. Ungated, like diagnostic_summary |
diagnostic_summary |
Self-check: store/Postgres/schema/manifest/bindings/worker/consent/egress-lease/env health, with a verdict and named fixes. Ungated — see below |
Egress needs three keys
First run: willow-mcp-init then willow-mcp onboard --project-root <repo> --enable-internet.See docs/OPERATOR-ONBOARD.md. Use wmc or the product venvbinary — not bare willow-mcp on PATH when the legacy sap_mcp.py server is installed.
A task that reaches the network requires all three standing keys plus aone-use signed task envelope. Any missing element denies before shell launch:
| Key | Question | Where | Turned by |
|---|---|---|---|
task_net |
May this app ever request egress? | mcp_apps/<app_id>/manifest.json |
operator, granted once |
consent.internet |
Is egress permitted right now? | $WILLOW_HOME/settings.global.json |
operator, flipped freely |
| egress lease | For this app, until when? | mcp_apps/_net_leases/<app_id>.json |
operator, willow-mcp grant-net, expires on its own |
| signed task envelope | This submitter, exact task, scope, expiry, and nonce? | tasks.network_authorization |
operator, willow-mcp sign-net-task, one use |
// $WILLOW_HOME/settings.global.json — the off switch
{ "consent": { "internet": false, "cloud_llm": false } }
$ willow-mcp onboard --project-root ~/github/willow --enable-internet
$ willow-mcp run-net myapp --task-file task.sh --ttl 30m # grant + sign + queue
$ willow-mcp worker --lane fast --once # drain the queue
$ willow-mcp doctor --app-id myapp # copy/paste fixes
$ willow-mcp grant-net myapp --ttl 30m --reason "publish the release"
$ willow-mcp sign-net-task myapp --task-file task.sh # keys: setup-egress / ~/.config/willow-mcp/egress/
$ willow-mcp net-status
$ willow-mcp revoke-net myapp
Open-web egress (willow_web_search / willow_web_fetch /willow_institutional_search) is the same three-key gate, keyed on web_netinstead of task_net (web_egress.egress_denial). Standing it upfor local/dev use is the same three grants — allow-permission myapp web_net,consent set internet true, grant-net myapp --ttl 30m — done separately, inorder, each its own command. dev-net does the same three, in one:
$ willow-mcp dev-net myapp --ttl 30m --reason "local dev"
It is not a new bypass path — it calls exactly the same operator-onlyadmin functions allow-permission / consent set / grant-net already call,so there is nothing new to audit, and it stays local-CLI-only exactly likethem: no MCP tool can reach it, and the PreToolUse self-grant guard blocks anagent from invoking it via Bash the same way it blocks a bare grant-net.Consent needs an interactive operator terminal only when it is not alreadygranted — a repeat run needs no TTY at all — and the whole command refusesoutright when WILLOW_MCP_STRICT_TRUST_ROOT is set (a hardened posture thisshortcut isn't for) unless you pass --force. It prints the lease's expiry,the exact grant-net command to renew it, and the full four-key diagnosticbelow.
web_egress.egress_status(app_id) is the read-only counterpart: all fourkeys — manifest permission, operator consent, egress lease, strict trustroot — reported at once instead of stopping at the first closed lock the waythe gate itself (web_egress.egress_denial) does. dev-net prints it aftergranting so you see every key's state in one place, not just the one you justfixed.
Setting consent.internet to false stops network tasks submitted throughtask_submit, immediately, without editing a single manifest. task_net is acapability (rarely granted, deliberately excluded from full_access);consent.internet is a switch; the lease is a time-boxed grant that an agentmay ask for and never issue. No MCP tool can mint one — grant-net is local CLIonly, exactly like confirm-binding. An agent may request egress and may nevergrant it to itself. sign-net-task requires an interactive host terminal andan Ed25519 private key outside WILLOW_HOME/WILLOW_STORE_ROOT; no MCP tool orworker receives that key.
At execution, Kartikeya treats # allow_net only as a request and calls thewillow-mcp host authorizer. The authorizer rechecks capability, consent, lease,strict trust-root state, signature, exact normalized task hash, expiry, and theone-use nonce. Direct task-table inserts and legacy rows have no envelope, sothey remain runnable only as network-isolated work (B-37).
Deployment is deliberately explicit: applydocs/schema/tasks-add-network-authorization.sql, reconfirm the tasks mapping,set WILLOW_MCP_EGRESS_PUBLIC_KEY to an operator-owned Ed25519 public PEM thatthe worker cannot write, set a worker-writable WILLOW_MCP_EGRESS_REPLAY_ROOT,and enable WILLOW_MCP_STRICT_TRUST_ROOT=1. The matching private key must remainoutside WILLOW_HOME and WILLOW_STORE_ROOT; only the interactivesign-net-task command reads it. Until those conditions hold, network tasks denyclosed while ordinary isolated tasks remain unchanged.
Consent and leases are both read fail-closed: a missing file, an unparseablefile, a non-boolean value ("true", 1), a lease past its deadline, a deadlinewith no timezone, or a lease record naming a different app than the file it sitsin — all read as denied. Absence is not consent, and a name is not an identity.Runtime tools only read consent. An operator can mutate it through the local,interactive-only willow-mcp consent set <key> <true|false> command; the commandatomically writes canonical policy and mirror and appends a metadata-only auditrecord. willow-mcp consent reconcile keeps the canonical value and repairs itsmirror. If the two disagree, diagnostic_summary reports both rather thanguessing intent (B-30).
Governance continuity
willow-mcp roster status compares the constitution repo's canonicalfleet.json with Postgres. willow-mcp roster sync is interactive-only andidempotently inserts or updates charter rows; unknown database rows are reportedas contested and preserved, never silently deleted.
Constitutional envelopes are loaded read-only from$WILLOW_HOME/constitutional/pre-approved.json and checked againstsyscall-table.json in the same directory (WILLOW_ENVELOPE_REGISTRY /WILLOW_SYSCALL_TABLE override either). willow-mcp-init seeds an emptystarter registry and a real syscall table there on first run — the registrystarts empty on purpose; it's the operator's own ratified grants to issue,never shippable content. envelope_apply validates issuer, grantee, verb,exact bounds shape, revocation, expiry, and FRANK-derived quota. Both grantsand faults append an envelope_citation to the existing frank_ledgerbefore authority is returned.
gates — every gate, on/off, egress-lease shaped
Diagnosing a denial today means knowing which of a dozen-plus gates to checkand which file or CLI command controls it. willow-mcp gates shows all ofthem at once, each rendered the way the egress lease already rendersitself — on/off, plus how long the "on" is good for. Run it in a realterminal and it's interactive — arrow keys / j-k to move, enter/space toactually flip the highlighted gate, no second command to copy anywhere:
$ willow-mcp gates # interactive TUI (every app under mcp_apps/)
$ willow-mcp gates myapp # interactive TUI, scoped to one app
$ willow-mcp gates --serve # live local HTML dashboard, working buttons
$ willow-mcp gates --serve --port 9000 --host 127.0.0.1
$ willow-mcp gates --static # one-shot text printout instead of the TUI
$ willow-mcp gates --html # writes ./willow-gates.html, a read-only snapshot
$ willow-mcp gates --json # raw rows, for scripting
--static/--json/--html are unchanged from before and still the rightchoice for scripting, CI, or a file you want to keep — --static is alsowhat runs automatically whenever stdout isn't a real terminal (piped,redirected), so nothing here breaks existing scripts.
The interactive TUI and --serve's live dashboard share one action layer(gates_actions.py) with the CLI subcommands below — pressing a row (orclicking its button) calls the exact same functions allow-permission/grant-net/confirm-binding do, nothing new. --serve binds127.0.0.1-only by default; it's a mutation-capable local admin surfacewith no authentication of its own, so widening --host prints a warningrather than doing it quietly. The one exception is the worker row'saction: it drains the queue once (like worker --once), never launchesthe persistent daemon — that would block the TUI/dashboard forever.
Manifest permission groups — which had no CLI before, only hand-editingmanifest.json or regenerating it via compile-agents — get their ownpair, usable standalone or as what the TUI/dashboard call underneath:
$ willow-mcp allow-permission myapp store_read
$ willow-mcp deny-permission myapp store_read
Both are local-CLI-only, never MCP tools, for the same reason grant-netisn't: an agent must never be able to grant itself a permission it was justdenied — and that boundary holds for the TUI and --serve too, sinceneither is reachable except by an operator running them on the host thatowns $WILLOW_HOME. consent.* rows never show a command or a workingbutton — willow-mcp only reads that policy (see above) — andstrict_trust_root / severance / human-orchestrator attestation areenvironment variables read once at process start, so their rows name theenv var to set and restart with, rather than pretending a live toggleexists.
task_net and integration_net both show up as their own capability rows(neither is folded into full_access), and both are authorized by the sameper-app egress lease below them — one grant-net/revoke-net covers Kartsandbox egress and server-process integration calls together, since a leaseis scoped to the app, not to which capability is asking.
Every row also carries a state_label in context instead of a bare ON/OFF —GRANTED, ALLOWED, ACTIVE, CONFIRMED, RUNNING, ENABLED (and their opposites) —and a category (egress & network / system / identity / permissions) thatthe TUI and both HTML pages group by. The HTML pages open on the egresstab — the smallest group, and the one with a clock — with a summary stripabove the tabs for at-a-glance state, and render the ~20-row permissionsgroup (routine, rarely touched) as a compact list rather than large cards,instead of one flat scroll of everything at once.
tree — the integration seam for a real dashboard
docs/design/*.html sketches a client UI as a tree — trunk (overallhealth), sap (task queue), canopy (agent fleet), roots (SOIL store), rings(schema-mapping confirmation), leaves (knowledge atoms), litter (activitylog), and stomata (the gates above). willow-mcp tree is what makes thatreal: one call that returns every part in that same shape, instead of adashboard assembling fleet_status/fleet_health/kb_startup_continuity/receipts_tail/gates itself.
$ willow-mcp tree myapp # short text summary
$ willow-mcp tree myapp --json # full data, for a real dashboard to consume
It's a thin CLI wrapper over willow_mcp.tree_view.build_tree(app_id),which a Python dashboard can also import and call directly. sap, canopy,and leaves go through the same @_guarded MCP tool functions a clientwould reach over the protocol — gating, rate limiting, and receipt loggingall still apply — and degrade to {"error": "postgres_unavailable"} with nodatabase configured, same as those tools already do. roots, rings,litter, and stomata read local SQLite/filesystem state directly, so theywork with no Postgres at all.
The residual, stated plainly
On a host where the agent and the MCP server run as the same uid, the agent canwrite the very files that authorize its egress. Leases make a self-grant expireand leave a record, and the PreToolUse hook blocks the obvious attempts — butthe operating system is not stopping it. diagnostic_summary names exactly whichkeys the running process could forge, under checks.net_lease.self_writable.
The control is ownership. Put mcp_apps/ and mcp_apps/_net_leases/ under a uidthe agent does not run as, then:
$ export WILLOW_MCP_STRICT_TRUST_ROOT=1 # refuse egress when the keys are self-writable
Strict mode is off by default because turning it on before that separationexists would deny egress on every current install. This is tracked as B-32 indocs/BUGS.md, and as issue #231(dedicated low-privilege agent uid) and #232(store .db OS-level permission enforcement, which depends on it); requestingegress and confirming it are separate authorities, and until the filesystemsays so, only convention does.
The concrete runbook for actually standing up the separated deployment —including a serve-mode shape where the agent has no local account on thehost at all — is docs/deploy/dedicated-uid-deployment.md.diagnostic_summary's checks.uid_separation (and doctor's uid separation: line) report the plain ownership fact — does the trust rootbelong to a different account than the one asking — next to, never insteadof, the self_writable/hardened checks strict mode actually enforcesagainst.
Integrations (outbound adapters)
integration_call lets the server process call external HTTP APIs throughregistered adapters — a second egress lane, beside the Kart sandbox's. It usesthe same three-key gate, but keyed on its own capability, integration_net:the server egresses as its own uid with its own filesystem view, a strictly moreprivileged lane than the network-namespaced sandbox, so task_net never impliesit (and vice versa). integration_call itself is also excluded fromfull_access — even the attempt surface is opt-in.
Adapters are earned, not scaffolded. Four are live (github,huggingface, jeles, utety); six are declared stubs (gmail, slack,notion, google-drive, datadog, jira) that refuse fail-closed, eachnaming what it needs and what earns its implementation. integration_list isthe ledger (it reports each adapter's live/stub status) — seedocs/design/integrations.md for the earn rule.
Credentials resolve environment-variable-first (e.g. WILLOW_GITHUB_TOKEN,then GITHUB_TOKEN), then the vault under integration/<name>/token. No toolever returns a credential — only its source.
$ willow-mcp-integrations list # the ledger, live + stubs
$ willow-mcp-integrations check github --app-id myapp # offline: creds? keys? no network call
$ willow-mcp-integrations set-token github # prompted + hidden, stored in the vault
Federated MCP (willow-mcp as a client)
willow-mcp can call tools on other MCP servers spread through the fleet —see docs/design/federated-mcp-gating.mdfor the full decision record. A stdio MCP server willow-mcp spawns itself isfork/exec at its own uid — a fourth, strictly-more-privileged egress classbeside the Kart sandbox, integration adapters, and open-web HTTP — so it getsits own capability, mcp_federation, and its own consent key,consent.federation, on the same three-key shape as the other three lanes.
A federated call is authorized only where two ceilings agree: thecaller's manifest must grant the namespaced permissionmcp:<server_id>:<tool> — gated per downstream tool, never per server, so agrant never silently widens as a downstream server grows new tools — andthe server must be in the operator-ratified registry. Neither alone issufficient: a full_access manifest gains no new surface just because a.mcp.json appears on disk, and a ratified server's advertised tools do notthemselves grant anything to a caller whose manifest never named them.
$ willow-mcp allow-permission myapp mcp_federation
$ willow-mcp allow-permission myapp 'mcp:<server_id>:<tool>'
$ willow-mcp consent set federation true
$ willow-mcp grant-net myapp --ttl 30m --reason "call the gazelle MCP server"
federation_discover is read-only inventory (which .mcp.json files existthat the registry does not yet own — the shadow-IT question); ratifying adiscovered server into something federation_call can reach is an operatoract, not an MCP tool, the same way an egress lease is. Every downstream toollisting and every call result is run through external-guard before itreaches a caller — a downstream server's tool names and descriptions areuntrusted input, scanned at listing time as well as at call time.
Remote downstream servers (streamable HTTP)
A ratified entry with transport: "streamable-http" (or http) and a url isdialled over the network instead of forked as a subprocess. auth_token_envoptionally names an environment variable holding a bearer token — the name,never the value, exactly as env_keys works.
The destination is checked with the same resolve-don't-pattern-match guard theopen-web lane uses, at ratification and again at every connect. Once is notenough: the registry records a URL, but DNS decides where a name points, so anentry ratified against a public host can be aimed at loopback or cloud metadatalater without the registry changing at all.
$ export FED_PEER_TOKEN=... # out of band, never in the registry
$ willow-mcp ...ratify... --transport streamable-http --url https://peer.example/mcp
You cannot federate to localhost over HTTP. Loopback, link-local(169.254.0.0/16), and private space are refused, which is the guard working —use stdio for a local downstream, which is what it is for. The five egresslocks (mcp_federation permission, the per-downstream-tool grant, ratification,consent.federation, and a live egress lease) apply to a remote peer exactly asthey do to a spawned one.
Signed downstream links (optional)
When the downstream is another willow-mcp running withWILLOW_MCP_ENFORCE_BINDING=1, a ratified entry can carry an identity so thisserver checks in and signs every outbound call — the willow-gate binding, in theoutbound direction. Three fields, attached at ratification:
| field | meaning |
|---|---|
signing_agent_id |
the app_id this server presents downstream |
signing_secret_env |
the name of an env var holding the hex secret — never the value |
signing_trust_level |
the tier claimed at check-in (0–4; the downstream caps it at your registered ceiling) |
The downstream operator runs willow-mcp register-agent <signing_agent_id> andhands you the minted secret out of band; you export it under the name you choseand ratify the link. The registry never holds the secret — it names the variableand the value is read from this process at connect time, exactly as env_keysworks for a child's environment.
A link that asks to sign and cannot — secret unset, malformed, under 32 bytes, ora check-in the downstream refuses — fails closed: it raises rather thanconnecting unsigned, and the config is resolved before any child process isspawned. An entry with no signing_agent_id is unsigned and behaves exactly asit always has.
What this buys depends on who you are calling. Against a downstream this processspawns it is least-privilege and audit — the downstream's tier ceilingapplies to you, its receipt log attributes your calls, and check-out reconcileswhat you declared against what its log recorded. It is not authentication there,because you already chose that child's binary and environment. It becomesauthentication against a peer this process did not start — which needs anon-stdio transport this client does not yet implement.
Repo hygiene sweep
A read-only survey of every git repo under a root — diverged and unpushedbranches, untracked source files, tracked-but-dirty runtime state, branchlitter, and merged worktrees that are safe to reap. It never fetches, pulls,commits, or deletes; cleanup is offered as a report line, never performed.
$ willow-mcp repo-sweep --root ~/github
[repo-sweep] 33 repos under /home/you/github, 4 with findings, 29 clean
willow-memory/.willow (master)
- 10 untracked source files: skills/brainstorming.md, skills/debugging.md …
safe-app-store-public (master)
- 22 tracked files dirty (runtime state in git?)
--emit-flags also raises one SOIL flag per repo with findings, intowillow_flags (inside the willow_* store_scope, so no manifest change).Flag ids are stable per repo, so a weekly run updates rather than accumulates.--json for machine-readable output; --max-depth (default 2) covers both aflat <root>/<repo> tree and an org-shaped <root>/<org>/<repo> one.
To run it weekly — Mon *-*-* 04:00:00, Persistent=true so a sweep missedwhile the machine was off still runs:
$ willow-mcp repo-sweep-service install # writes .service + .timer
$ systemctl --user enable --now willow-mcp-repo-sweep.timer
Like worker-service, the installer only manages unit files anddaemon-reload — it never starts, stops, enables, or disables anything. Thatsecond line is yours.
Running the task worker
task_submit only queues a task. A worker process executes it, sandboxed withbubblewrap. Without one running, tasks stay pending forever:
willow-mcp worker --lane fast # daemon; polls until stopped
willow-mcp worker --once # drain what's queued, then exit
The engine is kartikeya, a harddependency — a base pip install willow-mcp ships a working drainer.
--lane is fast or batch (env fallback: WILLOW_WORKER_LANE, thenkartikeya's own KART_WORKER_LANE, then fast). The two aren't justseparate queues — batch forces production mode: it refuses to start onkartikeya's generic/vendored sandbox default (unless you also pass--allow-generic-sandbox) and requires a real Postgres connection. fastonly enters production mode if you pass --require-postgres explicitly.Fast-lane concurrency (--slots) defaults from kartikeya's KART_FAST_WORKERSenv var, or 3 if unset.
A running worker publishes a heartbeat under $WILLOW_HOME/worker_heartbeat/,which fleet_health reads back:
{"pending": 3, "running": 0, "completed": 12, "failed": 0, "total": 15,
"workers": {"alive": 0, "workers": [{"pid": 4242, "state": "dead", ...}]},
"stranded": true}
stranded: true means there is pending work and no live worker — thedistinction between "queued, it'll run" and "queued, nothing is listening."diagnostic_summary raises the same condition as a named worker problem. Aworker is alive (ticking), stale (process up, loop wedged), or dead (pidgone). Heartbeats are advisory telemetry: no permission decision reads them, andreads verify the recorded pid is a live local process, so a forged file naming adead pid reads dead.
knowledge_search/kb_at/kb_startup_continuity and fleet_status adapt towhatever your host database's real columns are named — seedocs/design/schema-adaptation.md.knowledge_ingest/kb_ingest/kb_journal/kb_promote refuse to write(unconfirmed_schema) until you've reviewed and confirmed that mapping viaschema_confirm_mapping — the schema-confirm skillwalks through that.
Every tool requires an app_id param, checked against a manifest at$WILLOW_HOME/mcp_apps/<app_id>/manifest.json — see Authorization.The one exception is diagnostic_summary, which is intentionally ungated: itis the tool you reach for when your manifest or database is misconfigured, sogating it behind a permission would make the diagnostic itself undiagnosable. Itdiscloses only the caller's own configuration — never fleet rows or vaultsecrets — and in serve mode still requires a confirmed identity and redactsabsolute filesystem paths.
MCP config
Repo-local configs (.cursor/mcp.json, .mcp.json) wire willow-mcp pluscodebase-memory-mcp for graph-augmented code search while developing thispackage. Install the CBM binary to ~/.local/bin/codebase-memory-mcp, thenindex this repo (project: home-sean-campbell-github-willow-mcp).
willow-mcp's entry points at a repo-local venv rather than a bare python3 —your host interpreter may not have pip or the mcp package installed (amissing import here crashes the stdio server before the handshake, whichshows up as a client-side reconnect failure). Set it up once per clone:
python3 -m venv .venv
.venv/bin/python3 -m pip install -e .
Minimal single-server config (path is relative to the repo root, so thisworks unmodified on any clone once the venv above exists):
{
"mcpServers": {
"willow-mcp": {
"type": "stdio",
"command": ".venv/bin/python3",
"args": ["-m", "willow_mcp"]
},
"codebase-memory-mcp": {
"type": "stdio",
"command": "codebase-memory-mcp",
"args": []
}
}
}
Point WILLOW_PG_DB / WILLOW_STORE_ROOT at your host fleet store when youneed Postgres knowledge or shared SOIL data.
This config is dev-only — never point it at fleet secrets. No WILLOW_HOMEoverride means it defaults to ~/.willow; no WILLOW_PGP_FINGERPRINT meansmanifests are honored unsigned (#183 is opt-in); no WILLOW_MCP_ENFORCE_BINDINGmeans stdio app_id is trusted as claimed. A 2026-07-31 red-team pass foundexactly this gap live (issue #235, B-47): this project-repo desk and anoperator's separately-configured, hardened ~/.cursor/mcp.json fleet desk aretwo different trust postures under the same product, and it's easy to "testgreen" on the unhardened one while believing you've verified the hardenedone. If you need PGP enforcement or binding while developing this repo, setthose env vars in this file's env block yourself — willow-mcp intentionallyships no default here, so an unconfigured clone fails closed on capabilitygrants rather than silently inheriting someone else's fleet trust.
Version line. willow-mcp is the current substrate the fleet consumes. Itsits at the head of a lineage of distinct machines — each its own spec, notrebadges of one another:
willow-1.7→willow-1.9— earlier production lines;willow-1.9isarchived (April–May 2026 era).willow-2.0— a distinct, larger-surface fleet server; now legacy /migration source, not the current stack.willow-mcp— the current substrate: a re-scoped re-implementation ofwillow-2.0's SOIL / knowledge / dispatch core.
willow-mcp re-implements that core as a standalone product with a redesigned,smaller surface — not a drop-in copy ofwillow-2.0's tool API. Many toolswere renamed in the redesign (soil_* → store_*, ledger_* → frank_*,agent_task_* → task_*), so an app is not portable between the two unchanged.See docs/migrations/willow-2.0-gap-inventory.mdfor the verified tool-by-tool diff, and query lineage_why on the recorded atoms(version-willow-mcp, version-willow-2.0, version-willow-1.9,version-willow-1.7) for the provenance.
Not the same "2.0". The willow-2.0 fleet server above is thepredecessor line. willow-mcp's own package version (e.g. "serve mode is2.0.0+" below) is this product's semver — unrelated.
HTTP serve mode (OAuth)
Serve mode is 2.0.0+. Until the 2.0.0 release lands on PyPI, install fromsource (
pip install -e .in a clone) to use it.
Beyond stdio, willow-mcp can run as an HTTP server that authenticates callerswith OAuth 2.0 + PKCE against Google or Apple as the upstream identityprovider. Signing in proves who a caller is; a separate, operator-controlledidentity binding step maps that identity to an app_id before any toolpermission applies. An authenticated-but-unbound caller is denied exactly likean unmanifested app_id — fail closed, never fail open.
1. Store provider credentials in the local vault (secrets are prompted, sothey never land in shell history or a process listing):
willow-mcp setup --google-client-id "<client-id>" # prompts for the secret
# or, for Apple:
willow-mcp setup --apple-team-id "<team>" --apple-client-id "<svc>" \
--apple-key-id "<kid>" --apple-p8-key-path ./AuthKey.p8
2. Run the server:
python3 -m willow_mcp --serve --port 8765 --host 127.0.0.1
--port/--host take precedence over WILLOW_MCP_PORT/WILLOW_MCP_HOST,which take precedence over the defaults (8765 / 127.0.0.1). Point an HTTPMCP client at http://<host>:<port>/mcp.
3. First sign-in proposes a binding. When a person completes the Google/Appleapproval flow, the server writes an unconfirmed binding to$WILLOW_HOME/mcp_apps/_identity_bindings/<issuer>__<subject>.json:
{ "issuer": "google", "subject_id": "…", "email": "[email protected]",
"email_basis": "asserted", "app_id": null, "confirmed": false }
email_basis records how much downstream code should trust the email, becauseIdPs differ: asserted (Google — present and IdP-asserted every sign-in),first_auth_only (Apple — may appear only on the first authorization),relay (Apple private-relay address that can stop forwarding), orunavailable. If a bound identity's email later changes between sign-ins, thebinding is annotated with email_drift rather than silently updated.
4. Confirm the binding (operator-only, local). Confirmation is deliberatelynot an MCP tool — a remote caller must never confirm its own binding. Run iton the host that owns $WILLOW_HOME:
willow-mcp confirm-binding --issuer google --subject "<subject-id>" --app-id "<app_id>"
Only after this does the caller's session resolve to the manifest permissionsfor <app_id> (see Authorization).
Turning serve mode on and off
Serve mode is a background process, not part of the stdio server — so it'sturned on and off on demand rather than by editing config each time.scripts/willow-serve manages a systemd --user service for the --serveprocess and toggles the matching http entry in .mcp.json, so an MCPclient connects to it only while it's on:
scripts/willow-serve install # one-time: write + load the systemd user unit
scripts/willow-serve on # start serve + add the .mcp.json entry
scripts/willow-serve off # stop serve + remove the .mcp.json entry
scripts/willow-serve status # unit state + whether the entry is present
scripts/willow-serve logs # follow the serve logs (journalctl)
After on/off, reconnect your MCP client (in Claude Code: /mcp) so itpicks up the changed .mcp.json. Port/host default to 8766/127.0.0.1; setWILLOW_MCP_PORT / WILLOW_MCP_HOST before install to change them. ClaudeCode users get this as the willow-serve skill —just ask to turn serve mode on or off.
If you already signed in once,
onreuses your cached credential — no OAuthscreen reappears unless it was cleared. That's expected, not a failure.
Serve mode does not inherit your shell environment. The
systemd --userunit is started by systemd, not by your interactive shell, so aWILLOW_PG_DB(orWILLOW_STORE_ROOT,WILLOW_HOME, …) youexportin.bashrc/.zshrcwill not reach the serve process — it falls back to the defaults in theConfiguration table. This bites env-based, non-defaultsetups: the stdio server (launched from your shell) readswillow_20, say,while serve silently reads the defaultwillow. Make the config reachable bythe unit beforeon:# one-time: import current shell values into the systemd --user manager systemctl --user import-environment WILLOW_PG_DB WILLOW_STORE_ROOT WILLOW_HOME # …or, durably, drop them in a file systemd --user reads at login: # ~/.config/environment.d/willow-mcp.conf → WILLOW_PG_DB=willow_20Then
scripts/willow-serve install(regenerate) andon. Verify with a readtool over the serve endpoint: atable_not_found/relation … does not existon data that stdio can see is the signature of this env gap.
Installing standalone workers
willow-mcp worker-service manages separate fast and batch systemd user units.It writes every required environment value into the units, so workers do notinherit hidden willow-2.0 paths or depend on shell exports:
willow-mcp worker-service install
willow-mcp worker-service status
willow-mcp worker-service uninstall
Install and uninstall never start or stop services. Uninstall refuses while aworker is active; live state changes remain an explicit operator action. Beforestarting either unit, apply docs/schema/tasks-worker-production.sql andreconfirm the tasks mapping. The queue then isolates fast/batch claims,records claim owner/time, recovers stale claims, applies bounded retries, andtimestamps terminal rows.
Configuration
| Env var | Default | Description |
|---|---|---|
WILLOW_PG_DB |
willow |
Postgres database name (serve mode won't see a shell export — see serve env note) |
WILLOW_PG_USER |
$USER |
Postgres user (Unix socket auth) |
WILLOW_MCP_ENSURE_POSTGRES |
(off) | When 1, get_pg() tries pg_isready and local pg_ctlcluster / service postgresql start before returning postgres_unavailable (#160) |
WILLOW_PG_CLUSTER |
16/main |
Cluster passed to pg_ctlcluster when ensure-postgres runs (Debian-style installs) |
WILLOW_STORE_ROOT |
~/.willow/store |
SQLite store directory — set to willow-2.0's store root to share data |
WILLOW_MCP_FLEET_HOME |
(unset) | The fleet home this install claims to be severed from. Unset = no claim. See Severance |
WILLOW_MCP_FLEET_PG_DB |
(unset) | The fleet database this install claims to be severed from |
WILLOW_MCP_DISPATCH_MIRROR |
(unset) | Truthy on a fleet host to best-effort mirror dispatch packets into shared Postgres dispatch_tasks (so the fleet sees dispatches, like it already sees store/knowledge/tasks/agents). Off = filesystem-only; the filesystem packet is always canonical. See docs/schema/dispatch_tasks.postgres.sql |
WILLOW_APP_ID |
willow-mcp |
Default app_id if not passed per-call |
WILLOW_HOME |
~/.willow |
Root for manifests, vault, and identity bindings |
WILLOW_WORKER_LANE |
set by worker unit | Worker lane (fast or batch) |
WILLOW_WORKER_HEARTBEAT_ROOT |
$WILLOW_HOME/worker_heartbeat |
Explicit worker heartbeat directory |
WILLOW_WORKER_STALE_SECONDS |
1800 |
Age after which an uncompleted claim is recovered |
WILLOW_MCP_HOST |
127.0.0.1 |
Serve-mode bind host (--host overrides) |
WILLOW_MCP_PORT |
8765 |
Serve-mode bind port (--port overrides) |
WILLOW_MCP_URL |
(derived) | Public base URL for OAuth issuer/callbacks in serve mode |
WILLOW_MCP_PYTHON |
(searched) | Interpreter used when wiring willow-mcp into a project (onboard/project sync) — falls back to a venv search, then python3 |
WILLOW_MCP_ENFORCE_DB_PERIMETER |
(off) | When 1, local Postgres access via a Kart task also needs an operator-signed envelope (sign-db-task), not just the task_db capability. See kart-tasks skill |
WILLOW_MCP_AUTHORITY_CHECK |
(off) | Enables the S1 authority-check seam for dispatch gating. Landing the module must not change live behavior until an operator opts in |
WILLOW_MCP_ENFORCE_MEM_RATIFY |
(off) | Master switch for the Article IV Canon-promotion gate on shared-KB writes. The gate's own WILLOW_MEM_RATIFY_ENFORCE (also off by default) must also be on before a denial actually blocks — either alone only logs advisory |
WILLOW_OWNER_SUBJECT_ID |
(unset) | subject_id treated as the consent owner (exempt from subject-consent grants). Unset = no subject is the owner — the strict, safe default |
WILLOW_SETTINGS_GLOBAL |
(derived) | Overrides the canonical fleet settings-file path — the file consent.json mirrors (see docs/BUGS.md B-30 if you're chasing a mismatch between the two) |
WILLOW_IN_KART |
(unset) | Set inside a Kart sandbox; blocks network-authorization signing and forces non-interactive CLI paths. Not meant to be set by hand |
WILLOW_MCP_TRUST_OWNER |
willow-operator |
Trust-root unix user, used by harden-trust-root (see Trust-root hardening) |
WILLOW_MCP_RUNTIME_USER |
$SUDO_USER or caller |
Explicit runtime-user override for trust-root setup |
WILLOW_MCP_EGRESS_CONFIG_DIR |
~/.config/willow-mcp/egress |
Egress key/manifest directory |
WILLOW_MCP_EGRESS_SIGNING_KEY |
(from manifest) | Overrides the private key path used to sign egress manifests |
WILLOW_SEARCH_PROVIDER_ORDER |
ddg_html |
Comma-separated web-search provider chain order |
WILLOW_SOIL_HEARTBEAT_INTERVAL |
(subsystem default) | SOIL watchman heartbeat interval, seconds. Per-watchman override: WILLOW_SOIL_HEARTBEAT_INTERVAL_<KEY> |
WILLOW_CODE_GRAPH_DB |
$WILLOW_HOME/code_graph/graph.db |
Symbol/code-graph SQLite DB path |
WILLOW_ENVELOPE_REGISTRY |
$WILLOW_HOME/constitutional/pre-approved.json |
Pre-approved envelope registry path |
WILLOW_SYSCALL_TABLE |
(sibling of registry) | syscall-table.json path |
WILLOW_FLEET_ROSTER |
(derived) | fleet.json roster path |
WILLOW_MCP_GROVE_RINGS |
$WILLOW_HOME/grove/rings.json |
Grove ring-state store path |
WILLOW_MCP_SCHEMA_RINGS |
$WILLOW_HOME/schema_rings.json |
Confirmed-schema-mapping cache path (see also WILLOW_MCP_SCHEMA_RINGS_MAX) |
WILLOW_SENTRY_DSN |
(unset) | Sentry DSN — unset means observability is fully disabled (fail-closed default); PII, breadcrumbs, and stack locals are scrubbed regardless |
WILLOW_SENTRY_ENV / WILLOW_SENTRY_RELEASE / WILLOW_SENTRY_TRACES |
experiment / willow-mcp@experiment / 0 |
Sentry environment tag, release tag, trace sample rate — no-ops unless WILLOW_SENTRY_DSN is set |
SAP_SAFE_ROOT |
~/.sap/Applications |
SAFE folder root |
SAP_PGP_FINGERPRINT |
(empty) | Pinned GPG fingerprint |
Authorization
Manifest-based ACL, no external service or ACL database. Each app_idneeds a manifest at $WILLOW_HOME/mcp_apps/<app_id>/manifest.json:
{"permissions": ["store_read", "knowledge_write"]}
permissions is a list of group names and/or literal tool names —see PERMISSION_GROUPS in src/willow_mcp/gate.py for the authoritative set(51 groups). Common ones: store_read, store_write, knowledge_read,knowledge_write, schema_admin, task_queue, agent_dispatch,dispatch_read, dispatch_write, fleet_read, context, audit,gap_read, gap_write, gap_promote, fork_read, fork_write, nest_read,nest_write, integration_read, web_read, code_graph_read,code_graph_write, grove_read, grove_write, full_access — plus per-subsystem read/write groups forlineage, friction, commitments, the human-loop, and MarkdownAI. Fail-closed:no manifest, or an empty permissions list, denies every call for thatapp_id. gap_promote is kept separate from gap_write — landingsomething as trusted knowledge is a more consequential act than logging orresolving a gap, the same reasoning schema_admin gets its own groupinstead of folding into knowledge_write.
PGP-enforced manifests (opt-in)
By default a manifest is trusted as whatever's on disk — anyone who can writemcp_apps/<app_id>/manifest.json can grant that app any permission, or createa new one and become any identity (issue #183). Set WILLOW_PGP_FINGERPRINTto your operator key's fingerprint and this stops being true: gate.py deniesany manifest whose manifest.json.sig doesn't verify against that key —missing, tampered, or signed by a different key are all treated exactly likea missing manifest. Sign one with willow-mcp sign-manifest <app_id>(interactive operator terminal only, same as sign-net-task — gpg-agent isunreachable inside the Kart sandbox); re-sign after every edit, since achanged manifest with a stale signature is denied too. Seedocs/design/pgp-and-persona.md for the full design.
The MarkdownAI (mai) tools (registered only when WILLOW_MCP_MARKDOWNAI=1)are additionally per-app gated (#153/#161): markdownai_read andmarkdownai_write cover the file/render tools, and markdownai_directives —deliberately outside full_access — unlocks the side-effectful@db/@http/@env directives inside render(). Even with that grant:@db connections must be allowlisted in the manifest's "mai_connections"list and never default to the willow database; @http honors the operator'sconsent.internet and reaches the network only through web_fetch's guardedpath — the same destination check willow_web_fetch uses, which resolves namesbefore judging them and re-checks every redirect hop, rather than the hostnameblocklist it used to carry; and @env resolves only keysnamed in the operator's WILLOW_MAI_ENV_ALLOW (comma-separated, defaultdeny), with credential-shaped keys never resolving at all.
Grove — the fleet's shared messaging room
The 20 grove_* tools (willow_mcp/grove_tools.py, data layerwillow_mcp/grove.py) are the agent-side successor to willow-2.0'ssap/grove_tools.py: they give an agent a voice in Grove, the fleet's sharedPostgres-backed chat (grove.channels/grove.messages/grove.message_flagstables) — channels, threads, flags, a priority bus protocol, andfleet-awareness reads (grove_agents, grove_fleet_status,grove_human_required). Registered unconditionally, like the store/knowledgetools, but gated per-app like every other tool here: grove_read (13 tools)and grove_write (7 tools), or grove_all for both. Unlike web_read /integration_call / markdownai_*, both ride full_access: Grove carries noegress concern, same reasoning as knowledge_read/knowledge_write.
Not to be confused with the_grove.py / python -m willow_mcp.the_groveabove — that is an unrelated local SQLite rings-of-lessons store. This Groveis the fleet's shared Postgres messaging room.
Every write tool's sender defaults to the calling agent's grove_sender,resolved from the specialist registry (willow_mcp.registry.specialist_row) —never the literal "Auto" the canonical willow-2.0 tools defaulted to. Anagent posts as itself by default, and for free — grove_write alone coversthat. Posting as a different identity (relaying on another identity'sbehalf) is a separate, sender-locked privilege: passing an explicit senderthat does not match the caller's own resolved identity is refused —{"error": "sender_forbidden", ...}, before any DB write — unless thecaller's manifest also holds the grove_relay capability. grove_relay isits own manifest line, deliberately excluded from grove_write andfull_access (same shape as task_net/mcp_federation below): a broadGrove-write grant must never silently also grant impersonation. No seed seatholds it (bundle/config/specialists.json) — it is reserved for a futureoperator-granted bridge/relay seat.
The DB-name trap: Grove's tables live in the fleet's willow_20 Postgresdatabase, not this server's default willow database (WILLOW_PG_DB). IfWILLOW_PG_DB is unset or still willow, every grove_* tool returns agrove_unavailable error naming the fix rather than a raw driver traceback.Set WILLOW_PG_DB=willow_20 in the willow-mcp server's environment (andrestart it) to reach Grove.
There is also one capability permission, task_net, which is not a toolname but a privilege flag: it lets an app ask for task_submit(allow_net=True).It is deliberately excluded from task_queue and full_access — network egressfrom the sandbox must be granted explicitly, on its own line, and only host-side(never authored from inside the sandbox). On its own it authorizes nothing: thecall also needs the operator's consent.internet and a live egress lease(see Egress needs three keys).
store_scope — confining an app to its own collections
By default, store_* tools are unrestricted across collections — and bydefault the SOIL store is the wider Willow fleet's store (seeWILLOW_STORE_ROOT in Configuration above), so an app withstore_read/store_write/full_access can see every collection any otherapp or fleet process has written, the same way it always could. That's theright default for a single-operator, single-trust-domain install, but itmeans a store_read grant to one app is implicitly a grant to read everyother app's data too.
Sharing is a default, not a design commitment. An install that should be cutoff from the fleet can point WILLOW_STORE_ROOT at its own store and name thefleet it is severed from — see Severance below, which turns thecut into something diagnostic_summary checks rather than something the docsassert.
An operator who wants an app confined to its own data adds an optionalstore_scope array to that app's manifest:
{"permissions": ["full_access"], "store_scope": ["myapp_*"]}
Patterns match by exact name, or by prefix if they end in *. Withstore_scope set, store_put/get/list/update/search/delete rejectany collection outside it (collection_denied), and store_search_allonly searches the matching collections instead of every collection in thestore. Omit the field entirely for today's unrestricted behavior — an emptylist ("store_scope": []) means "no collections," not "unrestricted."
A scope the gate cannot read denies everything. If store_scope is presentbut malformed — most likely "store_scope": "myapp_*", a string where a listbelongs — the app is confined to no collections rather than granted all ofthem. The same holds for an unreadable manifest or an invalid app_id. This isdeliberate: an operator who mistypes the field believes the app is confined, anda policy that cannot be parsed is not consent. The app fails loudly, an ERRORis logged naming the field and the type it got, and nothing leaks while the typois being found. Omit the field (or set it to null) to declare no policy.
In HTTP serve mode, the app_id is not taken fromthe call — it is resolved from the caller's confirmed OAuth identity binding,then checked against that same manifest ACL.
egress_secret_exempt — letting a tool return a raw credential
Tool responses are scanned at a single funnel and any credential-shaped value(a provider sk- key, an AKIA… id, a PEM private-key block, a GitHub/Slack/Google/Stripe token, a JWT) is redacted to [REDACTED:<kind>] before itleaves — the data-path half of "no tool ever returns a credential." A few toolslegitimately must return a raw token, the canonical case being anintegration_call that performs an OAuth token exchange. Name those tools inthe app's manifest:
{"permissions": ["full_access"], "egress_secret_exempt": ["integration_call"]}
The scan still runs, so the audit trail stays complete: an exempted return iskept raw but receipted as credential_returned (naming the kinds, never thevalue), so the exception is loud rather than silent. Like store_scope, thefield fails closed toward redaction — a bad app_id, a missing/unreadablemanifest, or a malformed field (a string where a list belongs) exempts nothingand an ERROR is logged. Because manifests are operator-side (the PreToolUsehook blocks an app from writing its own), an app can never exempt itself. Theexemption is per named tool, never a blanket unlock.
Severance
A willow-mcp install can share a Willow fleet's store, database, and trust root,or it can be cut off from them. Both are legitimate. What is not legitimate isclaiming the cut and not having it — a server that reports ok while wired tothe fleet is worse than one with no check at all.
Severance is asserted, never assumed. Name the fleet you are severed from:
export WILLOW_MCP_FLEET_HOME=/home/you/github/.willow
export WILLOW_MCP_FLEET_PG_DB=willow_20
diagnostic_summary then reports a severance check over four surfaces:
| Surface | Kind | Violation |
|---|---|---|
store |
data | WILLOW_STORE_ROOT resolves inside the fleet home → degraded |
postgres |
data | WILLOW_PG_DB is the fleet database → degraded |
trust_root |
authority | mcp_apps/ is inside the fleet home, or is writable by this process → broken |
egress |
authority | this process can forge the three-key network gate (strict trust root off, or the consent switch / lease root / egress verification key is self-writable) → unknown degrades, a forgeable key breaks |
The distinction is the whole design. Store and database hold data: someonewho writes them corrupts records. The trust_root and egress surfaces holdauthority — the manifest that grants task_net, the lease root, the consentfile, the egress verification key. Someone who writes those grants themselvesthe egress the cut was supposed to deny. Only an authority surface can turn asevered install into a compromised one, so only those two break the verdict; thedata surfaces merely degrade it.
Consequently the trust root must live somewhere neither this process nor the Kartsandbox can write. A repo directory is the wrong place for it, however convenient:repos are bound read-write into task sandboxes. Put data in the repo; put the gateoutside it, owned by a uid the agent does not run as.
Symlinks are resolved before comparison. ~/.willow is frequently a symlink intoa fleet tree, and two names for one directory are not two directories.
Leave both variables unset and the check reports not_asserted and changesnothing — a single-trust-domain install is complete without severance, and onethat never claimed to be cut off cannot be caught lying about it. Set one and notthe other and the unnamed surface reports unknown, which degrades: anunverifiable claim is not a passing one.
The companion layer
Not everything in the package is a gate. A few subsystems exist to carry thestory of an install — lessons, work-units, the shape of the collaboration —and a tools/ directory turns jobs a model was doing by hand intodeterministic scripts.
The Grove — rings for lessons
the_grove.py is a rings store for lessons learned, sibling toschema_profile's vocabulary rings but unbounded on purpose: vocabulary may bepruned cheaply; lessons are kept precisely so the deployment cannot becomesomething that forgets them.
$ python -m willow_mcp.the_grove # the resting display
The Grove is stable.
Current depth: 0 rings.
Soil health: Worth tending.
Next gardener: unknown.
Chapters remaining: as many as the rain requires.
$ python -m willow_mcp.the_grove --status # pipe-friendly: stability, depth, soil health
core.record_lessons() distills any SQLite journal (the table holding thewriting is introspected, never assumed; the source is opened read-only) intoexactly one ring carrying the lesson worth keeping. A diseased rings file readsas empty but reports the grove unsettled rather than silently claimingdepth 0.
Forks — bounded work-unit tracking
The seven fork_* tools (fork_create / fork_status / fork_log /fork_list / fork_join / fork_merge / fork_delete, under thefork_read/fork_write permission groups) track branch + PR work-units asdurable SOIL records with an append-only change log — the same shape as gaps,lineage, and the human-loop queue, deliberately not a fleet-Postgres table(B-28's lesson: don't drag a schema migration into the shared database for abookkeeping record). fork_merge/fork_delete count atom/KB change-log refsas promoted/archived bookkeeping.
Friction floor — the mirror detector
friction_scan watches one thing: whether the agent has stopped being otherand is mirroring the user back, smoothed, while the user is escalating.When a window of agent turns sits below the friction floor during escalation,it raises a loud, human-facing flag — persisted and deduped;friction_flags_list reads them back. It never blocks and never egresses: asignal, not a verdict. It must be driven from outside the watched model — amirror cannot audit itself.
tools/ — take the job off the model
Deterministic harnesses for jobs a model was doing by hand — each turnsconversational labor into a script, so the next session runs the tool insteadof re-deriving the work. See tools/README.md for the fullwiring; the cast:
| Script | Job it takes off the model |
|---|---|
wtool.py |
the substrate — call any of the server's tools from a shell (--list, JSON args), so any script can do what a model does through an MCP client |
mai_lint.py |
deterministic @markdownai format validation (also a CI step) |
mai_metrics.py |
record one metric per bite into SOIL; report the new-gaps-by-learnings convergence curve |
mai_prose_split.py |
the prose/structure pass for converting narrative docs to @markdownai — separates protected prose from directive candidates, and a prose_ratio verdict flags story-shaped docs "do not force" instead of mangling them |
provision_gate.py |
union permission groups into a gate manifest, validating every name against gate.PERMISSION_GROUPS — loud-fail on a typo instead of granting nothing |
Hooks and skills (Claude Code)
.claude-plugin/plugin.json registers a PreToolUse hook and thirteen skillsfor Claude Code users — install this package as a plugin to get them alongsidethe MCP server itself. The hook is wired for four matchers (Bash,task_submit, Write|Edit|MultiEdit|NotebookEdit, and WebSearch|WebFetch),all routed through the same guard:
hooks/pre_tool_use.pyblocksBashcommands that reach for rawpsql/psycopg2/sqlite3against a database or store willow-mcp owns,redirecting to the matching MCP tool instead. It also blocks any call thatwould write the keys authorizing the agent's own egress — minting a lease,runninggrant-net, or editing a manifest to addtask_net— and warns on atask_submitthat hand-embeds a# allow_netdirective.- The full skill set (13):
session-start,consent,worktree,handoff-write,external-guard,schema-confirm,willow-serve,kart-tasks,debugging,review,tdd,brainstorming,persona-overlays. A few load-bearing ones: skills/schema-confirm.mdwalks throughreviewing and confirming a table's schema mapping before writing to it.skills/willow-serve.mdturns OAuth serve modeon/off on request (see above).skills/kart-tasks.mdcovers submitting and pollingKart tasks, the three-key egress model, and worker liveness.
See docs/design/hooks-and-skills.md forthe design and the reasoning behind shipping these alongside tools ratherthan as a later add-on.
License
Apache-2.0 — Sean Campbell 2026