Skip to main content

MCP Server

Daintree's local Model Context Protocol server: the four authorization tiers, per-client connect config, host-side confirmation, time-bounded grants, and the audit log.

Reviewed

What the MCP server is

Daintree runs a local Model Context Protocol server, and it inverts the usual relationship. Most MCP servers are tools your agent connects to. Here Daintree is the server. An external client (Claude Code, Codex, or anything that speaks Streamable HTTP) connects to it on 127.0.0.1 and drives the running habitat: launching agents into worktrees, creating worktrees from recipes, polling terminal state, waiting for an agent to go idle. Daintree's own in-app assistant connects to the same server through internal sessions.

The selection rule for the external surface follows from that inversion: keep what only Daintree can do, and drop what the caller can already do for itself. An external agent driving Daintree sits in a terminal with its own shell and its own gh, so git plumbing, forge reads and writes, file reads and project queries are its job, not Daintree's. Worktree lifecycle, recipe execution, agent orchestration and live habitat context have no shell equivalent, so those are what the server exposes.

The server is loopback-only, opt-in, and disabled by default. The default port is 45454. The primary transport is Streamable HTTP at /mcp. A legacy SSE endpoint at /sse still answers for in-app and older callers, but it was deprecated by the MCP spec in revision 2025-03-26 and clients vary in whether they attach the auth header to its separate POST leg: no external client should be pointed at it.

Enable the server

Open Settings (Command-, Control-, Control-, ) and switch to the MCP server tab. Flip the master toggle. The tab is organized as Connection, Port, Authentication, Audit log, and Turn outcome diagnostics.

The server reports one of four runtime states: disabled, starting, ready, or failed. When it is ready, the Connection section shows the bound port and the exact URL a client should use.

If 45454 is taken, Daintree tries the next ten ports in sequence, up to 45464. The URL shown in Connection always reflects the port that was actually bound, so re-copying the config after a fallback is enough to fix a stale client.

Settings > MCP server, with the Connection section expanded

Connect a client

The Connection section has a Client picker with three options. Pick the client you are connecting and press Copy MCP config: the snippet arrives in that client's own format. The URL displayed on the tab and the URL inside the copied snippet come from one computation, so the two cannot drift.

ClientFormatWhere it goes
Claude CodeJSON.mcp.json in your project, or ~/.claude.json for every project
CodexTOML~/.codex/config.toml
Other clientPlain transport detailsEntered by hand in any Streamable HTTP client

The Claude Code snippet is the shape claude mcp add --transport http … --header writes:

{
  "mcpServers": {
    "daintree": {
      "type": "http",
      "url": "http://127.0.0.1:45454/mcp",
      "headers": {
        "Authorization": "Bearer daintree_<your-key>"
      }
    }
  }
}

The Codex snippet uses a literal http_headers table rather than an environment variable, because the key is copy-pasted rather than injected: Daintree cannot reach the environment of an external Codex process, and the key rotates from this same tab, which would strand an exported variable:

[mcp_servers.daintree]
url = "http://127.0.0.1:45454/mcp"
http_headers = { Authorization = "Bearer daintree_<your-key>" }

Other client copies the transport-level truth instead of a config file, for clients whose config schema Daintree has not verified:

Transport: Streamable HTTP
URL: http://127.0.0.1:45454/mcp
Header: Authorization: Bearer daintree_<your-key>
Note

Cursor's and VS Code's config schemas are deliberately not claimed as verified here. If you are connecting one of those, use the Other client values and enter them in whatever form that client expects.

Connected external clients are listed under the Connection section, keyed by a hash of the bearer token and labelled with the client's user agent and the last four characters of its key. Each row has a Disconnect button that revokes that client's sessions without rotating the key for everyone else.

API key and the trust boundary

Daintree generates an API key on the first server start, in the format daintree_<32-hex>. It is stored in the app's electron-store and survives restarts. The server validates incoming bearer tokens by comparing precomputed SHA-256 hashes with crypto.timingSafeEqual. A missing or wrong token returns 401 Unauthorized with WWW-Authenticate: Bearer realm="Daintree MCP", writes an auth401 audit record, and ticks the abuse counter.

To rotate, use Rotate API key in the Authentication section. Rotation invalidates every external client holding the previous key at once, so re-copy the config into each one afterwards.

On top of the loopback bind, the transport enforces DNS-rebinding protection: the Host and Origin headers must be 127.0.0.1:<port> or localhost:<port>. Anything else is refused. That second check is what makes the empty-auth fallback below safe.

Authorization tiers

Every session resolves to exactly one of four tiers. Three of them form the in-app ladder used by the Daintree Assistant; the fourth is for external API-key clients.

TierWho gets itWhat it grantsTools
workbenchThe in-app assistant's read-only setting, and the fallback baseline for any in-app sessionRead-only introspection: project and worktree reads, file search and file view, terminal listing and scrollback, git and forge reads, agent state, skills.60
actionThe in-app assistant's defaultWorkbench plus in-app orchestration: create worktrees from recipes, spawn and restart terminals, launch agents, send and inject prompts, wait for idle, run a project check, drive the browser and dev preview, pick themes.110 cumulative
systemPer-assistant opt-inAction plus operations that touch disk or external services: delete worktrees, stage and commit and push, arm and disarm terminals for fleet broadcast, write the system clipboard, and the full set of forge writes.149 cumulative
externalAPI-key bearersAn independently curated flat allowlist of orchestration tools. A peer of the ladder, not a rung above system: it is not a superset of anything.25

There is no off tier. Turning the in-app assistant's Daintree control off is a separate switch from choosing its tier.

How a session lands in a tier:

  • API-key bearer. The Authorization header matches the configured key on a timing-safe comparison, so the session is external.
  • Empty-auth fallback. No key configured and an empty Authorization header also resolves to external. This dies the moment a key is set, and it is only safe because non-loopback Host and Origin values are already refused.
  • Per-pane token. An in-app assistant pane's own token maps to workbench, action or system from that pane's configuration. Never external.
  • Help-session token. Pinned to the WebContents that minted it and to the action context captured at provision time, so calls dispatch against the worktree you had focused when you launched the assistant.

Anything else falls to the workbench baseline. If getTier finds no live transport at all the call fails closed with SESSION_GONE rather than defaulting to a tier: a revoked bearer must not quietly widen onto the in-app baseline.

The in-app tier picker lives in Settings > Daintree Assistant, and defaults to Action. See Daintree Assistant for what each tier means in that context.

For the tool-by-tool reference (the 25 external tools, why the surface was cut, the payload budgets and what forge over MCP can reach), see Tool surface and budgets.

Two gates, and no third state

Authorization is enforced twice, on purpose. isTierPermitted owns tier membership and runs at tools/call. shouldExposeTool runs at tools/list and layers two hard ceilings on top (a tool marked danger: "restricted" or mcpVisibility: "hidden" is never advertised), then defers to the same membership check, so listing and dispatch can never drift apart.

A third gate narrows introspection. actions.list, actions.search and actions.getSchema are filtered in the main process against the calling session's own tier, so discovery describes the session's surface rather than reaching past it.

There is deliberately no state where a tool is withheld from tools/list but stays dispatchable. An earlier "discoverable" visibility tried that and it does not work: shipped clients build their tool registry from tools/list and reject unlisted names before they ever become requests, so withholding a name is indistinguishable from revoking it. The advertised set and the callable set are the same boundary.

Widening a session, and its limits

When an in-app assistant calls a tool above its tier, the call is denied and a banner names the tool. Two ways to widen, both time-bounded:

  • Approve once mints a per-tool grant keyed on (sessionId, toolId) with a 15-minute sliding TTL, refreshed on each successful dispatch, under a hard 30-minute wall-clock ceiling that the sliding window cannot extend.
  • Always allow elevates the whole session, and decays back to workbench after 30 minutes of awake time. Sleep and wake do not extend it.

Both target the narrowest sufficient tier rather than blanket-elevating to system. Both TTLs sit at or under the 30-minute idle session timeout, so neither can outlive the session that holds it. Grant lifecycle events (grant.issued, grant.expired, grant.revoked) interleave with tool-call records in the audit log.

If the same (sessionId, toolId) pair is denied twice in a row the banner is suppressed for the rest of the session, but every denial still writes an audit record carrying bannerSuppressed: true. The counter resets when a grant is issued or the session ends.

Confirmation is host-side, always

Tools annotated danger: "confirm" are exposed and dispatchable when the tier permits, but the call is dispatched unconfirmed so a human approves it in Daintree's own native confirmation dialog. The dialog shows the action title, its description, and a redacted argument summary: long strings collapse to <string: N chars> and nested objects to <object>, so a secret in an argument does not land in the prompt.

The renderer cancels a pending confirmation after 28 seconds, two seconds inside the 30-second main-process dispatch deadline, so the audit log records a clean timeout rather than a torn-down dispatch. Concurrent confirm calls queue behind the visible dialog. While it is open the audit record sits at confirmation-pending and then resolves to approved, rejected or timeout.

Raised by Daintree, not by the caller: an external Claude Code client asked for worktree.delete

Security posture over MCP

The MCP surface is the one place where a non-human caller reaches into a running habitat, so several boundaries exist only here.

  • project.getSettings no longer returns decrypted secrets. It projects the full settings payload (which carries decrypted secure environment variables and a large icon blob) down to an agent-visible field set before returning, and dispatch parses that result against the action's declared schema as a second boundary. Its own description still warns that run-command strings come back verbatim and may have a credential inlined by whoever wrote them.
  • Introspection is scoped to the calling session's tier. actions.list, actions.search and actions.getSchema describe the caller's own surface, not the registry.
  • Destructive calls require host confirmation and never trust client elicitation. See above.
  • An unpinned tool call routes by focus order, and reports where it landed. An external session follows window focus on every call, so a long-running agent's calls can retarget mid-session when you switch workspace. Every dispatch reports the workspace it actually resolved to: its kind (project or scratch), id and path, so that drift is observable to the caller instead of silent.
  • Terminal dispatch requires an explicit terminal id. An agent or MCP caller sending input to "the terminal" is no longer resolved implicitly.
  • The external API-key fullToolSurface opt-in is gone. It short-circuited both tier gates and treated the author-set danger and mcpVisibility fields as the ceiling, which exposed 335 of 426 actions to any API-key caller. It was removed outright rather than left dormant: the MCP spec is explicit that tool annotations are untrusted UX hints, not an access-control boundary, and the server-side allowlist is the enforceable one. Nothing widens the external allowlist.
  • The github.* action aliases were removed in v0.18. github.listIssues, github.listPullRequests, github.getIssueByNumber, github.checkCli, github.getRepoStats and github.openPR do not exist at any tier. Everything forge-shaped is forge.* and routes through the provider abstraction described in Code Forge.

For the process-level picture (sandboxing, IPC hardening, secret scrubbing, git hardening), see Security & Privacy.

Audit log

Every dispatch writes a record to a ring buffer, capped at 500 entries by default and configurable between 50 and 10,000 in the Audit log section. Records carry an id, timestamp, tool id, session id, tier, a redacted argument summary, the result, a severity, and optional errorCode, durationMs, confirmationDecision, tierHint, bannerSuppressed, turnId and repeatCount fields. Grant lifecycle events share the buffer and carry a discriminator so they can be filtered apart.

Filters narrow the view by time range and by result. The viewer also renders a per-tool latency table — n, p50 and p95 columns, with a separate row per outcome, success and failed. The SLO band is tagged against the p95 alone: Instant under 200 ms, Fast under 1000 ms, Standard up to 5000 ms, Slow beyond that. A p95 of zero carries no band.

Once the buffer holds at least 50 records, four anomaly detectors run against new dispatches:

  • Latency drift: a per-tool modified z-score using the median absolute deviation; 3 or higher flags the record.
  • Failure cluster: three or more failures inside any 10-record sliding window.
  • p95 outlier: a tool's p95 measured against the median p95 across all tools, with a five-tool minimum.
  • First-seen combination: the first appearance of a given tool-and-tier pair.

Export the buffer as newline-delimited JSON, or copy it as pretty-printed JSON. Clear wipes it behind a confirmation.

The same viewer and latency table also sit on Settings > Daintree Assistant, under Advanced diagnostics. That copy deliberately drops external-tier records: it is there to account for what the in-app assistant did, so it never shows external client traffic. Read the MCP server tab's own Audit log section for that.

The Assistant tab’s copy of the viewer, which excludes external-tier records

Turn outcome diagnostics

When an in-app assistant moves from active to passive, Daintree classifies the turn against a priority waterfall of eleven outcomes: answered, hedged, refused, no docs found, tier rejected, MCP not ready, agent stuck, tool error, resume stale, reasoning loop, and unknown. Every tool call inside the turn carries the same turn id, so the audit log can be filtered or aggregated per turn. The Turn outcome diagnostics section shows the per-tool rollups.

The two worth acting on are tier rejected, which means a tier or grant denial ended the turn and the assistant probably needs a wider tier, and reasoning loop, which fires when the same tool and argument summary repeat three or more times inside one turn.

Runtime state, restart and the abuse cap

If the HTTP server closes unexpectedly, a supervisor restarts it with exponential backoff: a 500 ms base, a 2× multiplier, capped at 15 seconds, with ±250 ms of jitter per attempt. Thirty seconds of stable uptime resets the counter. After five consecutive failures the supervisor parks the server in failed and shows the last error next to the toggle.

Sessions that go 30 minutes without activity are reaped, which revokes their grants with reason session-ended. The next call re-authenticates and mints a fresh session; most clients reconnect without you noticing.

There is also an abuse policy, off by default. Turn it on and the server counts 401s and tier-mismatch denials in a 60-second sliding window. Five denials revokes the session and notifies the renderer. Reconnecting mints a fresh one: it is a brake on a misconfigured or looping client, not a lockout.

Troubleshooting

  • 401 with a config you just copied. Some clients drop or misplace the Authorization header on Streamable HTTP. Re-copy the snippet for your specific client from the picker rather than adapting another client's. Check the key has not been rotated since you pasted it.
  • TIER_NOT_PERMITTED. The tool exists but this session's tier does not reach it. For an in-app assistant, raise the tier in Settings > Daintree Assistant or approve the call from the banner. For an external client, the tool is off the curated allowlist and nothing widens it. Do that work in the caller's own shell instead.
  • Connection refused, or a 404. Daintree may have bound a fallback port. Read the URL from the Connection section and re-copy the config.
  • Wrong transport. Point external clients at /mcp. /sse is legacy and its header handling across clients is unreliable.
  • A confirm-gated call refused with CONFIRMATION_REQUIRED. No Daintree window was open to show the dialog. Open the app and retry.
  • Idle expiry. A client idle for 30 minutes loses its session and reconnects on the next call.
  • Loopback rejected. The transport requires Host and Origin to be loopback. Proxies and container setups that rewrite them will be refused.
  • Tool surface and budgets: the 25 external tools, the payload caps, forge over MCP, resources and prompts.
  • Daintree Assistant: the in-app client that uses the workbench, action and system tiers.
  • Security & Privacy: the process-level model this server sits inside.
  • Code Forge: provider setup for the forge.* tools.
  • Recipes: what recipe.run and worktree.createWithRecipe execute.
  • Plugins: plugin-contributed MCP servers, which are a separate surface from this one.