git:20260803.49581e6 to git:20260821.3dfe12f

6 added, 4 removed. Audit A to A.

# grove.mcp — the MCP server (stdio or networked workspace control plane)
> ↑ [root](../../../CLAUDE.md)
Exposes the daemon as MCP tools for any MCP client (Mewbo, Claude Code, Claude Desktop). Requires the `[mcp]` extra (official `mcp` SDK).
## The boundary rule (non-negotiable)
- **MCP client → `grove.mcp` → `GroveClient` → daemon REST → core. Never import engine internals** (manager/store/git/tmux/registry/config); the daemon is the single authority, which is also what lets this server run on a different host than the engine. Enforced by the "MCP server speaks only through the client SDK" import-linter contract (direct imports only — the client/contracts legitimately reach core modules underneath).
- Connects via the client SDK's `UrlTransport` (`BackendConfig.daemon_url`) — attach to an externally supervised daemon, never spawn one. Auth: `GROVE_API_TOKEN` wins if set; otherwise `GroveClient` mints a same-host session from `auth.json`, so a co-located daemon needs zero setup.
## Transports & inbound auth (`stdio` | `streamable-http`)
**Two transports, two trust models — and the trust model is the whole design.** Under `stdio` the client *spawns* this process, so the OS process boundary IS the authentication and no inbound credential exists. Under `streamable-http` this process binds a socket and becomes the network edge, so it must authenticate callers itself.
- **This package is the sanctioned network edge; the daemon is NOT.** [daemon/CLAUDE.md](../daemon/CLAUDE.md)'s "bind 127.0.0.1, never widen it" stands unamended — mounting MCP onto the daemon would have widened it, forced `[daemon]` to carry the MCP SDK, and gained nothing. Topology instead: `remote harness --Bearer--> grove-mcp --loopback--> grove-daemon`. That the MCP server may live on a different host than the engine was already this package's stated property (the boundary rule above); network transport just makes it reachable *from* one.
- **Two credentials point in OPPOSITE directions — the single easiest thing to confuse.** `GROVE_API_TOKEN` / `McpServerConfig.api_token` is **outbound** (this server → daemon; optional, absent means the same-host `auth.json` local mint). `GROVE_MCP_TOKEN` / `auth_token` is **inbound** (caller → this server; mandatory for a network transport). They are never the same secret and neither substitutes for the other.
- **Fail closed in `McpServerConfig.validate()`, called from `GroveMcpServer.__init__` before any socket exists.** A networked transport without an inbound token refuses to start (exit 2). There is deliberately **no `--insecure` escape hatch**: the tool surface includes `grove_kill_workspace`, setting a token is one env var, and opt-out flags survive into production.
- **`--read-only` withholds tool *registration*; it is not a scope.** Grove sessions carry no scopes or roles at all (every authenticated caller has full authority — see [core/auth](../core/CLAUDE.md)), so minting scopes on the `AccessToken` would imply an authorization model that does not exist. Withholding is the only honest denial: a tool an agent can see is a tool it will try to call. Read-only keeps every read tool and hides every tool flagged `mutates=True` in `_register_tools`. **Never write a count of read vs. mutating tools here** — the registration tuple in `_register_tools` is the only census, and a stale count reads as authority.
- - **Env IS this package's config cascade, by contract.** The import-linter rule forbids `grove.mcp` from importing `grove.core.config` *and* `grove.core.auth`, so there is no `GroveConfig` section and no reuse of `SessionStore` pairing here. That is also why inbound auth is a static shared secret rather than the pairing flow: pairing needs a human at a TUI, and a networked server starts unattended — the same constraint the daemon's hook-ingest token solves the same way (`daemon/auth.py`'s `make_require_hook_token`). Keep new knobs on the CLI-flag → env → default pattern.
+ - **Env IS this package's config cascade, by contract.** The import-linter rule forbids `grove.mcp` from importing `grove.core.config` *and* `grove.core.auth`, so there is no `GroveConfig` section and no reuse of `SessionStore` pairing here. That is also why inbound auth is a static shared secret rather than the pairing flow: pairing needs a human at a TUI, and a networked server starts unattended — the same constraint the daemon's hook-ingest token solves the same way (`daemon/auth.py`'s `make_require_hook_token`). Keep new knobs on the CLI-flag → env → default pattern. `grove-mcp` initializes process TLS trust before it builds a client; `GROVE_TLS_CA_PATH` adds an operator-supplied root to the OS trust store, while a bad path fails on stderr before the MCP transport begins.
### SDK facts worth not re-deriving (verified against the locked `mcp` 1.27.2)
- **`auth=` AND `token_verifier=` are BOTH required to get a guarded route.** `FastMCP.streamable_http_app` gates `AuthenticationMiddleware` behind `if self.settings.auth:` but wraps the route in `RequireAuthMiddleware` behind `if self._token_verifier:`. Passing only the verifier yields a route that **401s everything**, because no backend ever populates the auth scope. Passing only `auth` leaves the route unguarded.
- **`resource_server_url=None` is load-bearing.** It suppresses `create_protected_resource_routes`, so we never advertise RFC 9728 protected-resource metadata. We are not an OAuth resource server; advertising a nonexistent authorization server would invite a spec-compliant client into a doomed OAuth flow instead of using the static bearer it was configured with. `issuer_url` is required by the Pydantic model but is **inert** while both `resource_server_url` and `auth_server_provider` are absent. (Claude Code prefers an explicitly configured `Authorization` header over OAuth anyway, but the metadata must not be there to tempt other clients.)
- **Host/port/path are constructor settings, not `run()` arguments.** `FastMCP.run(transport, mount_path)` takes no bind info, so `_network_kwargs` must decide them at construction time.
- **DNS-rebinding protection is OFF when `transport_security` is unset, and 421s *every* request when enabled with an empty `allowed_hosts`.** Hence it is opt-in via `GROVE_MCP_ALLOWED_HOSTS`/`_ORIGINS`: the operator names the hostnames clients actually use, or gets no Host validation at all. Never enable it with an empty allowlist.
- **`sse` is deliberately not exposed** though the SDK supports it: it is deprecated in favour of Streamable HTTP and takes its mount path from a *different* setting (`sse_path`), which would give one `--path` flag two meanings. Re-adding it later is a one-line change.
- **HTTP transport needs NO new dependency and no `uv lock`.** The `mcp` extra already pulls `starlette`, `uvicorn`, and `sse-starlette` transitively — which matters because re-locking 404s on the dead `mkdocs-shadcn-mewbo` wheel (the same hazard that made the daemon hand-roll its SSE framing).
- **`_load_fastmcp()` must stay the first SDK touch in `__init__`.** The lazy `mcp.server.auth.*` / `transport_security` imports live inside `_network_kwargs`, which runs *after* it — that ordering is what keeps the missing-SDK path emitting a clean install hint instead of a chained traceback.
## Tool surface (published contract — renaming breaks every configured client)
The registration tuple in `GroveMcpServer._register_tools` is the census: every tool name and its `mutates` flag live there and nowhere else. Notes below cover only the tools whose *shape* encodes a decision.
- **`grove_list_projects()` is the zero-argument discovery entry point.** Every other repo-scoped tool needs a `repo_root`, which a remote agent has no way to guess — this is where it gets one. Thin passthrough to `GroveClient.list_projects` → `GET /projects`, itself a passthrough to `RepoRegistry.known_projects()`. No new engine logic: the union of store-derived and config-declared projects, and therefore empty-project visibility, came for free. Non-mutating, so it survives `--read-only` — which matters, since a read-only observer is exactly the caller that starts with no paths in hand.
- - **`grove_list_sessions(repo_root=None, limit=50)` is the "what has been running on this host" read.** Zero-argument by design — like `grove_list_projects`, it is callable with nothing in hand, and unlike every other read it answers *beyond* Grove's own workspaces: sessions in repos Grove never managed, launched by a human, included. Thin passthrough to `GroveClient.list_sessions` → `GET /sessions`, with the scope carried by the presence of `repo_root`. Non-mutating, so it survives `--read-only`, which is the point. **The catalog is deliberately the ONLY tool for this axis** — the drill-in (`GET /sessions/{id}/turns`) exists on `GroveClient` but is not a tool, because an unbounded transcript is the wrong thing to hand an agent by default and the row already carries enough to decide.
- - **`grove_get_fleet_status()` is the zero-argument SUPERVISION read, and it exists because every other read here answers for exactly one workspace.** Without it the MCP tier can set a phase but cannot answer "which of my twenty agents is waiting on me": `WorkspaceStateView` (`grove_list_workspaces`/`grove_get_workspace`) carries the *lifecycle* status only, `WorkspacePeekView` carries git + a pane snapshot, and the blended `AgentActivityView` — `needs_attention`, the live `questions`, the current task — otherwise reaches MCP only through `grove_list_sessions(repo_root=…)`, session-shaped and repo-scoped, with no phase on it at all, so a fleet watcher pays N+2 round trips for a picture the daemon already assembles once per tick. Thin passthrough to `GroveClient.get_activity` → the pre-existing `GET /activity`: **no new route, no new contract View, no engine change**. Non-mutating, so it survives `--read-only`. Two riders. The result is BOUNDED but not small — one full `WorkspaceStateView` plus sessions plus recent commits per workspace, every project the daemon serves — so its docstring points a single-workspace caller back at the per-workspace tools rather than letting the fleet read become the default; the same judgement that keeps the transcript drill-in off this surface, applied to a payload that grows with the fleet rather than without bound. And it is a SNAPSHOT: the SSE `/events` half stays a transport concern the client SDK does not wrap, because a long-lived stream is not a tool call.
+ - **`grove_list_sessions(repo_root=None, limit=50)` is the "what has been running on this host" read.** Zero-argument by design — like `grove_list_projects`, it is callable with nothing in hand, and unlike every other read it answers *beyond* Grove's own workspaces: sessions in repos Grove never managed, launched by a human, included. Thin passthrough to `GroveClient.list_sessions` → `GET /sessions`, with the scope carried by the presence of `repo_root`. Non-mutating, so it survives `--read-only`, which is the point. The full turn drill-in (`GET /sessions/{id}/turns`) remains deliberately absent: an unbounded transcript is the wrong default payload for an agent, and the catalog row carries enough to decide.
+ - **`grove_recollect_session(session_id, kind, cwd, last=None)` is the fidelity exception, not a transcript drill-in.** It returns only direct human queries, oldest first, using the exact coordinates from `grove_list_sessions`; it reads the complete normalized message spine so it can recover instructions predating a compaction, then applies the optional tail. `SessionTurnView.user_text` caps text at 4 KB, but this tool's `SessionQueryView.text` is deliberately uncapped: shortening the instruction would defeat recollection's sole purpose. It is non-mutating and therefore remains available under `--read-only`.
+ - **`grove_get_fleet_status()` is the zero-argument SUPERVISION read, and it exists because the other status reads answer for exactly one workspace.** Without it the MCP tier can set a phase but cannot answer "which of my twenty agents is waiting on me": `WorkspaceStateView` (`grove_list_workspaces`/`grove_get_workspace`) carries the *lifecycle* status only, `WorkspacePeekView` carries git + a pane snapshot, and the blended `AgentActivityView` — `needs_attention`, the live `questions`, the current task — otherwise reaches MCP only through `grove_list_sessions(repo_root=…)`, session-shaped and repo-scoped, with no phase on it at all, so a fleet watcher pays N+2 round trips for a picture the daemon already assembles once per tick. Thin passthrough to `GroveClient.get_activity` → the pre-existing `GET /activity`: **no new route, no new contract View, no engine change**. Non-mutating, so it survives `--read-only`. Two riders. The result is BOUNDED but not small — one full `WorkspaceStateView` plus sessions plus recent commits per workspace, every project the daemon serves — so its docstring points a single-workspace caller back at the per-workspace tools rather than letting the fleet read become the default; the same judgement that keeps the transcript drill-in off this surface, applied to a payload that grows with the fleet rather than without bound. And it is a SNAPSHOT: the SSE `/events` half stays a transport concern the client SDK does not wrap, because a long-lived stream is not a tool call.
- **`grove_remap_workspace_session(workspace_id, session_ref)`** pins an existing agent session as a workspace's tracked primary — the write-side of "which session the dashboard follows" (e.g. after `/clear` rotated the id). Thin passthrough to `GroveClient.remap_session`; `session_ref` is a full id or unique prefix. `grove_create_workspace`'s optional `resume_session_id` (continue an existing session in the new workspace; claude_code/codex only) is the other half — both are the MCP face of the daemon's remap route + resume field.
- **`grove_attach_ticket(workspace_id, ref)` / `grove_detach_ticket(workspace_id, ref)` take ONE free-text `ref` — a URL, `#42`, `42`, or `owner/repo#42` — never a `provider` argument**, because forcing an orchestrator to name Gitea/GitHub/Linear is exactly the friction these tools exist to remove (an agent that just opened a PR has a URL or a number, never a provider name). Both are thin passthroughs to `GroveClient.attach_ticket_by_ref` / `detach_ticket_by_ref`; **the ref is resolved SERVER-SIDE** through the engine's own grammar (`TicketProviderRegistry.resolve_link`, [tickets](../core/tickets/CLAUDE.md)) at the daemon's `POST`/`DELETE /workspaces/{id}/tickets`, so this package parses nothing — which is what keeps it inside the "MCP CANNOT import `grove.core.tickets`" contract (that package's `__init__` pulls in `grove.core.workspace` through the provider classes). An ambiguous or unparseable ref comes back as a typed 422, never a silent guess. `WorkspaceStateView.ticket_refs` already carries attached tickets on every read (`grove_get_workspace`/`grove_list_workspaces`), so this axis needed only the two writes.
+ - **`grove_update_workspace` exists because a workspace could NAME itself and not RENAME itself, and the asymmetry was the bug.** An agent inside a workspace could already attach tickets to it, report its phase, publish a todo list and `grove_kill_workspace` it — the most destructive verb was reachable and the most harmless one was not. It matters because a create with no title deliberately generates a short id *on the promise that it stays renameable*, and until this tool the only ways to keep that promise were the TUI modal or a raw bearer token, so an orchestrator's fleet stayed named after hashes. Thin passthrough to `GroveClient.update_workspace` (which already existed and had no caller above it), `mutates=True`, so `--read-only` withholds it. **It takes the workspace id EXPLICITLY and infers nothing from a cwd**, unlike the CLI's `grove edit`: this server may not share a host with the engine, so a cwd here names a directory the manager has never heard of — the same reasoning that keeps `grove_list_projects` the zero-argument way to *learn* a path. `None` means "leave alone" and `""` clears the description, all the way down to the engine's `_UNSET`; a tool that echoed the current title back would turn every description edit into a rename.
- **`grove_list_workspaces` takes optional `repo_root`/`ticket_provider`/`ticket_id`.** `repo_root` scopes to one repo like `grove_list_agents`; `ticket_provider`+`ticket_id` (give both or neither — `GroveClient.list_workspaces` raises client-side on a lone half) answer the issue-ops "does a workspace already exist for this ticket" question via `WorkspaceManager.find_by_ticket` on the daemon, wired over the single `GET /workspaces?repo=&ticket=<provider>:<id>` query-param dispatch. All three stay optional so the zero-arg call keeps its "everything, everywhere" shape.
- - **`grove_create_workspace`'s optional `model` rides `CreateWorkspaceRequest.model` and is forwarded VERBATIM to the agent tool** (`claude`/`codex --model <id>`, mewbo server-side; generic ignores it) — Grove never validates or interprets the id, per the provider-boundary rule. `grove_list_agents` is its discovery companion: a thin passthrough to `GroveClient.list_agents(repo)` → `list[AgentSummaryView]`, whose `models: tuple[str, ...]` (≤10) is a hint for that argument, not a whitelist. Models reach this package only through that one client method / contract View — never by importing an agent adapter directly.
+ - **`grove_create_workspace`'s optional `model` rides `CreateWorkspaceRequest.model` and is forwarded VERBATIM to the agent tool** (`claude`/`codex --model <id>`, mewbo server-side; generic ignores it) — Grove never validates or interprets the id, per the provider-boundary rule. `grove_list_agents` is its discovery companion: a thin passthrough to `GroveClient.list_agents(repo)` → `list[AgentSummaryView]`, whose `models` are a hint for that argument, not a whitelist. A discovered catalog is capped at ten, while an explicit `AgentSpec.models` list is returned whole; models reach this package only through that one client method / contract View — never by importing an agent adapter directly.
- Outputs reuse the contract Views; `models.py` adds shapes only where the daemon returns no body (kill → 204) or where availability itself is the answer (message). Explicit status fields, never prose an agent must parse.
- **`kill` takes a required `delete_branch`** — destructive tools never guess; schema validation rejects an omitted flag before the client is ever called (pinned by test).
- Peek pane snapshots cap at `GroveTools.SNAPSHOT_CAP` (4 KB, trailing ellipsis = trim signal), mirroring the contracts package's bounded-text rule.
- `send_workspace_message` targets `POST /workspaces/{id}/message`. A bare 404/405 (`ProtocolError code="http_error"`) means the route is absent → `status="unavailable"` (capability discovery); an enveloped `workspace_not_found` is a real caller error and propagates.
## Structure
`server.py` = env config (`McpServerConfig`, incl. transport/bind/inbound-token resolution + the fail-closed `validate`) + FastMCP wiring (`_network_kwargs`) + `SharedSecretVerifier` + lifespan-owned `GroveClient`; `tools.py` = `GroveTools`, the seam tests pin (handlers in, Views out, fake client at the HTTP boundary); `models.py` = the few result shapes. Tool method **docstrings ARE the MCP descriptions** an agent reads when choosing tools — keep them action-first and explicit about side effects.
- **`grove_get_workspace_phase`/`grove_set_workspace_phase`/`grove_get_workspace_todo` are thin passthroughs to `GroveClient.get_phase`/`set_phase`/`get_todo`, the same recipe as every other read/write here.** The interesting design lives one layer down, in `grove.mcp.instructions.SERVER_INSTRUCTIONS`: the phase axis is REPORTED, not derived (see `core/phase.py`), and the primary channel for the in-workspace agent is writing the file named by `GROVE_PHASE_FILE` directly, never these tools — a containerized agent (the default runtime) can reach neither the daemon nor a mounted `grove` CLI, so the two MCP tools are the *fallback* convenience for a host/attached agent, and the instructions string says so explicitly (file first, tools second) rather than teaching the common case a channel that cannot work for it.
- **`instructions=` on the FastMCP constructor is `grove.mcp.instructions.SERVER_INSTRUCTIONS`, not an inline string on `server.py`.** Two audiences share it (an orchestrator with nothing in hand, and an agent working inside a workspace who needs the phase-reporting contract) — the module docstring is the place to read the "why a file, why this order" reasoning; don't duplicate it here.
## Session lessons
- **FastMCP renders a failed tool as `f"Error executing tool {name}: {e}"`, so this whole surface is only ever as legible as the exception's `str()`.** An unguarded `httpx` timeout stringifies to `""`, so callers get that prefix and an empty body — indistinguishable from a dead workspace, a dropped connection, or a transient blip. The fix belongs at [client](../client/CLAUDE.md)'s `_request` seam, where every tool inherits it, **never as per-tool `except` blocks here** — two tools reported it independently precisely because the defect was one layer below both. Corollary for the tool surface: a transport failure must stay an *exception*. Folding it into `SendMessageResult.status` would hand an orchestrator a successful-looking result for a message that may never arrive, and `unavailable` specifically means the permanent capability gap (no message endpoint), which is the opposite of a retryable blip. Verified in passing: **there is no payload-size cap anywhere in this path** — 200 KB round-trips stdio → server → client SDK → daemon — so a size-correlated failure here is a coincidence of slower operations carrying longer payloads, not a limit.
- **FastMCP (official `mcp` SDK, locked 1.27.2) accepts bound methods in `add_tool`** and derives schemas from signatures, including the `BranchPlan` Pydantic discriminated union. `call_tool` returns `(content, structured)`; a list return is wrapped under `{"result": [...]}` in the structured half.
- **The stdio run enters the FastMCP lifespan**, so connect the `GroveClient` there once for the whole session — the local token mint writes `auth.json` and is not free per call.
- A function-call default like `branch_plan: BranchPlan = AutoBranch()` trips ruff B008; share one module-level frozen instance instead.
- **A bare `except ImportError` around a DEEP SUBMODULE import cannot tell an absent distribution from an incompatible one, and the hint it produces actively misdirects.** SDK 2.0.0 removed `mcp.server.fastmcp`, and `grove-mcp` then printed "requires the MCP SDK — install with: pip install 'grove[mcp]'" on a host where `mcp` was installed and `import mcp` succeeded, sending the debugging at the install and never at the version. Presence is a question about the **top-level distribution** (`importlib.metadata` / `find_spec`), never about the submodule you happened to reach for; the two answers take different messages, and the incompatible one must name the installed version, the supported range (`>=1.2,<2`) and the underlying error. One seam, `grove._mcp_sdk.McpSdk`, serves all three deferred-import sites (this package plus `core/channel.py` and `core/permission.py`) — it lives at the PACKAGE ROOT because `grove.mcp` may not import `grove.core` (the contract above) and a root module importing nothing from Grove is the only home both sides can reach.
- **`grove-mcp` is an unconditional console script, but the `mcp` SDK lives behind the opt-in `[mcp]` extra** — a `.[daemon]`-only host has the script on PATH with no SDK. So the FastMCP import is **deferred** (`_load_fastmcp`, called from `GroveMcpServer.__init__`, guarded by `TYPE_CHECKING` for the annotation): a top-level import would crash the whole `grove.mcp` package at load time (before `main()` runs) with a chained traceback the MCP client buries. `main()` catches the `ImportError` and emits the install hint to stderr + `sys.exit(1)` — clean hint, no traceback.