Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 26 additions & 1 deletion hyperdb-mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

### Added

- The `status` tool now reports an `engine` block with the backing `hyperd`
connection: `mode` (`daemon` or `local`), `hyperd_endpoint` (the libpq
endpoint queries run against), and `daemon_health_port` (the shared daemon's
control/lock port, `null` in local mode).
- **Single-instance `hyperd` daemon** — by default, all MCP clients now
share one `hyperd` process per user instead of each spawning their own.
Multiple AI clients (Claude Code, Cursor, VS Code Copilot, etc.) can
access the same persistent databases simultaneously with reduced
resource overhead. The daemon auto-spawns on first client connect and
shuts down after 30 minutes idle. Pass `--no-daemon` to opt out.
stays resident (idle shutdown is opt-in — see below). Pass `--no-daemon`
to opt out.
- **Identity-checked daemon discovery.** Clients verify a daemon by sending
`PING` and requiring a `PONG hyperdb-mcp <version>` reply (matched on exact
tokens, not a string prefix) before trusting it — a TCP connection alone is
no longer sufficient, so an unrelated process occupying the port is no
longer mistaken for the daemon.
- **Port scanning.** The daemon health/lock port now defaults to scanning
upward from **7485** (16 ports), using the first free one; the old fixed
default 7484 collided with `hyperd`'s conventional gRPC port. Set
`HYPERDB_DAEMON_PORT` to pin an exact port (disables scanning). `daemon
status` / `daemon stop` locate the daemon via discovery + scan, so they
work regardless of which port it landed on.
- **Newer-client version takeover.** A starting client built from a strictly
newer `hyperdb-mcp` version stops and replaces an older running daemon
(and its `hyperd`), so upgrades take effect immediately instead of waiting
for the old daemon to exit. Equal or older versions reuse the daemon.
- **Daemon stays resident by default.** Idle shutdown is now opt-in via
`--idle-timeout <SECS>` or `HYPERDB_DAEMON_IDLE_TIMEOUT`; with neither set
the daemon (and `hyperd`) stay warm, eliminating the connection error and
"hyper restarting, please retry" round-trip a client previously hit after
a 30-minute idle shutdown.
- New `daemon` subcommand: `hyperdb-mcp daemon status` / `daemon stop`.
- New environment variables: `HYPERDB_STATE_DIR`, `HYPERDB_DAEMON_PORT`,
`HYPERDB_DAEMON_IDLE_TIMEOUT`.
Expand Down
1 change: 1 addition & 0 deletions hyperdb-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ rmcp = { version = "1.7", features = ["server", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "signal", "time"] }
serde = { workspace = true }
serde_json = { workspace = true, features = ["preserve_order"] }
semver = "1"
clap = { version = "4", features = ["derive"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Expand Down
12 changes: 7 additions & 5 deletions hyperdb-mcp/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,15 @@ Logs land next to the persistent file when one is supplied (so users find them i

## Daemon Mode Internals

`Engine::new` defaults to *daemon mode* — it tries `daemon::spawn::ensure_daemon()` first, which discovers an existing daemon via `~/.hyperdb/daemon.json` (overridable via `HYPERDB_STATE_DIR`) or auto-spawns one as a detached background process. The Engine then connects via TCP (`Connection::connect(endpoint, …)`) without owning any `HyperProcess`.
`Engine::new` defaults to *daemon mode* — it tries `daemon::spawn::ensure_daemon(resolve_port_scan())` first, which discovers an existing daemon via `~/.hyperdb/daemon.json` (overridable via `HYPERDB_STATE_DIR`), else scans the port range for a running daemon, else auto-spawns one on the first free port as a detached background process. The Engine then connects via TCP (`Connection::connect(endpoint, …)`) without owning any `HyperProcess`, and records the daemon's `health_port` so the server's debounced `HEARTBEAT` targets the actual discovered port rather than re-resolving.

Falls back to local mode (per-session `hyperd` via `HyperProcess::new`) when the daemon can't be reached, or always when `--no-daemon` is passed.
Falls back to local mode (per-session `hyperd` via `HyperProcess::new`) when the daemon can't be reached (including `AllOccupied` — the whole scan range is held by foreign processes), or always when `--no-daemon` is passed.

Cross-platform single-instance lock is the daemon's TCP health port — bind succeeds for exactly one process per user. Liveness is validated by the discovery flow before trusting the file: a stale `daemon.json` (daemon crashed) is detected and removed.
**Port resolution + identity.** `resolve_port_scan()` returns a `PortScan { base, span }`: when `HYPERDB_DAEMON_PORT` is set it pins that exact port (`span = 1`); otherwise it scans `span = DAEMON_PORT_SCAN_SPAN` (16) ports up from `DEFAULT_DAEMON_BASE_PORT` (7485 — deliberately *not* 7484, which is hyperd's conventional gRPC port). `probe_port` classifies each port as `OurDaemon` / `Camped` / `Refused`: liveness is no longer a bare TCP connect but an identity handshake — `health::ping_identified` sends `PING` and requires the reply's first two tokens to be exactly `PONG` and `hyperdb-mcp` (the third token is the daemon version). A foreign process that merely accepts TCP is `Camped` and skipped; only a `Refused` (connection-refused) port is treated as free to spawn on. `discover()` applies the same identity check before trusting `daemon.json`, so a stale or foreign-owned file is detected and removed.

The daemon's main loop tracks idle time via `DaemonState::last_activity`. `HEARTBEAT` commands from active clients reset the timer; clients debounce these to once per 60 seconds in `HyperMcpServer::with_engine`. Idle timeout (default 30 min) triggers graceful shutdown: discovery file removed → `hyperd` dropped → health listener exits.
**Version takeover.** When discovery finds a running daemon, `maybe_take_over` compares the client's `version::MCP_VERSION` against the daemon's reported version via the pure `client_should_take_over` helper (`semver`). If the client is *strictly newer* it sends `STOP` (which drops the daemon's `HyperProcess`, stopping `hyperd`), waits for the health port to stop answering the identity ping, then respawns a fresh daemon on the same port. Equal/older/unparseable versions reuse the daemon — never a downgrade-kill. This makes upgrades take effect immediately instead of waiting for the old daemon to disappear.

**Idle shutdown is opt-in.** `DaemonConfig.idle_timeout` is `Option<Duration>`, set only when `--idle-timeout` or `HYPERDB_DAEMON_IDLE_TIMEOUT` is provided (flag wins over env). With neither set the idle-monitor branch of the `run_daemon` `tokio::select!` is replaced by `std::future::pending()` and never fires — the daemon (and `hyperd`) stay resident indefinitely so clients never pay the cold-start "restarting, please retry" round-trip. `DaemonState::last_activity` and the debounced `HEARTBEAT` plumbing still exist and only matter when the timeout is enabled. The hyperd restart-limit shutdown (below) is independent and always active.

### hyperd liveness monitoring and restart

Expand All @@ -242,7 +244,7 @@ Two new code paths fire `report_hyperd_error_to_daemon` (best-effort, 200ms time

### Known limitations

- **Hung-but-alive `hyperd`** (TCP listening, but unresponsive to queries) is NOT detected. The monitor's `try_wait()` returns `None` for a hung process; client tool calls hang on the read side without producing a `ConnectionLost` error. Operator recovery is `hyperdb-mcp daemon stop` followed by reconnect.
- **Hung-but-alive `hyperd`** (TCP listening, but unresponsive to queries) is NOT detected. The monitor's `try_wait()` returns `None` for a hung process; client tool calls hang on the read side without producing a `ConnectionLost` error. Operator recovery is `hyperdb-mcp daemon stop` followed by reconnect. Note the tradeoff introduced by resident-by-default: the idle timeout used to be an implicit backstop that reaped a wedged daemon after 30 min, after which the next client respawned a fresh one. With idle shutdown now opt-in (off by default), a hung-but-alive `hyperd` stays wedged until a client reports an error (fast-path `REPORT_HYPERD_ERROR`, which fires on a client-side `ConnectionLost`) or an operator runs `daemon stop`. This is an accepted tradeoff — keeping `hyperd` warm avoids the cold-start "restarting, please retry" round-trip on every active session, and genuine hyperd hangs are rare. A future enhancement could add a daemon-side liveness probe (a periodic trivial query with a timeout) to close the "all clients idle + hyperd hung" gap without reintroducing cold-start latency.
- **Watchers** auto-recover from hyperd restarts: when an ingest fails with a connection-lost error, the watcher rebuilds its connection pool against the engine's current endpoint and retries the file once. Persistent failures (the second attempt also fails) fall through to the standard `failed/` move so a single broken file can't keep the watcher pinned in retry loops.

See `src/daemon/{mod,discovery,health,run,spawn}.rs` for the full implementation.
Expand Down
20 changes: 14 additions & 6 deletions hyperdb-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ Each session has **two databases**: an ephemeral primary (scratch space — alwa

| Mode | Flag | Behavior |
|---|---|---|
| **Shared daemon** *(default)* | *(none)* | One `hyperd` process per user, shared across all MCP clients. The first client auto-spawns the daemon; subsequent clients discover and reuse it. Idle for 30 minutes → daemon shuts itself down; the next client spawns a fresh one. |
| **Shared daemon** *(default)* | *(none)* | One `hyperd` process per user, shared across all MCP clients. The first client auto-spawns the daemon; subsequent clients discover and reuse it. The daemon stays resident by default (idle shutdown is opt-in — see below), so the next client connects instantly instead of waiting for a fresh `hyperd` to start. A client built from a newer `hyperdb-mcp` version transparently takes over (stops and replaces) an older running daemon. |
| **Private hyperd** | `--no-daemon` | Each MCP client spawns its own `hyperd` (legacy behavior, one per session). |

The shared daemon is the bigger win for users running multiple AI clients (Claude Code + Cursor + VS Code) — they all share one Hyper engine instead of spawning three.
Expand Down Expand Up @@ -240,16 +240,22 @@ CREATE TABLE "persistent"."public"."revenue_2026" AS

### Daemon management

The daemon is normally invisible — it auto-spawns and idle-times-out on its own. For diagnostics:
The daemon is normally invisible — it auto-spawns on first use and stays resident. For diagnostics:

```bash
hyperdb-mcp daemon status # Show running daemon (PID, endpoint, started_at, version)
hyperdb-mcp daemon stop # Gracefully shut down the daemon
hyperdb-mcp daemon # Run as a daemon explicitly (rarely needed)
```

`status` and `stop` locate the running daemon automatically (reading `daemon.json`, then scanning the port range), so they work even if the daemon scanned onto a non-default port. Pass `--port <PORT>` to target a specific port explicitly.

State files live at `~/.hyperdb/` by default (override with `HYPERDB_STATE_DIR`).

**Port discovery.** The daemon binds a TCP health/lock port — by default it scans upward from **7485** (16 ports) and uses the first free one; set `HYPERDB_DAEMON_PORT` to pin an exact port (no scan). The health port doubles as a single-instance lock and an identity check: clients send `PING` and require a `PONG hyperdb-mcp <version>` reply before trusting a daemon, so an unrelated process occupying the port is skipped rather than mistaken for the daemon.

**Staying resident.** By default the daemon never idle-shuts-down — keeping `hyperd` warm means the next tool call connects immediately instead of triggering a "restarting, please retry" round-trip. To opt into auto-shutdown (e.g. on CI), pass `--idle-timeout <SECS>` or set `HYPERDB_DAEMON_IDLE_TIMEOUT`.

### Recovery from hyperd crashes

The daemon polls `hyperd` every 5 seconds. If the process has exited (crashed, OOM, killed), the daemon spawns a replacement, atomically updates `~/.hyperdb/daemon.json` with the new endpoint, and continues serving clients. Clients see one failed tool call (the request that was in flight when hyperd died); the next tool call transparently reconnects to the new hyperd via the same recovery path used for normal connection drops.
Expand Down Expand Up @@ -813,15 +819,17 @@ Daemon subcommand:
hyperdb-mcp daemon Start the daemon (usually auto-spawned)
hyperdb-mcp daemon stop Gracefully stop the running daemon
hyperdb-mcp daemon status Show running daemon info
hyperdb-mcp daemon --port <PORT> Override the health/lock port (default 7484)
hyperdb-mcp daemon --idle-timeout <SECS> Override idle timeout (default 1800 = 30 min)
hyperdb-mcp daemon --port <PORT> Pin the health/lock port. When omitted,
scans upward from 7485 for a free port.
hyperdb-mcp daemon --idle-timeout <SECS> Opt into idle shutdown after SECS idle.
When omitted, the daemon stays resident.

Environment:
HYPERD_PATH Path to hyperd binary (auto-detected if on PATH)
HYPERDB_PERSISTENT_DB Override the default persistent-db path
HYPERDB_STATE_DIR Override daemon state directory (default ~/.hyperdb/)
HYPERDB_DAEMON_PORT Override daemon health/lock port (default 7484)
HYPERDB_DAEMON_IDLE_TIMEOUT Override daemon idle timeout in seconds (default 1800)
HYPERDB_DAEMON_PORT Pin daemon health/lock port (default: scan from 7485)
HYPERDB_DAEMON_IDLE_TIMEOUT Opt into idle shutdown (seconds); default: stay resident
```

---
Expand Down
Loading
Loading