The local daemon

Decision record: ADR-0002. Security: local-mcp.md.

One process, per user, per machine

flowchart TB
    subgraph Clients["Every MCP client on this machine"]
        MA["Claude Code main agent"]
        SA["Subagents × N"]
        CI["CI scripts"]
        CLI["theurian CLI"]
    end

    MA --> D
    SA --> D
    CI --> D
    CLI --> D

    subgraph D["Theurian daemon — 127.0.0.1:7419"]
        HTTP["Streamable HTTP /mcp<br/>+ GET /health"]
        LOCK["single-instance:<br/>flock + port probe + handshake"]
        REG["project registry"]
        WQ["write queue (1)"]
        RP["read pool (WAL, N)"]
        PUB["index publisher (1)"]
    end

    D --> P1["Project A"]
    D --> P2["Project B"]
    D --> P3["Project C"]

Why not stdio

A stdio MCP server is spawned once per client. In Claude Code that means one per session and, in practice, one per subagent. For Theurian:

Consequence Why it is fatal, not merely wasteful
N write connections to one SQLite file Concurrent writers on a file-backed database corrupt it
N index builders Racing builds publish partial trees
N caches No shared warmth; the tenth agent is as slow as the first
No single publisher The atomic index swap has no owner

The cost of HTTP is that something must start the daemon. That is the entire reason /theurian:setup exists.

Single-instance enforcement

Three mechanisms, because each alone has a known failure mode:

flowchart TD
    S["theurian daemon start"] --> L{"Acquire flock on<br/>~/.theurian/daemon.lock"}
    L -->|acquired| P{"Is the port free?"}
    L -->|held by another| H["Probe /health"]

    P -->|free| BIND["Bind, serve, hold the lock"]
    P -->|occupied| H

    H -->|"healthy, same version + data dir"| REUSE["Exit 0. Reuse the running daemon."]
    H -->|"healthy, different data dir"| ERR1["Error: another Theurian owns this port.<br/>Do not kill it."]
    H -->|"no response"| STALE["Stale lock or a dead process.<br/>Report; never auto-delete data."]

    style BIND fill:#1f6f4a,color:#fff
    style REUSE fill:#1f6f4a,color:#fff
    style ERR1 fill:#8a2f2f,color:#fff
  • A PID file alone is insufficient. PIDs are recycled; a stale file can name a live unrelated process.
  • A lock alone is insufficient. Advisory locks behave inconsistently on some network filesystems, and a deleted lock file loses the guarantee silently.
  • A port probe alone is insufficient. Something else may hold the port, which is why the handshake reports version and data directory.

A losing starter exits 0 after confirming the winner is healthy. It never kills the winner, and it never repairs data automatically — an automatic repair of a suspected corruption is how data gets lost.

Concurrency

Concern Model
Reads N independent connections, journal_mode=WAL, busy_timeout=5000, synchronous=NORMAL, foreign_keys=ON
Writes One asyncio task owning one connection, fed by a queue
Index publication One publisher; active_indexes swapped atomically
External I/O Always outside a write transaction

The external-I/O rule matters more than it looks. Summarizing a document can take seconds. Holding a write transaction across that call blocks every other write for the duration and turns a slow model into a stalled daemon. The pattern is always: read → release → call → re-acquire → write.

Request lifecycle

sequenceDiagram
    participant C as MCP client
    participant D as Daemon
    participant A as AuthorizationProvider
    participant S as State resolver
    participant R as Retrieval

    C->>D: POST /mcp — tool call + context
    D->>D: Validate Origin and Host
    D->>D: Verify the bearer token (constant time)
    D->>D: Validate input against the tool's JSON Schema
    D->>A: May this principal read projectId?
    A-->>D: yes / no
    D->>S: Resolve snapshotId (pinned, or the current active state)
    S-->>D: state hash + active index build
    D->>R: Query with pre-filters (project, status)
    R-->>D: Results with provenance
    D-->>C: Results + trust labels

Every step before retrieval is a gate. None of them consult ambient state: the project, the snapshot, and the principal all come from the request.

The pre-filter step said "(tenant, ACL, sensitivity, validity)" until this pass, which is FR-R1's list of axes rather than the two SqliteIndexStore._scope emits. Filtering happens before ranking, which is the property FR-R1 exists for and which does hold; enforcing the other three axes (tenant, ACL, sensitivity) is #119, the successor to #63.

Health endpoint

GET /health is unauthenticated and returns only:

{ "status": "ok", "version": "0.4.0", "protocolVersion": "theurian/v1", "uptimeSeconds": 3421 }

Deliberately uninformative. This is what the SessionStart hook calls, and a health check that needed a credential would push credential handling into a hook that runs on every session.

OS service registration

Per user. Never root, never sudo.

Platform Mechanism Location
macOS LaunchAgent ~/Library/LaunchAgents/dev.theurian.daemon.plist
Linux systemd user unit ~/.config/systemd/user/theurian.service
Windows Task Scheduler interface defined; not a 1.0 gate
Container Docker Compose interface defined

Behind the DaemonManager port, with two hard rules: installation happens only from an explicit user action, and status() is cheap and side-effect-free because SessionStart calls it.

Failure modes

Situation Behaviour
Port occupied by another Theurian Reuse if the data directory matches; otherwise error without killing it
Port occupied by something else Clear error naming the port and the occupant
Stale lock, no listener Report it; suggest theurian doctor. Never auto-delete state
Daemon crashes mid-index-build The build is abandoned; the previous active index still serves; the partial build is garbage-collected explicitly
Two setup invocations race One wins; the other converges. Tested
Database corrupted Refuse to serve; report; suggest a rebuild from Git-tracked migrations — which is always possible (ADR-0004)