diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..c586361 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,5 @@ +{ + "MD024": { + "siblings_only": true + } +} diff --git a/Cargo.lock b/Cargo.lock index a7c5879..957b8ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -592,9 +592,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", diff --git a/docs/superpowers/plans/2026-08-13-hyperdb-mcp-agent-ux.md b/docs/superpowers/plans/2026-08-13-hyperdb-mcp-agent-ux.md new file mode 100644 index 0000000..624cb94 --- /dev/null +++ b/docs/superpowers/plans/2026-08-13-hyperdb-mcp-agent-ux.md @@ -0,0 +1,1054 @@ +# HyperDB MCP Agent UX and Operational Diagnostics Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: use +> `superpowers:subagent-driven-development` and execute this plan task by task. +> Steps use checkbox (`- [ ]`) syntax for tracking. The main thread owns plan +> revision, commits, final validation, and merge-readiness judgment. + +**Goal:** Make HyperDB MCP installation/runtime failures diagnosable, make every +routed result identify its effective database, establish a measured 33-tool +catalog contract, and improve the built-in chart for common diagnostics without +changing defaults or breaking the published Rust structs. + +**Architecture:** Add a pure diagnostics/identity layer shared by a new +side-effect-free CLI doctor and MCP status; enrich daemon wire records without +changing public `DaemonInfo`; classify SQLSTATE `55006` only at persistent +attach; centralize additive `resolved_database` response metadata; preserve the +full router while measuring its generated schema; and route new MCP chart +options through an internal presentation type so public `ChartOptions` remains +source-compatible. + +**Tech stack:** Rust workspace; `hyperdb-mcp`; rmcp 1.8 generated tool router; +Clap; serde/schemars; Plotters 0.3.7; CommonJS Node wrapper; real `hyperd` via +`HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd`; Conventional Commits. + +**Design specification:** +`docs/superpowers/specs/2026-08-13-hyperdb-mcp-agent-ux-design.md` + +**Final integration base:** `origin/main` @ `e609061` (plan originally approved at `87e0b9d`) +**Branch:** `codex/hyperdb-mcp-agent-ux` +**Worktree:** +`/Users/ssteiner/Documents/Codex/2026-08-12/insta/hyper-api-rust-mcp-ux` + +--- + +## Global constraints + +These constraints apply to every task and every agent. + +- Read and obey repository `AGENTS.md`, `CLAUDE.md`, and affected neighboring + code before editing. Search the whole repository before concluding an API, + test, or documentation surface is absent. +- Use `apply_patch` for edits. Preserve unrelated/user changes. Never reset, + clean, delete, or rewrite the original checkout. +- Hyper-backed commands use exactly + `HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd`; never invent `hyperd` flags. +- Hyper tests need local callback-listener permission in this environment. A + sandbox `Failed to create callback listener` is environmental, not a product + regression; rerun the identical command with loopback permission and retain + both outputs in the evidence log. +- Do not add narrowing integer `as` casts. When a touched line already performs + one, replace it with `TryFrom` plus an explicit saturation/error policy. +- Validate untrusted launcher JSON and chart options. Do not echo unknown + launcher fields, environment contents, or secrets. +- Do not change the default database, default tool surface, read-only listing, + daemon takeover comparison, public `ChartOptions`, or public `DaemonInfo`. +- Update `hyperdb-mcp/src/readme.rs` whenever a chart/tool schema changes. +- Append user-visible entries only to `hyperdb-mcp/CHANGELOG.md` under + `## [Unreleased]`; do not edit versions or the root generated changelog. +- A command is green only when its real output and zero exit status were seen. + No output for roughly 30 seconds is a hang/failure requiring investigation. +- Developer/tester agents do not commit. After an independent task reviewer has + no unresolved Critical or Important finding, the main thread stages explicit + paths and makes the task's Conventional Commit. + +## Harness execution protocol + +For every behavioral task: + +1. A tester agent owns the named test files and proves every planned new + assertion fails against the current branch for the intended reason. Each + task below names its red test functions and exact command. The tester must + capture the harness's nonzero executed-test count (or Node TAP test count) + and the expected assertion/compiler failure; a Cargo filter that reports + zero tests is a failed gate even when Cargo exits zero. Use `-- --exact` + whenever one fully qualified Rust test is selected. + When a genuinely new Rust interface makes the first red a compiler error, + capture that nonzero compiler failure first; the tester may then add only the + smallest `unimplemented!()` DI/signature seam allowed by the tester role and + rerun to obtain an executed failing assertion. The engineer must replace the + seam before any green claim. + A table-driven test covering multiple routed tools/shapes must execute every + case and accumulate named mismatches before its final assertion; it must not + use `?`, `expect`, or an assertion inside the loop that short-circuits later + red evidence. +2. An engineer agent owns the named production files and makes the proven-red + test green with the smallest conforming change. It must not revert work from + earlier tasks or unrelated agents. +3. The engineer runs the focused suite plus: + + ```bash + cargo fmt --all --check + cargo clippy -p hyperdb-mcp --all-targets --all-features -- -D warnings + ``` + +4. A fresh read-only reviewer receives the relevant spec/plan sections, the + complete task diff, and captured red/green/lint output. It reports only + Critical / Important / Minor findings and a merge verdict. +5. Critical/Important findings return to an engineer, followed by a fresh + re-review. The main thread independently verifies every claimed fix. +6. The main thread commits explicit paths only after the task gate is satisfied. + +When a behavioral task changes README/help/tool prose, its tester adds the +semantic documentation assertion and records it red before that task edits the +prose. Task 14 tests only documentation drift deliberately deferred by Tasks +1-13; it does not retroactively claim already-green documentation assertions as +red-before-green evidence. + +Compatibility characterizations are explicit exceptions to red-before-green: +Task 1's existing catalog contract, +`legacy_daemon_info_literal_is_source_compatible` in Task 3, and +`callback_connection_shutdowns_hyperd_after_parent_kill` in Task 7, and +`legacy_chart_options_literal_is_source_compatible` in Task 11 intentionally +pass on the unchanged base. They pin existing behavior/source/lifecycle +compatibility and must be recorded as passing characterizations, never +misreported as red tests. + +## File map + +| Area | Primary files | Tasks | +|---|---|---| +| Catalog measurement | `hyperdb-mcp/src/server.rs`, `hyperdb-mcp/tests/tool_schema_tests.rs` | 1 | +| Installation identity | `hyperdb-mcp/src/diagnostics.rs`, `src/lib.rs`, `npm/bin.js`, `npm/bin.test.js` | 2 | +| Daemon record | `src/daemon/discovery.rs`, `health.rs`, `run.rs`, `tests/daemon_tests.rs` | 3 | +| Doctor CLI | `src/main.rs`, `src/paths.rs`, `src/diagnostics.rs`, `tests/doctor_tests.rs`, README | 4 | +| Daemon routing fixes | `src/main.rs`, `src/daemon/health.rs`, `src/engine.rs`, `src/server.rs` | 5 | +| Status contract | `src/diagnostics.rs`, `src/engine.rs`, `src/server.rs`, MCP/resource tests | 6 | +| Lock classification | `src/engine.rs`, `src/error.rs`, engine/error tests | 7 | +| Routed query results | `src/server.rs`, `tests/end_to_end_mcp_tests.rs` | 8 | +| Routed data results | `src/server.rs`, per-tool/end-to-end tests | 9 | +| Routed KV/copy results | `src/server.rs`, KV/end-to-end/schema tests | 10 | +| Chart foundations | `src/chart.rs`, `src/server.rs`, `src/readme.rs`, chart/MCP tests | 11 | +| Horizontal bars | same chart surfaces | 12 | +| Log measure scale | same chart surfaces | 13 | +| Documentation sweep | README, concise README, smoke guide, demo, changelog, resource tests | 14 | +| Integrated validation/memory | whole diff, `ssteiner-ai/notes`, applicable agent profiles | 15 | + +--- + +### Task 1: Characterize and budget the generated MCP catalog + +**Owners:** tester (test), engineer only if a minimal read-only snapshot helper +is required. +**Files:** + +- Create: `hyperdb-mcp/tests/tool_schema_tests.rs` +- Modify, only if needed: `hyperdb-mcp/src/server.rs` +- Reference: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs:1-121` + +- [ ] Reuse the in-memory rmcp duplex pattern to call `list_all_tools()` on an + un-warmed server; do not start Hyper merely to inspect schemas. +- [ ] Assert the exact sorted legacy list of 33 names and that `doctor` is not a + tool. Assert the full profile is unchanged by read-only mode. +- [ ] Add exactly named tests + `generated_catalog_preserves_full_33_tool_contract`, + `generated_catalog_budget_and_metadata_contract`, and + `generated_catalog_readme_coverage_contract`. Serialize the typed + `Vec` returned by `list_all_tools()` with minified + `serde_json::to_vec`; this canonical typed payload, excluding the JSON-RPC + envelope, is the byte metric. Remeasure the unchanged base with that exact + helper and calculate total bytes plus per-tool + name/description/input-schema/other bytes. Measure initialize instructions + and `get_readme` separately. +- [ ] Print the stable metrics only under `--nocapture` and enforce a reviewed + high-water budget of exactly `57_344` bytes unless the unchanged-base + measurement proves that value incorrect and the plan reviewers approve a + replacement integer. Do not label bytes as tokens. +- [ ] Assert output-schema and annotation presence/absence for every generated + tool rather than silently charging those fields to `other`. +- [ ] Derive README coverage from generated router names or compare generated + names with the existing documented-name set so future tools cannot bypass + the check by omission from a hand-maintained array. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --test tool_schema_tests -- --nocapture + cargo test -p hyperdb-mcp --test readme_tests + ``` + + Expected: PASS on unchanged behavior; output shows exactly three new catalog + tests executed, 33 tools, and the canonical measurement emitted. Zero matching + tests fails this characterization gate. +- [ ] Review spotlight: ensure the test measures the actual rmcp response, not a + duplicated hand-built list; ensure server construction has no filesystem + or engine side effects. +- [ ] Commit: `test(mcp): pin generated tool catalog contract` + +### Task 2: Parse and pass installation/launcher identity + +**Owners:** tester (`diagnostics_tests.rs`, Node test), engineer +(`diagnostics.rs`, `lib.rs`, `bin.js`). +**Files:** + +- Create: `hyperdb-mcp/src/diagnostics.rs` +- Create: `hyperdb-mcp/tests/diagnostics_tests.rs` +- Create: `hyperdb-mcp/npm/bin.test.js` +- Modify: `hyperdb-mcp/src/lib.rs` +- Modify: `hyperdb-mcp/npm/bin.js` + +- [ ] Add exactly named Rust tests `launcher_identity_parsing_contract`, + `installation_identity_version_warning_contract`, and + `launcher_identity_rejects_oversize_without_secret_leakage` for absent + metadata, valid allowlisted metadata, malformed JSON, unknown-key + ignoring, whole-value/individual-string limits, source version/build + parsing, mismatched wrapper/platform/native versions, and a shared + `ReportedPath { display, encoding }` created from UTF-8 and OS-supported + non-UTF-8 paths with explicit `utf8|lossy` marking and bounded display. + Keep parsing + pure by accepting an `Option<&OsStr>`; do not mutate global environment + variables in parallel tests. +- [ ] Run + `cargo test -p hyperdb-mcp --test diagnostics_tests -- --nocapture` before + production edits. Expected red is the cited unresolved diagnostics + interface/compiler error; it must be a nonzero exit, never a zero-match + Cargo pass. Once the smallest compile seam exists, rerun the same command + before implementing parsing and capture three executed failing tests. +- [ ] Before editing `bin.js`, create Node tests named + `launcher_module_is_import_safe`, + `launcher_info_contains_only_allowlisted_fields`, + `launcher_preserves_spawn_error_semantics`, + `launcher_preserves_numeric_exit_status`, and + `launcher_preserves_signal_termination`. Use a child process for the + import-safety red assertion so the current top-level `process.exit` + cannot kill the test runner, and dependency-inject `spawnSync` after the + refactor. Run `node --test hyperdb-mcp/npm/bin.test.js`; expected nonzero + TAP with all five named tests discovered and the import/export contract + failing for the intended reason. +- [ ] Refactor `bin.js` behind `main()` and `if (require.main === module)` so a + pure launcher-info builder can be exported to `node:test` without spawning + the native binary. Preserve shebang, argument forwarding, inherited stdio, + exit status, platform resolution, and bundled `HYPERD_PATH` behavior. +- [ ] Pass a single private `HYPERDB_MCP_LAUNCHER_INFO` JSON value containing + only wrapper package name/version/path, platform package + name/version/path, and selected executable path. Source manifests may + yield `null` versions; never guess them. +- [ ] In Rust, add serializable `ReportedPath`, `InstallationIdentity`, + `LauncherIdentity`, and warning types. `ReportedPath` is the one bounded, + encoding-aware representation used by installation, configuration, and + daemon reporting in later tasks. Cap launcher JSON at 16 KiB and each + reported string at 4 KiB, parse only known fields, label it + launcher-reported, and use `current_exe`, `mcp_version_string`, and + `hyper_api_version_string` as authoritative native facts. +- [ ] Compare semver bases without treating the `.r` build suffix as npm + semver. Malformed values warn; they do not crash MCP startup. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --test diagnostics_tests + node --test hyperdb-mcp/npm/bin.test.js + node --check hyperdb-mcp/npm/bin.js + ``` + + Expected: all PASS; Node tests prove metadata composition and that imported + `bin.js` does not execute `main()`. They also deterministically prove spawn + errors, numeric statuses, and signal termination preserve existing wrapper + exit behavior. +- [ ] Review spotlight: environment injection, secret/unknown-field leakage, + Windows path/package behavior, and preservation of wrapper exit semantics. +- [ ] Commit: `fix(mcp): report npm launcher identity` + +### Task 3: Enrich daemon discovery without changing `DaemonInfo` + +**Owners:** tester (`daemon_tests.rs` plus module tests), engineer (daemon +modules). +**Files:** + +- Modify: `hyperdb-mcp/src/daemon/discovery.rs` +- Modify: `hyperdb-mcp/src/daemon/health.rs` +- Modify: `hyperdb-mcp/src/daemon/run.rs` +- Modify: `hyperdb-mcp/tests/daemon_tests.rs` + +- [ ] Add external `legacy_daemon_info_literal_is_source_compatible`; add private + module tests + `daemon::discovery::tests::daemon_record_old_and_new_flat_wire_contract`, + `daemon::discovery::tests::raw_discovery_read_is_non_mutating_and_distinguishes_io`, + and `daemon::health::tests::health_status_returns_flat_enriched_record`. + Private wire/reader types stay private; no test-only public API is added. + The tests deserialize old flat + JSON, round-trip new build/executable identity, prove old `DaemonInfo` + readers ignore the additive object, compile an exhaustive legacy literal, + and prove a raw read never deletes stale/malformed/unreadable discovery + state. `NotFound` is missing; permission/other I/O is unreadable. +- [ ] Run the three module tests with + `cargo test -p hyperdb-mcp --lib -- --exact --nocapture` + and the external literal test with + `cargo test -p hyperdb-mcp --test daemon_tests legacy_daemon_info_literal_is_source_compatible -- --exact --nocapture`. + Expected one executed red assertion per behavioral module command after + the allowed compile seam, while the legacy literal characterization passes + on the base. The richer record/raw inspection behavior does not yet exist; + zero matching tests fails the gate. +- [ ] Add a separate version-tolerant record with exact shape + `#[serde(flatten)] info: DaemonInfo` plus one optional additive `identity` + object containing `DaemonBuildIdentity`. Legacy fields remain top-level; + never emit a nested `info` object. Keep every field of public `DaemonInfo` + exactly unchanged. Represent paths through the shared encoding-aware path + type without assuming UTF-8. +- [ ] Preserve `write_discovery_file(&DaemonInfo)` for compatibility. Add an + enriched writer for daemon runtime and update every initial/restart write + site. Old files parse with absent identity. +- [ ] Make health `STATUS` serialize that same flat enriched record while leaving its + shared `Arc>` state and public signatures intact; existing + readers continue to ignore the extra object. +- [ ] Add a raw non-mutating reader with distinguishable missing, unreadable, + malformed, and parsed outcomes. Do not change cleanup behavior of normal + `discover()`. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --lib daemon::discovery::tests::daemon_record_old_and_new_flat_wire_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --lib daemon::discovery::tests::raw_discovery_read_is_non_mutating_and_distinguishes_io -- --exact --nocapture + cargo test -p hyperdb-mcp --lib daemon::health::tests::health_status_returns_flat_enriched_record -- --exact --nocapture + cargo test -p hyperdb-mcp --test daemon_tests legacy_daemon_info_literal_is_source_compatible -- --exact --nocapture + ``` + + Expected: PASS, including old-schema compatibility and no stale-file deletion. +- [ ] Review spotlight: exact flat fixtures, exhaustive-literal compatibility, + takeover semver, both initial and restart write sites, serde + forward/backward behavior, and path privacy. +- [ ] Commit: `fix(mcp): enrich daemon discovery identity` + +### Task 4: Add side-effect-free doctor report and CLI + +**Owners:** tester (`doctor_tests.rs`, diagnostics unit tests, README test), +engineer (diagnostics/path/CLI). +**Files:** + +- Create: `hyperdb-mcp/tests/doctor_tests.rs` +- Modify: `hyperdb-mcp/src/diagnostics.rs` +- Modify: `hyperdb-mcp/src/paths.rs` +- Modify: `hyperdb-mcp/src/main.rs` +- Modify: `hyperdb-mcp/src/server.rs` only for a crate-private catalog snapshot +- Modify: `hyperdb-mcp/README.md` (minimal doctor usage) +- Modify: `hyperdb-mcp/tests/readme_tests.rs` (minimal doctor contract) + +- [ ] Add pure unit tests + `diagnostics::tests::collect_doctor_state_matrix_is_pure` and + `diagnostics::tests::candidates_refetch_and_verify_enriched_status` around a + collector with injected raw-reader, status-prober, bounded-scanner, and + clock/deadline functions. Drive missing, unreadable, malformed, + parsed-unreachable, live-from-discovery, and live-from-scan states without + real fixed ports. The collector's dependency bundle exposes no writer or + cleanup operation. A raw discovery record and a scan result are both only + candidate locations: neither is live identity evidence until a fresh + enriched `STATUS` response is parsed and its health port matches the + responding candidate. Use fresh facts, compare a discovery candidate with + its raw PID/build/executable, and add a deterministic mismatch case that + emits a stale/replaced warning without deleting the file. +- [ ] Add child-process tests `doctor_cli_json_and_human_smoke_is_side_effect_free` + and `doctor_human_output_escapes_and_bounds_reported_paths` using + `env!("CARGO_BIN_EXE_hyperdb-mcp")` and isolated temp values for state, + persistent path, HOME/USERPROFILE, and launcher metadata. Assert matching + JSON/human facts and byte-for-byte absence of newly created state + directories, discovery files, persistent files, logs, or scratch + databases. Include C0/ESC in a known field, a non-UTF-8 path where the OS + supports it, an overlong value, and an unknown secret sentinel. Human + output escapes controls; every path has `display` plus `utf8|lossy`; the + sentinel is absent; the report warns that local paths need review before + sharing. +- [ ] Add `doctor_readme_contract` before editing README and require its red + omission to mention exact CLI spelling and side-effect-free scope. +- [ ] Run these exact red commands: + + ```bash + cargo test -p hyperdb-mcp --lib diagnostics::tests::collect_doctor_state_matrix_is_pure -- --exact --nocapture + cargo test -p hyperdb-mcp --lib diagnostics::tests::candidates_refetch_and_verify_enriched_status -- --exact --nocapture + cargo test -p hyperdb-mcp --test doctor_tests -- --nocapture + cargo test -p hyperdb-mcp --test readme_tests doctor_readme_contract -- --exact --nocapture + ``` + + Expected nonzero failures for the missing collector/subcommand/prose, with the + two named child tests and README test discovered once their compile seams + exist. Missing daemon/default persistent file remain informational, not an + automatic command failure. Zero matching tests fails the gate. +- [ ] Preserve `resolve_persistent_db_path` and add source-aware resolution used + by CLI/doctor (`cli`, deprecated alias, environment, platform default, + disabled). Keep existing precedence and conflict errors. +- [ ] Implement the typed report sections from the spec: `status`, + `installation`, `configuration`, `daemon`, `tool_catalog`, `warnings`. + Catalog measurement must use the generated router snapshot from Task 1. +- [ ] Consume Task 2's bounded `ReportedPath { display, encoding }` for every + configuration path (installation and daemon paths already use it). Escape + C0/DEL/ESC in all human-rendered untrusted values, and retain serde JSON + escaping in JSON mode. +- [ ] Report observed `HYPERD_PATH` and the documented upward + `.hyperd/current/hyperd` candidate without starting Hyper or inventing PATH + search behavior. +- [ ] Add `Commands::Doctor { json: bool }`; restructure command extraction so + remaining global CLI fields can be inspected without a partial-move bug, + then return before logging and engine paths. Exit zero when a report is + produced even with warnings, including unreadable discovery. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --lib diagnostics::tests::collect_doctor_state_matrix_is_pure -- --exact --nocapture + cargo test -p hyperdb-mcp --lib diagnostics::tests::candidates_refetch_and_verify_enriched_status -- --exact --nocapture + cargo test -p hyperdb-mcp --test doctor_tests -- --nocapture + cargo test -p hyperdb-mcp --test diagnostics_tests + cargo test -p hyperdb-mcp --test tool_schema_tests -- --nocapture + cargo test -p hyperdb-mcp --test readme_tests doctor_readme_contract -- --exact --nocapture + cargo run -p hyperdb-mcp -- doctor --json + ``` + + Expected: all tests PASS; manual JSON is valid and does not start `hyperd`. +- [ ] Review spotlight: every filesystem mutation path, scan time bounds, + truthful path-source labels, exit semantics, and tool count remaining 33. +- [ ] Commit: `fix(mcp): add side-effect-free doctor command` + +### Task 5: Route daemon control messages to the effective health port + +**Owners:** tester (`daemon_tests.rs`, `recovery_tests.rs`), engineer +(CLI/health/engine/server). +**Files:** + +- Modify: `hyperdb-mcp/src/main.rs` +- Modify: `hyperdb-mcp/src/daemon/health.rs` +- Modify: `hyperdb-mcp/src/engine.rs` +- Modify: `hyperdb-mcp/src/server.rs` +- Modify: `hyperdb-mcp/tests/daemon_tests.rs` +- Modify: `hyperdb-mcp/tests/recovery_tests.rs` + +- [ ] Add exactly named child/helper tests + `daemon_status_post_action_port_targets_explicit_listener`, + `report_hyperd_error_targets_discovered_health_port`, and + `slow_health_report_does_not_hold_engine_mutex`. The first invokes the + literal process spelling `hyperdb-mcp daemon status --port ` against an + isolated listener and proves N is used without discovery. The second uses + a scanned non-base daemon and proves `REPORT_HYPERD_ERROR` reaches its + health port. The third makes the report endpoint slow and proves, within a + channel timeout, that another engine/status caller acquires the engine + mutex before the bounded socket report completes. +- [ ] Run each test by full name with `-- --exact --nocapture`; expected red on + ignored post-action CLI port, base-port reporting, and/or I/O while the + guard is held. Each command must show one executed test and the intended + assertion failure. +- [ ] Change `daemon_status` to accept `Option` and honor an explicit port; + use discovery/scan only when absent. Make `port` a compatible Clap global + daemon argument (or equivalent) so both existing + `daemon --port status` and required `daemon status --port ` parse; + do not silently change start/stop syntax. +- [ ] Change the report helper to take the effective health port. In + `try_daemon_mode`, pass `info.health_port`. In `with_engine`, capture + `engine.daemon_health_port()` before running the closure. Put the mutex + guard in an explicit inner scope/drop it, then perform heartbeat or + loss-report TCP I/O. Never perform health-network I/O while the guard is + held or relock merely to recover the port. +- [ ] Apply finite connect/read/write timeouts to every best-effort health + report path; timeout remains a logged/best-effort failure. +- [ ] Preserve best-effort/no-panic semantics and skip reports in local mode. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --test daemon_tests daemon_status_post_action_port_targets_explicit_listener -- --exact --nocapture + cargo test -p hyperdb-mcp --test daemon_tests report_hyperd_error_targets_discovered_health_port -- --exact --nocapture + cargo test -p hyperdb-mcp --test recovery_tests slow_health_report_does_not_hold_engine_mutex -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test daemon_tests -- --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test recovery_tests + ``` + + Expected: PASS with local callback permission. +- [ ] Review spotlight: explicit guard lifetime, bounded network time, deadlock + risk, `Option` handling, fallback/local behavior, and explicit port + not accidentally invoking cleanup discovery. +- [ ] Commit: `fix(mcp): target effective daemon health port` + +### Task 6: Unify installation identity and document degraded status + +**Owners:** tester (end-to-end MCP, resource, and README tests), engineer +(diagnostics/status). +**Files:** + +- Modify: `hyperdb-mcp/src/diagnostics.rs` +- Modify: `hyperdb-mcp/src/engine.rs` +- Modify: `hyperdb-mcp/src/server.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` +- Modify: `hyperdb-mcp/tests/resource_tests.rs` +- Modify: `hyperdb-mcp/tests/readme_tests.rs` +- Modify: `hyperdb-mcp/src/readme.rs` (status contract) + +- [ ] Extend the MCP harness to retain `server.engine_handle()`. Use a native + thread plus ready/release channels to hold the `std::sync::Mutex` without + carrying a non-Send guard across `.await`. +- [ ] Add exactly named MCP tests + `status_full_and_degraded_share_identity_contract` and + `status_degraded_returns_promptly_while_engine_locked` for `mcp_version`, correct + `hyper_rust_api_version`, `installation`, `default_database: "local"`, + `engine_busy`, intentional omissions, and prompt return while locked. +- [ ] Add `resource_status_renderer_uses_actual_engine_keys`. The resource + consumes only actual `has_persistent`/`persistent_path` values from + `Engine::status`; because the engine emits no `read_only` key, render that + fact directly from `HyperMcpServer::read_only` (the same source already + used to augment full/degraded status). Do not invent or look up a new + engine-status key. Remove obsolete workspace-key lookups. +- [ ] Add `readme_degraded_status_contract` before editing `src/readme.rs`; it + asserts partial/non-definitive semantics and retry guidance. +- [ ] Run each of the four tests by full name with `-- --exact --nocapture`. + Expected one executed failing test per command because identities/default + field/prose are absent, the degraded API identity is mislabeled, and the + resource consumes stale keys. Zero matches fails the gate. +- [ ] Add one response augmentation path used by both full and degraded status. + Preserve all existing engine statistics/root fields. +- [ ] Fix degraded API version and update the tool/concise README wording: + `engine_busy: true` is partial; degraded `hyperd_running: false` may be + inconclusive; retry for full statistics. +- [ ] Correct the workspace/readme renderer to consume actual status keys. +- [ ] Run: + + ```bash + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests status_full_and_degraded_share_identity_contract -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests status_degraded_returns_promptly_while_engine_locked -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test resource_tests resource_status_renderer_uses_actual_engine_keys -- --exact --nocapture + cargo test -p hyperdb-mcp --test readme_tests readme_degraded_status_contract -- --exact --nocapture + ``` + + Expected: PASS; degraded call completes within the test's explicit bound. +- [ ] Review spotlight: mutex/thread determinism, status compatibility, no + expensive doctor probes in every status call, and identity consistency. +- [ ] Commit: `fix(mcp): unify full and degraded status identity` + +### Task 7: Classify persistent attachment contention contextually + +**Owners:** tester (engine module, `engine_tests.rs`, `error_tests.rs`, +end-to-end MCP tests, and Hyper API process lifecycle test), engineer +(`engine.rs`, `error.rs`). +**Files:** + +- Modify: `hyperdb-mcp/src/engine.rs` +- Modify: `hyperdb-mcp/src/error.rs` +- Modify: `hyperdb-mcp/tests/engine_tests.rs` +- Modify: `hyperdb-mcp/tests/error_tests.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` +- Modify: `hyperdb-api/tests/process_tests.rs` (parent-death characterization) + +- [ ] Add private module test + `engine::tests::persistent_attach_55006_maps_resource_busy`, a synthetic + failing test for a structured Hyper server error with + SQLSTATE `55006` in persistent-attach context. Assert `RESOURCE_BUSY`, the + effective path, `(55006)`/raw message, doctor guidance, and non-accusatory + possible-owner wording. +- [ ] Add `real_persistent_lock_reproduces_resource_busy`: keep one no-daemon Engine alive with a persistent + file, attempt a second private Engine against the same file, and assert the + same classification. Run the entire potentially blocking reproduction in + a dedicated child instance of the integration-test binary selected by an + environment sentinel and exact helper-test name. The parent owns that + exact child handle; on timeout it kills and waits for the child before + failing, and on normal exit it waits and checks output/status. Parent and + child use RAII temp paths. A channel timeout around an in-process worker is + insufficient because it cannot unwind or join a blocked attach. Do not add + `hyperd` flags or sleeps. +- [ ] Before relying on child containment, add passing characterization + `callback_connection_shutdowns_hyperd_after_parent_kill` to the Hyper API + process tests. A helper child creates `HyperProcess`, reports its exact + public `pid()`, and blocks; the parent kills and waits for that helper, + then bounded-polls the exact reported hyperd PID until it exits through + the callback-connection dead-man switch. If the characterization fails, + do not proceed or claim cleanup: design and independently review exact + process-group/job-object containment or a lifecycle fix first. +- [ ] Add `non_attach_55006_preserves_existing_mapping`, proving the global + `From` mapper does not blindly call it a database lock. +- [ ] Add MCP-level `persistent_lock_keeps_mcp_available`: hold the file with a + private engine, warm a server against it, assert status remains promptly + usable, then assert the first persistent-routed query returns structured + `RESOURCE_BUSY` with the same evidence. Contain the complete scenario in + the same parent-controlled child-process pattern; every timeout path kills + and waits for that exact child before the parent test returns. +- [ ] Run all four tests by full name with `-- --exact --nocapture`. Expected + one executed failing test per applicable command because attachment is + currently `INTERNAL_ERROR` and the MCP-level contract is absent. Zero + matches or a timeout is a failed gate. +- [ ] Add a private persistent-attach conversion helper and use it only around + create/attach of the reserved persistent database. Preserve raw error and + SQLSTATE. Retain phrase fallback for older Hyper messages. +- [ ] Improve generic `RESOURCE_BUSY` guidance and correct `HYPERD_PATH` advice + that currently claims arbitrary PATH search. +- [ ] Run: + + ```bash + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-api --test process_tests callback_connection_shutdowns_hyperd_after_parent_kill -- --exact --nocapture + cargo test -p hyperdb-mcp --lib engine::tests::persistent_attach_55006_maps_resource_busy -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test engine_tests real_persistent_lock_reproduces_resource_busy -- --exact --nocapture + cargo test -p hyperdb-mcp --test error_tests non_attach_55006_preserves_existing_mapping -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests persistent_lock_keeps_mcp_available -- --exact --nocapture + ``` + + Expected: PASS; real reproduction finishes without hanging. +- [ ] Review spotlight: global `55006` boundary, Windows ingest behavior, error + ownership claims, create-vs-attach context, and raw diagnostic retention. +- [ ] Commit: `fix(mcp): classify persistent database contention` + +### Task 8: Add `resolved_database` to query-oriented results + +**Owners:** tester (`end_to_end_mcp_tests.rs`), engineer (`server.rs`). +**Files:** + +- Modify: `hyperdb-mcp/src/server.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` + +- [ ] Add exactly named MCP test `resolved_database_query_success_shapes` for + `query`, `execute`, `sample`, `describe`, and `chart`, covering default + local plus representative persistent/mixed-case/attached routes. Before + writing assertions, inventory every successful return branch in these + handlers. Exercise normal, zero-row/empty, and custom content shapes where + those are currently successes; do not turn an existing error into success + to manufacture coverage. Query assertions parse its second text block; + chart assertions preserve image-first/text-stats delivery. +- [ ] Pin every pre-existing field, structured/text mirroring, and content order + for each branch at the same time. +- [ ] Implement the table as non-short-circuiting case aggregation: record tool + call errors and field/order mismatches by case name, execute all query + shapes, then fail once with the complete mismatch list. The first missing + field must not hide later red evidence. +- [ ] Run + `HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests resolved_database_query_success_shapes -- --exact --nocapture`; + expected one executed failing test solely because `resolved_database` is + absent. Zero matches fails the gate. +- [ ] Add small helpers near `resolve_db` that canonicalize `None` to `local` + and inject a top-level field into object responses. Do not put routing in + generic stats structs. +- [ ] Thread the resolved name through each custom response builder. Preserve + query SQL formatting, chart image delivery, and structured/text mirroring. +- [ ] Replace touched integer `as` conversions with explicit `TryFrom` policy. +- [ ] Run: + + ```bash + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests resolved_database_query_success_shapes -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test sample_tests + ``` + + Expected: PASS for all five tool families. +- [ ] Review spotlight: database precedence, alias lowercasing, special response + shapes, strict old clients, and helper failure on non-object JSON. +- [ ] Commit: `fix(mcp): report resolved database for query tools` + +### Task 9: Add `resolved_database` to ingest/export/watch/metadata results + +**Owners:** tester (per-tool/end-to-end tests), engineer (`server.rs`). +**Files:** + +- Modify: `hyperdb-mcp/src/server.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` +- Modify: `hyperdb-mcp/tests/per_tool_database_tests.rs` where useful + +- [ ] Add exactly named test `resolved_database_data_success_shapes` with response assertions for `load_data`, `load_file`, + `load_files`, `watch_directory`, `export`, and `set_table_metadata`. + Include local, `persist: true`, explicit local winning over persist, and a + canonical attached alias across the group. +- [ ] Inventory and exercise every existing successful shape across these + handlers, including empty, not-found/idempotent, and partial per-file + shapes where the implementation currently treats them as success. Pin all + prior fields and notifications. Do not redefine an error as success merely + to satisfy the matrix. +- [ ] Aggregate every named data-tool case without `?`/`expect`/loop assertions, + then fail once with all mismatches so the captured red output proves every + planned shape was reached. +- [ ] Reuse temp files/directories and existing watcher cleanup; do not add + nondeterministic sleeps when registry state can be observed directly. +- [ ] Run + `HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests resolved_database_data_success_shapes -- --exact --nocapture`; + expected one executed failing test on missing result metadata. Zero + matches fails the gate. +- [ ] Inject the common top-level field after successful routing. For + `load_files`, one top-level target is authoritative because all entries + share it; do not duplicate it into every per-file result. +- [ ] Preserve watcher handles, export stats/path, catalog updates, and resource + notifications. +- [ ] Run: + + ```bash + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests resolved_database_data_success_shapes -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test per_tool_database_tests + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test watcher_tests + ``` + + Expected: PASS. +- [ ] Review spotlight: parallel `load_files` top-level truth, watcher teardown, + output-path behavior, and catalog routing side effects. +- [ ] Commit: `fix(mcp): report resolved database for data tools` + +### Task 10: Complete routed metadata for KV and copy tools + +**Owners:** tester (KV/end-to-end/schema tests), engineer (`server.rs`). +**Files:** + +- Modify: `hyperdb-mcp/src/server.rs` +- Modify: `hyperdb-mcp/tests/kv_tools_tests.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` +- Modify: `hyperdb-mcp/tests/tool_schema_tests.rs` + +- [ ] Add exactly named `resolved_database_kv_success_shapes` for all nine KV + tools. Verify every normal and currently-successful missing-key/empty-store + shape carries the canonical target while preserving prior fields. +- [ ] Aggregate all nine KV tools and their named success branches before one + final assertion; no early failure may conceal a later missing field. +- [ ] Add `copy_query_preserves_target_and_resolved_database`; assert its legacy + `target_database` remains present and equals the new common field in every + success shape. +- [ ] Add `routed_tool_allowlist_matches_generated_schemas` with an explicit + 21-tool semantic allowlist from the design. Compare it with generated + schema candidates exposing `database` and/or `persist`, then handle + `copy_query` as the named semantic exception because it exposes + `target_database`. Property-name detection alone must not define routing + semantics or prove response injection. +- [ ] Run the three tests by full name with `-- --exact --nocapture`; expected + one executed failing test per command on metadata and/or inventory + coverage. Zero matches fails the gate. +- [ ] Return/thread the canonical resolved name out of each KV engine closure + alongside its value, then apply the common helper to KV/copy success + values only. Never reconstruct the target from the original request after + resolution. Do not alter read-only guards, overwrite semantics, or current + attached-read behavior. +- [ ] Run: + + ```bash + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test kv_tools_tests resolved_database_kv_success_shapes -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests copy_query_preserves_target_and_resolved_database -- --exact --nocapture + cargo test -p hyperdb-mcp --test tool_schema_tests routed_tool_allowlist_matches_generated_schemas -- --exact --nocapture + ``` + + Expected: PASS; schema inventory equals the documented routed inventory. +- [ ] Review spotlight: every success branch including `found:false`, copy + compatibility, no metadata on errors, and schema inventory false positives + such as `target_database`-only tools. +- [ ] Commit: `fix(mcp): complete resolved database result metadata` + +### Task 11: Preserve chart API while fixing range/category behavior + +**Owners:** tester (chart unit/integration/MCP tests), engineer (chart). +**Files:** + +- Modify: `hyperdb-mcp/src/chart.rs` +- Modify: `hyperdb-mcp/tests/chart_tests.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` + +- [ ] Add passing characterization + `legacy_chart_options_literal_is_source_compatible`, constructing public + `ChartOptions` with exactly its legacy fields and calling the public + `render_chart` signature. This is a compatibility characterization, not a + fabricated red test. +- [ ] In `chart.rs`'s private unit-test module add failing + `chart::tests::bar_ranges_and_categories_are_validated`, covering numeric + x plus `x_as_category:false`, applied bar `y_range`, positive-only and + negative-only linear baselines, and reversed/equal/non-finite explicit + ranges. Add MCP-level `chart_mcp_rejects_invalid_ranges` for structured + `INVALID_ARGUMENT` mapping. +- [ ] Run the compile characterization by full name and expect one pass. Then + run each new behavior test by full name with `-- --exact --nocapture` and + expect one executed failure on ignored range/off-canvas behavior or wrong + error mapping. Zero matches fails the gate. +- [ ] Keep public `ChartOptions` and `render_chart` signatures/fields exactly + unchanged; Task 11 adds no MCP fields or presentation type. +- [ ] Treat bar x values categorically regardless of `x_as_category:false`, + apply `y_range`, and validate every explicit range as finite and strictly + increasing before Plotters. +- [ ] Use zero as the linear bar baseline when the range includes it; otherwise + use the nearer explicit boundary (lower for positive-only, upper for + negative-only). Return `INVALID_ARGUMENT` for caller-invalid ranges. + Preserve legacy vertical/linear/default-legend rendering. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --test chart_tests legacy_chart_options_literal_is_source_compatible -- --exact --nocapture + cargo test -p hyperdb-mcp --lib chart::tests::bar_ranges_and_categories_are_validated -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests chart_mcp_rejects_invalid_ranges -- --exact --nocapture + cargo test -p hyperdb-mcp --test chart_tests -- --nocapture + ``` + + Expected: PASS; public source compatibility and existing chart regressions + remain green. +- [ ] Review spotlight: public Rust source compatibility, fixed-range semantics, + categorical positioning, negative bars, and no physical/data-axis + confusion. +- [ ] Commit: `fix(mcp): validate chart ranges and bar categories` + +### Task 12: Add horizontal bars, legend control, and value labels + +**Owners:** tester (chart/MCP tests), engineer (chart/server/readme). +**Files:** + +- Modify: `hyperdb-mcp/src/chart.rs` +- Modify: `hyperdb-mcp/src/server.rs` +- Modify: `hyperdb-mcp/src/readme.rs` +- Modify: `hyperdb-mcp/tests/chart_tests.rs` +- Modify: `hyperdb-mcp/tests/end_to_end_mcp_tests.rs` +- Modify: `hyperdb-mcp/tests/readme_tests.rs` + +- [ ] In `chart.rs`'s private unit-test module add failing + `chart::tests::horizontal_bar_layout_contract` and + `chart::tests::legend_and_value_label_contract`. They test pure + category/group/layout output plus SVG geometry: first SQL category at the + top, distinct grouped rectangles, swapped descriptions, legend + suppression, original scalar values, Unicode, and a PNG smoke path. +- [ ] Characterize existing vertical behavior first and make horizontal match + it: one category is supported; duplicate category+series rows remain + distinct overlapping marks in input order; missing series/category cells + remain gaps; series retain deterministic existing ordering; the eight + colors cycle for later series; and long/Unicode labels are accepted but + neither truncated nor auto-sized. These are explicit supported outcomes, + even if a caller must increase width/height to avoid clipping. +- [ ] Add MCP test `chart_mcp_presentation_options_contract` for accepted schema, + defaults, invalid cross-chart combinations, result content order, and PNG + delivery. Add `readme_chart_presentation_contract` before prose edits for + all three new controls and the layout caveat. +- [ ] Run all four tests by full name with `-- --exact --nocapture`; expected one + executed red test per command because fields/rendering/docs are absent. + The private renderer behavior tests live inside `chart.rs`; external tests + must not expose an internal type merely to obtain a seam. +- [ ] Add optional MCP `bar_orientation`, `label_values`, and `show_legend` + fields plus an internal typed presentation-options value and extended + renderer. Public `render_chart` delegates with legacy defaults. Reject + `label_values:true` for non-bars and explicit bar orientation on non-bars + as `INVALID_ARGUMENT`. +- [ ] Default `show_legend` to true. `false` suppresses bar/line/scatter legends; + existing `label_points:true` still suppresses line/scatter legends + regardless of this flag. +- [ ] Reuse grouping/order/color logic, reverse the categorical coordinate so + the first query row is at the top, and increase horizontal category-label + area without adding auto-sizing. +- [ ] Extend the internal point model to retain original y scalar text before + numeric conversion; render that exact text for value labels without a + formatting DSL or collision solver. Keep public chart API unchanged. +- [ ] Only after the documentation test is red, update the concise README/tool + description in the same task. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --lib chart::tests::horizontal_bar_layout_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --lib chart::tests::legend_and_value_label_contract -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests chart_mcp_presentation_options_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test readme_tests readme_chart_presentation_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test chart_tests -- --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests chart + cargo test -p hyperdb-mcp --test tool_schema_tests -- --nocapture + ``` + + Expected: PASS for PNG/SVG and schema budget. +- [ ] Review spotlight: top-to-bottom order, negative linear bars, grouped + offsets, label clipping, backend parity, and invalid option combinations. +- [ ] Commit: `fix(mcp): add diagnostic chart presentation controls` + +### Task 13: Add positive logarithmic measure scale + +**Owners:** tester (chart/MCP tests), engineer (chart/server/readme). +**Files:** same as Task 12. + +- [ ] In `chart.rs`'s private unit-test module add failing + `chart::tests::log_range_handles_finite_extremes` and + `chart::tests::log_rendering_contract`. Cover positive bar/line/scatter, + a repeated value, minimum positive subnormal, maximum finite, explicit + positive range, vertical/horizontal SVG and PNG, and rejection of zero, + negative, mixed-sign, non-finite, reversed/non-increasing, histogram, or + explicit ranges that do not contain every plotted value. Verify no bar + starts at numeric zero or produces inverted geometry. +- [ ] Add MCP `chart_mcp_log_scale_contract` for parser/error/content behavior + and `readme_chart_log_contract` before prose edits. +- [ ] Run all four tests by full name with `-- --exact --nocapture`; expected one + executed red test per command because scale/helper/docs are absent. Keep + internal renderer behavior tests inside `chart.rs`. +- [ ] Add internal typed measure scale and optional MCP `y_scale`, defaulting to + linear. Keep semantics tied to data-role y even for horizontal bars. +- [ ] Validate all values/ranges before building Plotters contexts. Use the + verified Plotters `.log_scale()` API. Prefer small explicit linear/log + branches over complex generic abstraction because their `ChartContext` + coordinate types differ. +- [ ] Compute automatic bounds in natural-log space with five-percent span + padding. Clamp log endpoints to + `ln(f64::from_bits(1))..=ln(f64::MAX)` before exponentiation. A repeated + value uses a fixed five-percent decade span. If rounding collapses an + endpoint at a bound, use the adjacent representable positive float on the + available side. The final finite positive increasing range must enclose + all values. An explicit log range must contain every plotted value. +- [ ] Bars start at the effective positive lower bound, never zero. Do not add + x-log, symlog, histogram log, negative-only log, value clamping, or silent + filtering. +- [ ] Only after the documentation test is red, update concise README and MCP + description in the same task. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --lib chart::tests::log_range_handles_finite_extremes -- --exact --nocapture + cargo test -p hyperdb-mcp --lib chart::tests::log_rendering_contract -- --exact --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests chart_mcp_log_scale_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test readme_tests readme_chart_log_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test chart_tests -- --nocapture + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test end_to_end_mcp_tests chart + cargo test -p hyperdb-mcp --test tool_schema_tests -- --nocapture + ``` + + Expected: PASS; all invalid values fail as `INVALID_ARGUMENT` before backend + rendering. +- [ ] Review spotlight: subnormal/huge floats, auto-range equality, horizontal + data-axis mapping, generic code complexity, and silent misrepresentation. +- [ ] Commit: `fix(mcp): add positive logarithmic chart scale` + +### Task 14: Align public documentation, terminology, and changelog + +**Owners:** tester (`doctor_tests.rs` plus semantic documentation assertions), +doc-editor agent (prose), followed by reviewer. +**Files:** + +- Modify: `hyperdb-mcp/src/readme.rs` +- Modify: `hyperdb-mcp/README.md` +- Modify: `hyperdb-mcp/SMOKE_TESTS.md` +- Modify: `hyperdb-mcp/examples/demo.rs` +- Modify: `hyperdb-mcp/src/main.rs` help text +- Modify: `hyperdb-mcp/src/server.rs` tool/parameter descriptions +- Modify: `hyperdb-mcp/CHANGELOG.md` +- Modify: `hyperdb-mcp/tests/readme_tests.rs` +- Modify: `hyperdb-mcp/tests/resource_tests.rs` +- Modify: `hyperdb-mcp/tests/doctor_tests.rs` + +- [ ] First rerun the already-green semantic contracts added in Tasks 4, 6, 12, + and 13 for doctor, full/degraded status, and chart controls. Do not present + these as Task 14 red evidence. +- [ ] Before Task 14 prose edits, add exactly named deferred-drift tests + `public_docs_database_and_read_only_contract`, + `smoke_demo_and_changelog_contract`, and + `cli_help_matches_hyperd_and_read_only_contract`. They cover + `RESOURCE_BUSY`, `resolved_database`, actual guarded/allowed read-only + tools, local/persistent/attached terminology, `HYPERD_PATH` resolution, + smoke/demo truth, and the crate `## [Unreleased]` contract. Run each by + full name with `-- --exact --nocapture`; expected one executed failure per + command on the cited current drift. Zero matches fails the gate. +- [ ] Use `local`, `persistent`, and attached database consistently. Retain + `workspace` only for deprecated compatibility names/resource URI/history. +- [ ] Position chart as a quick diagnostic and document all current delivery and + presentation options. Fix the concise README's PNG-only/nonexistent-path + example and temporal-axis comments. +- [ ] Correct read-only lists: include actual guarded tools; keep + `unwatch_directory` and Hyper export allowed. Correct CLI help claiming + Hyper export is disabled. +- [ ] Document doctor identity provenance and side-effect-free behavior, + contextual `RESOURCE_BUSY`, daemon port behavior, and degraded status. +- [ ] Keep tool descriptions concise enough to remain under the Task 1 budget; + move long operational guidance to `get_readme`/README. +- [ ] Append Added/Fixed/Changed bullets to the crate changelog only. Do not edit + versions, root changelog, or release manifests. +- [ ] Run: + + ```bash + cargo test -p hyperdb-mcp --test readme_tests public_docs_database_and_read_only_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test readme_tests smoke_demo_and_changelog_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test doctor_tests cli_help_matches_hyperd_and_read_only_contract -- --exact --nocapture + cargo test -p hyperdb-mcp --test readme_tests + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp --test resource_tests + cargo test -p hyperdb-mcp --test tool_schema_tests -- --nocapture + node --check hyperdb-mcp/npm/bin.js + git diff --check + ``` + + Expected: PASS; 33 tools and catalog budget retained. +- [ ] Review spotlight: code/doc truth, stale workspace terminology, exact tool + guards, unsupported promises, and changelog policy. +- [ ] Commit: `fix(mcp): align agent UX and diagnostics documentation` + +### Task 15: Integrated verification, adversarial review, and durable memory + +**Owners:** independent validator/reviewers; main thread reconciles; writer or +doc-editor creates the final note only from verified evidence. +**Source files:** entire branch diff. +**External artifact:** +`/Users/ssteiner/dev/ssteiner-ai/notes/hyperdb-mcp-agent-ux-implementation-2026-08-14.md` + +- [ ] Confirm the Hyper worktree is clean except intended changes and inspect the + complete `origin/main...HEAD` diff and commit list. +- [ ] Run pre-review gates with captured exit codes/output: + + ```bash + cargo fmt --all --check + cargo clippy --workspace --all-targets --all-features -- -D warnings + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp + HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test --workspace --exclude hyperdb-api-node --exclude hyperdb-bootstrap + node --test hyperdb-mcp/npm/bin.test.js + node --check hyperdb-mcp/npm/bin.js + git diff --check origin/main...HEAD + ``` + +- [ ] Dispatch two independent final reviewers in parallel: + fast/mechanical (requirements, tests, docs, unsafe casts, dead code, + over-engineering) and deep/architectural (cross-file contracts, daemon + races, security/privacy, compatibility, chart correctness, incomplete + routing). Supply the complete diff and verification evidence. +- [ ] Reconcile each finding against source. Critical/Important findings go to a + fresh engineer, then a fresh re-review. Record rejected false positives and + their evidence rather than silently unioning reviewer lists. +- [ ] After every accepted review fix is independently re-reviewed, the main + thread stages only explicit source/test/doc paths, makes the appropriate + Conventional Commit, and confirms the Hyper worktree is clean. Inspect + `origin/main...HEAD` again so final review fixes cannot remain uncommitted + and therefore disappear from the reviewed diff. +- [ ] After that last source commit, rerun the entire gate block above. Only this + clean, post-fix, post-commit run can support the final green claim. +- [ ] Re-run `tool_schema_tests -- --nocapture` and record before/after total, + descriptions, schemas, largest tools, initialization instructions, and + `get_readme`. Call bytes bytes; any token estimate must name the actual + client/model tokenizer or remain explicitly unavailable. +- [ ] Read the writer/doc-editor role profile before dispatch. Create the dated + `ssteiner-ai` note using `apply_patch`; the filesystem write may require + escalation and is limited to this exact note plus an applicable role + profile only when the evidence supports a LEARNINGS LOG entry. Do not + change any other `ssteiner-ai` path. The note must include: + + - Codex Desktop and model/reasoning provenance available to the session; + - source repo/base/worktree/branch and ordered commits; + - a self-contained ordered copy/summary of the approved implementation plan, + plus immutable source commit/file references; do not rely on a link into the + disposable worktree as the only plan record; + - implemented, altered, and explicitly deferred scope; + - before/after tool-catalog and any build/dependency measurements; + - exact final commands, exit codes, pass/ignored counts, and environmental + loopback note; + - per-task and integrated reviewer identities, findings, fixes, false-positive + reconciliations, and final verdicts; + - operational gotchas and reusable architectural/test lessons; and + - recommended follow-ups for future HyperDB MCP sessions. + +- [ ] If the run produced a genuinely reusable lesson for engineer, tester, + reviewer, writer, or doc-editor, update that profile's LEARNINGS LOG in + `/Users/ssteiner/dev/ssteiner-ai/.Codex/agents/` (newest first, dated, + source named, stale lesson superseded/pruned). Do not add generic praise or + one-off implementation facts as role memory. +- [ ] Run `git diff --check` in `ssteiner-ai` and inspect its pre-existing dirty + state so only requested note/profile paths are reported. Do not commit or + push the second repository unless separately authorized. +- [ ] Dispatch a fresh read-only fact/consistency reviewer after the note is + complete. It compares every note claim and count with immutable Hyper + commits, the final plan/spec, captured red/green/gate evidence, and + reviewer verdicts. Note-only corrections return to the writer/editor and + are rechecked. If this audit uncovers a source defect, return it through a + fresh engineer, task/integrated re-review, explicit source commit, full + post-fix gate block, note update, and note re-review. +- [ ] After the last audited note/profile correction, rerun `git diff --check` + in `ssteiner-ai` and repeat the requested-path allowlist/dirty-state + inspection. This final mechanical check supersedes the earlier pre-audit + snapshot. +- [ ] Final handoff reports Hyper branch/path/commits, exact gate evidence, + reviewer verdicts, unresolved Minor/deferred items, and the clickable + `ssteiner-ai` note. No PR, push, merge, release, or publication. + +--- + +## Plan completion gate + +Implementation is not complete until all of the following are true: + +- Tasks 1-14 have passed their independent task-review gates; +- Task 15 has two independent integrated source-review verdicts plus a separate + read-only fact/consistency verdict on the completed durable note; +- no final reviewer has an unresolved Critical or Important finding; +- strict workspace Clippy and both required Rust test gates have fresh captured + zero exits after the final code change; +- npm wrapper tests/checks and tool-catalog budget are green; +- the full tool surface remains the same 33 names and local remains the default; +- public `ChartOptions` and `DaemonInfo` are unchanged; +- crate documentation/changelog match actual behavior; and +- the requested `ssteiner-ai` results/memory document exists and contains the + plan, evidence, reviews, and reusable follow-up context. diff --git a/docs/superpowers/specs/2026-08-13-hyperdb-mcp-agent-ux-design.md b/docs/superpowers/specs/2026-08-13-hyperdb-mcp-agent-ux-design.md new file mode 100644 index 0000000..9a67408 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-hyperdb-mcp-agent-ux-design.md @@ -0,0 +1,785 @@ +# HyperDB MCP Agent UX and Operational Diagnostics — Design + +**Status:** Approved; adversarial-review amendments incorporated + +**Date:** 2026-08-13 + +**Author:** Stefan Steiner with Codex Desktop (ultra reasoning) + +**Final integration base:** `origin/main` @ `e609061` (design originally approved at `87e0b9d`) + +**Working branch:** `codex/hyperdb-mcp-agent-ux` + +## Context + +Firsthand use of the installed `hyperdb-mcp` exposed a connected set of +operator and agent-experience problems: + +- an installed npm wrapper, native MCP binary, Rust API build, `hyperd`, and + resident daemon can all have different identities, but the current surfaces + do not make those identities comparable; +- a persistent-database lock can surface as a generic internal error even when + Hyper supplies SQLSTATE `55006`; +- tools route correctly between the ephemeral primary, the reserved persistent + database, and user attachments, but most results do not say which target was + actually selected; +- the full tool catalog is large enough to deserve measurement and a deliberate + disclosure policy; +- `chart` is valuable as a direct SQL-to-image diagnostic, but ranked bars and + common diagnostic presentation controls are awkward or absent; and +- public documentation has drift around degraded status, read-only behavior, + chart parameters, persistent terminology, and `hyperd` path resolution. + +The original review is recorded in +`/Users/ssteiner/dev/ssteiner-ai/notes/hyperdb-mcp-agent-ux-review-2026-08-12.md`. +This specification converts that review into an implementation contract. + +Three independent read-only investigations mapped the operational, routing, +tool-schema, chart, packaging, and compatibility surfaces before this design +was approved. They also exposed several adjacent correctness defects described +below. No implementation changes were made during that investigation. + +### Verified baseline + +- The live MCP catalog contains 33 tools. +- A minified live `tools/list` measurement contained 53,645 UTF-8 bytes: + 17,203 bytes of tool descriptions and 34,566 bytes of input schemas. This is + a wire-size measurement, not a model-token count; client transformation and + tokenizer choice affect the latter. +- `chart` is the largest individual tool entry at 6,495 bytes in that + measurement. +- The isolated base worktree passes + `HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp` with + exit code 0. Eight daemon stress tests are documented as ignored by the + existing suite. +- Hyper-backed tests require permission to bind a local callback listener. A + sandboxed run predictably fails with `Failed to create callback listener`; + the identical command passes when local loopback binding is allowed. + +## Goals + +- Make installation, binary, API, daemon, and persistent-path mismatches + diagnosable even when MCP startup or database attachment is unhealthy. +- Turn persistent attachment contention into a structured, actionable error + without misclassifying unrelated SQLSTATE `55006` failures. +- Make the post-resolution database target visible in every success response + from a database-routed tool. +- Establish a reproducible tool-catalog measurement and compatibility contract + before changing disclosure behavior. +- Retain `chart` as a bounded quick-diagnostic utility and add the few + presentation controls needed for common analytical checks. +- Correct public contract drift and use one vocabulary for the database model. +- Preserve current defaults and existing MCP tool availability. + +## Non-goals + +- No new MCP `doctor` tool or diagnostics resource. The existing `status` tool + is the in-protocol diagnostics surface; the new doctor is a native CLI + subcommand that still works when MCP registration cannot. +- No default-core tool profile, tool removal, tool grouping, dynamic route + activation, or read-only-specific schema filtering in this tranche. +- No change from the ephemeral `local` primary as the default target. +- No process killing, daemon takeover, discovery-file deletion, database + opening, or OS-specific lock-owner discovery from `doctor`. +- No arbitrary PATH enumeration, shell-shim reconstruction, or unsupported + `hyperd --version` invocation. +- No generic SQLSTATE `55006` mapping. +- No chart replacement, dashboard/layout system, faceting, stacking, symlog, + x-axis logarithms, histogram logarithms, custom log bases, or automatic label + collision solver. +- No release, version bump, push, pull request, merge, or publication as part of + this implementation branch. + +--- + +## Design + +### 1. Side-effect-free `hyperdb-mcp doctor` + +Add a sibling Clap subcommand: + +```text +hyperdb-mcp doctor [--json] +``` + +The existing global database and daemon flags remain valid inputs. Doctor +returns before logging setup, directory creation, engine warm-up, daemon spawn, +or persistent-database attachment. + +The default presentation is concise human-readable text. `--json` emits the +same typed report as minified or pretty JSON suitable for issue reports and +automation. Successfully collecting a report exits zero even when warnings are +present; malformed CLI arguments or an inability to serialize the report remain +ordinary command failures. A missing daemon or as-yet-uncreated default +persistent file is informational rather than automatically unhealthy. + +#### Report model + +The report has five stable top-level sections plus warnings: + +```json +{ + "status": "ok", + "installation": {}, + "configuration": {}, + "daemon": {}, + "tool_catalog": {}, + "warnings": [] +} +``` + +`installation` reports: + +- the actual native executable from `std::env::current_exe`; +- the full MCP build identity from `mcp_version_string()`; +- the full Rust Hyper API identity from `hyper_api_version_string()`; and +- optional launcher-reported npm metadata. + +`configuration` reports: + +- persistent mode (`persistent_attached` or `ephemeral_only`); +- resolved persistent path and its source (`cli`, deprecated CLI alias, + environment, platform default, or disabled); +- whether the resolved file and parent currently exist; +- daemon state directory, discovery path, and client-log path; +- the observed `HYPERD_PATH`, whether it names an existing file/directory, and + the upward-search `.hyperd/current/hyperd` candidate when applicable; and +- the effective read-only and no-daemon flags. + +Doctor reports the actual resolution facts available without starting Hyper. It +does not claim that arbitrary PATH lookup occurs: current runtime resolution +uses `HYPERD_PATH` or an upward `.hyperd/current/hyperd` search. + +`daemon` reports one of these discovery states: + +- `missing`; +- `unreadable`; +- `malformed`; +- `parsed_unreachable`; +- `live_from_discovery`; or +- `live_from_scan`. + +When available, it includes PID, endpoint, health port, start time, plain semver +used by takeover logic, full build identity, and native executable path. A +non-mutating raw discovery read, bounded candidate scan, and fresh enriched +`STATUS` verification supply these facts. +Doctor must not call the current cleanup-oriented `discover()` path, because +that path deletes a stale discovery file. + +`missing` is reserved for `NotFound`. Permission failures and other discovery +I/O errors are `unreadable`, with a bounded warning that preserves the error +kind without pretending the file is absent or malformed. Doctor still produces +a report when this happens. + +The collector is a pure orchestration layer with injected raw-reader, +status-prober, bounded-scanner, and clock/deadline functions. Unit tests drive +every state without a real fixed-port scan and make cleanup/write operations +unrepresentable in that layer; one isolated child-process smoke test exercises +the real CLI. Both a raw discovery record and a scan hit are only candidate +locations. Doctor obtains and parses a fresh enriched `STATUS` response, +verifies that its health port is the responding candidate port, and uses those +fresh facts before attributing PID/build/executable identity. For a discovery +candidate, it compares fresh facts with the raw record and emits a +stale/replaced-record warning on PID/build/executable disagreement without +deleting the file. Neither a raw record nor a scan-only `DaemonInfo` value is +sufficient live identity evidence. + +`tool_catalog` reports the full-profile tool count and minified serialized byte +size from the same generated router contract used by MCP. Initialization +instructions and `get_readme` sizes are reported separately rather than folded +into the tool-schema number. + +#### npm launcher identity + +The Node wrapper already resolves both its umbrella package and the selected +platform package before spawning the native binary. It will pass one private +JSON environment value containing only: + +- wrapper package name, version, and package path; +- selected platform package name, version, and package path; and +- selected native executable path. + +Rust treats these values as launcher-reported metadata, validates the expected +shape, and never blindly re-emits unknown keys. The whole value is capped at 16 +KiB and each reported string at 4 KiB. Source manifests intentionally lack +versions, so unavailable local-development values remain `null` rather than +being guessed. Direct Cargo or crates.io launches report launcher metadata as +absent. + +Every installation/configuration/daemon path uses one typed representation: +`display` plus an `encoding` marker (`utf8` or `lossy`). JSON escaping remains +standard serde JSON escaping. Human output additionally escapes C0, DEL, and +ESC characters in every launcher-controlled string and path so a package name, +version, or path cannot inject terminal control sequences. Reports explicitly +warn that they contain local paths and should be reviewed before sharing. + +Warnings identify mismatched wrapper/platform/native base versions, malformed +launcher metadata, a stale or malformed daemon record, and a live daemon whose +build/executable differs from the current client. They describe evidence, not a +guessed lock owner. + +#### Compatibility-safe daemon record + +The current public `DaemonInfo` Rust struct remains unchanged so downstream +exhaustive struct literals do not break. The version-tolerant internal record +has this exact wire shape: `#[serde(flatten)] info: DaemonInfo` keeps every +legacy field at the JSON top level, and one optional additive `identity` object +contains full build identity and executable path. It must not serialize a +nested `info` object. Existing public read/write helpers retain their behavior; +new internal helpers expose the richer record to doctor and daemon status. + +Old discovery JSON must parse, new readers must accept absent identity fields, +old readers must ignore the new `identity` object, and takeover comparison must +continue using the existing plain semver only. Unknown fields remain forward +compatible. Exact old/new discovery-file and health-`STATUS` fixtures plus an +exhaustive legacy `DaemonInfo` literal protect wire and Rust source +compatibility. + +### 2. Shared installation identity and status contract + +The pure installation-identity collector is shared by CLI doctor and the MCP +`status` response. Filesystem/daemon probes remain doctor-specific where doing +them on every status call would be unnecessary. + +Both full and degraded status responses gain: + +- root `mcp_version`; +- the correctly labeled existing `hyper_rust_api_version`; +- an additive `installation` object; and +- `default_database: "local"`. + +Existing fields are preserved. In particular, full engine statistics and the +current `engine` block retain their names. + +The existing degraded response bug is fixed: it currently writes the MCP build +string into `hyper_rust_api_version`. Full and degraded paths must pass the same +version-identity assertions. + +The documented degraded contract becomes: + +- `engine_busy: false` means the complete status path ran; +- `engine_busy: true` means a prompt partial observer response was returned and + the caller should retry for SQL-dependent statistics; +- omission of table counts, row totals, disk usage, ephemeral path, and log + details is intentional while degraded; and +- `hyperd_running: false` is not definitive in a degraded local response or + when daemon discovery is unavailable. + +The stale `hyper://readme` status consumer reads only `has_persistent` and +`persistent_path` from `Engine::status`. That engine value has no `read_only` +key; the resource renders read-only state directly from +`HyperMcpServer::read_only`, the same authoritative configuration source used +to augment full/degraded tool status. Broader unification of resource and tool +response shapes is deferred. + +### 3. Adjacent daemon reliability fixes + +Two confirmed defects are included because they directly undermine the new +diagnostic story: + +1. `hyperdb-mcp daemon status --port ` currently discards ``. Status must + accept that exact spelling (while retaining the existing pre-action spelling + if Clap can do so compatibly), probe the explicit port when supplied, and use + discovery plus scan only when it is absent. +2. A client reporting a dead `hyperd` currently targets the configured base + port rather than the health port of the daemon it actually discovered. The + report path must use the cached discovered health port, matching heartbeat + routing. + +The engine mutex is explicitly scoped/dropped before heartbeat or error-report +TCP I/O. Those best-effort calls use bounded connect/read/write timeouts. A slow +health peer therefore cannot retain the engine lock or prevent another +status/engine caller from proceeding. + +No new `hyperd` flags or takeover semantics are introduced. + +### 4. Contextual persistent-lock classification + +Persistent attachment currently wraps every failure as `INTERNAL_ERROR`, which +loses the existing `RESOURCE_BUSY` classification. Add a context-specific +conversion used only by default persistent attachment. + +When that attach operation returns SQLSTATE `55006`, or a legacy message already +recognized as a busy resource, the MCP error is: + +- code: `RESOURCE_BUSY`; +- message: the effective persistent path plus the preserved raw Hyper message + and SQLSTATE; +- suggestion: run `hyperdb-mcp doctor`, compare client/daemon identities, close + the actual Hyper/Tableau process holding the file or choose another + persistent file, and retry. + +The wording lists possible owners without asserting which one holds the lock. + +This classification must not be added to the global SQLSTATE mapper. Hyper also +uses `55006` for an unreadable Windows `COPY FROM` source, which is not database +contention. A regression test holds that boundary. + +Warm-up continues to leave the MCP server available after initialization +failure; the first relevant tool call receives the structured error, while +`status` and CLI doctor remain diagnostic paths. + +### 5. Canonical resolved-database metadata + +Every success response from a tool that accepts database routing gains: + +```json +"resolved_database": "local" +``` + +Values are the result after precedence and canonicalization: + +- omitted target, explicit case-insensitive `local`, or `database: "local"` + winning over `persist: true` becomes `local`; +- `persist: true` or case-insensitive `database: "persistent"` becomes + `persistent`; and +- a user attachment becomes its lowercased canonical alias. + +The field is additive and reflects the effective target, not the request echo. +A response-layer helper injects it into top-level JSON results without changing +generic engine telemetry structs. + +The inventory covered by this contract is: + +- `load_data`, `load_file`, and `load_files`; +- `query`, `execute`, `sample`, `describe`, and `chart`; +- `watch_directory`, `export`, and `set_table_metadata`; +- `kv_get`, `kv_set`, `kv_set_many`, `kv_delete`, `kv_list`, + `kv_list_stores`, `kv_size`, `kv_pop`, and `kv_clear`; and +- `copy_query`, which retains `target_database` and also gains the common field. + +`query` and `chart` have custom content assembly, so their existing text/image +ordering remains intact while their JSON metadata gains the field. Normal +structured/text JSON responses continue mirroring the same object for old and +new clients. + +Primary-only tools such as `query_data`, `query_file`, and `load_iceberg` do not +gain a synthetic routing field in this tranche. Attachment-management results +already identify their alias/source and are not database-selection responses. + +### 6. Tool-catalog measurement before disclosure changes + +Add an MCP-level contract test that obtains the generated catalog through +`tools/list` and asserts: + +- exactly the existing 33 sorted tool names; +- no MCP `doctor` tool; +- minified total and per-tool byte accounting; +- separate name, description, input-schema, and other-field byte totals; +- absence or presence of output schemas and annotations explicitly; and +- separately measured initialization instructions and `get_readme` payload. + +The test uses a reviewed high-water byte budget rather than a brittle exact +serialization equality. The initial budget is chosen after measuring this +branch's unchanged base router and must leave only modest deliberate headroom +for the new chart fields and corrected status description. The report is +visible under `--nocapture` and included in the final verification evidence. + +This work does not infer a token count from bytes. Any later core-profile +proposal must measure the exact transformed tool payload with the target +client, model tokenizer, and versions, and must compare cold start with and +without initialization instructions and `get_readme`. + +Core/full profiles are deferred because: + +- current core membership has no usage evidence; +- the stored router is not currently used by the default generated handler, so + profile filtering requires deliberate router rewiring; +- hidden tools would require client configuration or reliable dynamic + `tools/list_changed` support; and +- resources/prompts are primary-only and cannot yet replace routed query and + describe tools for persistent or attached databases. + +### 7. Keep `chart` as a bounded diagnostic + +`chart` remains a direct SQL-to-inline-image convenience, not a general +visualization framework. Removing Plotters would simplify some native +dependencies but would not simplify the npm wrapper or release matrix, and no +clean A/B package-size or build-time measurement currently supports removal. + +The MCP input gains four optional controls: + +```text +bar_orientation: "vertical" | "horizontal" (default "vertical") +y_scale: "linear" | "log" (default "linear") +show_legend: boolean (default true) +label_values: boolean (default false; bars only) +``` + +Defaults preserve existing output behavior. Parameter semantics are based on +data roles rather than physical screen axes: `x` remains the bar category, +`y` remains the numeric measure, and `y_range`/`y_scale` control that measure +even when horizontal bars draw it along the physical x-axis. + +#### Horizontal bars + +- Preserve first-seen SQL row order and draw the first ranked row at the top. +- Preserve grouped multi-series behavior and existing color mapping. +- Swap axis descriptions appropriately and reserve more category-label space. +- Document that callers should increase chart height for long rankings rather + than adding speculative automatic sizing. +- Preserve characterized vertical-bar edge behavior: a duplicate + category/series row remains a second overlapping mark in input order; a + missing category/series cell remains a gap; series are ordered + deterministically by their existing key order; and the eight-color palette + cycles for the ninth and later series. One category, long labels, and Unicode + labels remain accepted. They are not aggregated, truncated, auto-sized, or + rejected merely for layout quality. + +#### Legend and value labels + +- `show_legend: false` suppresses legends for bars, lines, and scatter plots. +- Existing `label_points: true` continues to suppress the line/scatter legend + regardless of `show_legend`. +- `label_values: true` labels bar values only, using the original scalar display + form. Using it with another chart type is a caller-facing invalid argument. +- The internal point model retains that original scalar text before numeric + conversion so labels do not round-trip through `f64` formatting. +- No collision-avoidance or formatting language is added. + +#### Positive logarithmic measure scale + +- `y_scale: "log"` is supported for bar, line, and scatter charts only. +- Every plotted measure and both explicit `y_range` endpoints must be finite + and strictly positive. +- Zero, negative, mixed-sign, non-increasing, or non-finite ranges return a + caller-facing invalid argument; values are never silently dropped or + clamped. +- Logarithmic bars begin at the effective positive lower bound rather than zero. +- Automatic ranges are computed in natural-log space. Five-percent log-span + padding is clamped to `ln(f64::from_bits(1))..=ln(f64::MAX)` before + exponentiation. A single repeated value uses a fixed five-percent decade + span. If exponentiation/rounding collapses an endpoint at a finite bound, use + the adjacent representable positive float on the available side; the final + range must be finite, positive, strictly increasing, and enclose every value. +- An explicit log range must contain every plotted value. In particular, a log + bar outside the range is rejected rather than drawn from the positive lower + baseline into inverted or clipped geometry. +- Histogram log behavior, x-log, symlog, negative-only log, and custom bases are + deferred. + +#### Existing correctness fixes included + +- Bars always treat `x` as categorical. The current `x_as_category: false` + route can create numeric positions against a category-count axis and put bars + off-canvas. +- `y_range` is applied to bars; it is currently documented but ignored. +- A linear bar baseline is zero when zero is in range, otherwise the nearer + explicit range boundary (lower for positive-only, upper for negative-only), + so a fixed range never creates off-axis baseline geometry. +- Explicit ranges are validated as finite and strictly increasing before + Plotters receives them. +- Existing temporal line/scatter documentation and test names are corrected to + match proportional temporal rendering. + +#### Preserve the published Rust surface + +`ChartOptions` is public and can be constructed with exhaustive struct literals. +Adding fields would be a source-breaking Rust change even though the MCP schema +change is additive. Keep `ChartOptions` and the public `render_chart` entrypoint +source-compatible. Introduce an internal presentation-options type and an +internal extended renderer; the public function delegates with presentation +defaults, while the MCP handler uses the extended path. + +### 8. Documentation and terminology contract + +Use these terms consistently: + +- **local** — the ephemeral primary database, discarded with the session; +- **persistent** — the reserved durable database attached under the + `persistent` alias; and +- **attached database** — a user-supplied alias added by `attach_database`. + +`workspace` remains only where compatibility requires it: the deprecated +`--workspace` alias, existing resource URI, or historical release text. +Internal identifier renaming is not required when it would create unrelated +churn. + +Update all affected public surfaces: + +- CLI help for doctor, daemon status, read-only mode, and `hyperd` resolution; +- the `status` and `chart` tool descriptions; +- `hyperdb-mcp/src/readme.rs`; +- `hyperdb-mcp/README.md`; +- `hyperdb-mcp/SMOKE_TESTS.md`; +- chart demo comments and option examples; +- resource README rendering and semantic tests; and +- `hyperdb-mcp/CHANGELOG.md` under `## [Unreleased]`. + +Correct the current read-only drift: documentation must match every actual +write guard, and `unwatch_directory` and Hyper-format export remain allowed. +Do not hand-edit workspace/package versions or the root generated changelog. + +--- + +## Compatibility and versioning + +- The default router remains the full 33-tool surface. +- No existing MCP tool, parameter, result field, resource URI, prompt, CLI flag, + or constructor is removed or renamed. +- New result fields and MCP chart parameters are additive. +- Existing chart defaults remain unchanged. +- `ChartOptions` and public `DaemonInfo` remain source-compatible by keeping new + presentation and discovery metadata in separate internal types. +- Old daemon discovery files remain readable. +- Plain semver remains the only daemon takeover comparison key. +- The database default remains `local`. +- Public changes receive per-crate `## [Unreleased]` entries and Conventional + Commits. Release automation, not this branch, owns version changes. + +## Error handling + +- Doctor distinguishes absent, unreadable, malformed, unreachable, and live + state without mutating it. +- Invalid launcher JSON becomes an explicit warning; it cannot crash startup. +- Installation paths that cannot be represented as UTF-8 use a lossless or + clearly marked display representation rather than panicking. +- Chart option/range/log violations return `INVALID_ARGUMENT` before rendering. +- Persistent attach contention preserves the original Hyper error and SQLSTATE + inside a contextual `RESOURCE_BUSY` response. +- Non-attach SQLSTATE `55006` behavior is unchanged. +- Existing errors and structured/text response mirroring remain compatible. + +## Testing strategy + +All behavior is developed red-before-green. A test must fail for the expected +reason before production code is written, and the implementing agent records +both the red and green commands/output. + +### Doctor and installation identity + +- Native doctor under isolated environment variables reports native/API/path + facts and absent npm metadata honestly. +- Doctor creates no state directory, discovery file, persistent file, log, + daemon, or scratch database. +- Human and JSON reports carry the same facts. +- Valid npm metadata appears; deliberately mismatched versions warn; malformed + metadata warns without crashing. +- Old and enriched daemon discovery JSON both parse. +- Stale/malformed discovery remains on disk after doctor. +- A non-`NotFound` discovery I/O failure reports `unreadable`; it does not + become `missing`, `malformed`, or a whole-command failure. +- A daemon found by explicit discovery and one found only by scan are + distinguished. +- Discovery and scan candidates both require a fresh port-verified enriched + `STATUS`; a deterministic mismatched-PID/build discovery fixture reports the + fresh identity plus a stale/replaced warning and leaves the file untouched. +- Pure collector tests inject the reader/prober/scanner/deadline and prove that + no cleanup or write dependency is reachable; the real child-process case is + a smoke test, not the sole side-effect proof. +- Known path/control-character inputs are escaped in human output, non-UTF-8 + path display is marked, overlong strings are bounded, an unknown secret + sentinel never appears, and reports warn before sharing local paths. +- Exact old/new flat discovery and health-`STATUS` fixtures plus an exhaustive + legacy `DaemonInfo` literal preserve compatibility. +- `daemon status --port` probes the supplied port. +- restart reports use the discovered health port. + +### Persistent lock + +- A synthetic structured attach error with SQLSTATE `55006` becomes + `RESOURCE_BUSY` and includes path, SQLSTATE, and doctor guidance. +- A real two-private-engine reproduction holds one persistent file open and + verifies the second attachment returns the same actionable classification. +- A `55006` error outside persistent attach remains outside this mapping. +- Failed warm-up leaves MCP serving: status remains prompt and the first + persistent-routed tool returns structured `RESOURCE_BUSY`. +- The test does not invent or depend on unsupported `hyperd` flags. +- The real two-engine and MCP warm-up reproductions run wholly in a dedicated + child test process. The parent owns the exact child handle and kills then + waits for it on timeout, guaranteeing that a blocked attach cannot leave an + in-process worker behind. A Hyper API lifecycle characterization separately + has a helper report its exact `HyperProcess::pid()`, kills/waits the helper, + and bounded-polls that hyperd PID until the callback-connection dead-man + switch shuts it down. The lock tests may claim guaranteed cleanup only while + that characterization passes; otherwise they require reviewed process-group + or job-object containment/lifecycle repair first. + +### Status + +- Full MCP status returns `engine_busy: false`, full statistics, and correct MCP + and API identities. +- Holding the engine lock makes MCP status promptly return the documented + degraded shape with `engine_busy: true`. +- Degraded output omits documented expensive fields and carries correct version + identities. +- Tool, concise README, public README, and smoke documentation all explain the + non-definitive degraded `hyperd_running` case. +- Workspace/readme resources consume actual status keys. + +### Resolved database + +- Every routed tool named in section 5 is exercised for default local routing. +- Representative tools cover `persist: true`, mixed-case `persistent`, explicit + `database: "local"` winning over `persist: true`, and a mixed-case attached + alias canonicalizing to lowercase. +- A structural coverage test prevents a newly routed tool from silently + omitting `resolved_database`. +- `copy_query.target_database` remains present and consistent. +- Query content order, chart image delivery, and old-client text JSON remain + unchanged. +- An explicit routed-tool allowlist is checked against schema-derived candidates + plus the semantic `copy_query` exception. Tests cover every existing success + shape, including empty, not-found, and partial shapes where the tool treats + those outcomes as success, while pinning prior fields and content order. + +### Tool catalog + +- MCP `tools/list` reports the legacy 33 names and no doctor. +- The test emits total/per-tool/breakdown byte metrics under `--nocapture` and + enforces the reviewed high-water budget. +- Initialization instructions and `get_readme` are measured separately. +- Read-only mode does not silently change the advertised full surface. +- Generated router names drive README coverage so a tool cannot bypass the + documentation assertion. +- The byte metric is exactly minified `serde_json::to_vec` output for the typed + `Vec` returned by the generated router, excluding the JSON-RPC envelope. + The unchanged base is remeasured through that same helper, the budget is the + integer `57_344`, and output-schema/annotation presence or absence is asserted + explicitly. + +### Chart + +- Parser/validation unit tests cover accepted/default/invalid orientation and + scale combinations. +- SVG semantic tests cover horizontal order, grouped geometry, absent legend, + visible values, and log ticks/values without image goldens. +- PNG smoke tests cover each new renderer path. +- Range tests cover reversed, equal, non-finite, zero, negative, mixed-sign, + one-value, minimum-positive-subnormal, maximum-finite, and explicit-range + containment cases. +- Existing vertical linear output remains the default. +- MCP dispatch tests prove schema deserialization and result metadata. +- Long/Unicode categories, duplicate category-series rows, missing cells, + multiple series, and more series than the palette are covered where behavior + is intentionally supported or rejected. + +### Required gates + +Focused suites run throughout implementation. Before completion, the validator +runs the documented repository gates and records exit codes and output: + +```bash +cargo fmt --all --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test -p hyperdb-mcp +HYPERD_PATH=/Users/ssteiner/dev/bin/hyperd cargo test --workspace --exclude hyperdb-api-node --exclude hyperdb-bootstrap +``` + +Hyper-backed gates run with local callback-listener permission when the sandbox +otherwise blocks loopback binding. A silent, hanging, or outputless command is +not reported as passing. + +## Agent and review workflow + +This is a Harness plan-driven change with role separation: + +1. The main thread owns this design and the implementation plan. +2. The written plan receives two independent adversarial reviews in parallel: + one fast/mechanical and one deep/architectural. The main thread revises it. +3. Each implementation task is owned by a developer or tester agent using the + applicable repository role brief and explicit file ownership. +4. A separate adversarial task reviewer inspects every developer result against + the specification and real test evidence. Important findings return to a + developer and then a fresh re-review. +5. The integrated branch receives independent fast and deep final reviews. + Reviewer claims are reconciled against code and captured command output; + unsupported reviewer assertions are not accepted on authority alone. +6. The main thread performs the final merge-readiness judgment. No developer + self-report substitutes for validator output, and no agent that implemented + a task acts as its final reviewer or publisher. +7. After implementation, validation, and review are complete, create + `/Users/ssteiner/dev/ssteiner-ai/notes/hyperdb-mcp-agent-ux-implementation-2026-08-14.md` + as the durable handoff and memory artifact. It records the approved design + and plan, actual changes and commits, before/after catalog measurements, + every final verification command and exit status, reviewer findings and + resolutions, deferred decisions, known operational constraints, and the + LLM/reasoning mode and UI client used for the work. Any genuinely reusable + role lesson is also added to the applicable tracked agent-profile LEARNINGS + LOG; do not add speculative or one-off noise merely to create an entry. A + fresh read-only reviewer fact-checks the completed note against immutable + commits, source, and captured command evidence before handoff. + +## Implementation boundaries + +Expected primary change areas: + +- `hyperdb-mcp/src/main.rs` +- `hyperdb-mcp/src/diagnostics.rs` (new) +- `hyperdb-mcp/src/daemon/` +- `hyperdb-mcp/src/paths.rs` +- `hyperdb-mcp/src/version.rs` +- `hyperdb-mcp/src/error.rs` +- `hyperdb-mcp/src/engine.rs` +- `hyperdb-mcp/src/server.rs` +- `hyperdb-mcp/src/chart.rs` +- `hyperdb-mcp/npm/bin.js` +- focused/new tests under `hyperdb-mcp/tests/` +- MCP README, concise README, smoke guide, demos, and crate changelog. + +Changes outside this inventory require an explicit plan revision. In +particular, do not opportunistically refactor the API crates, alter default +persistence, rewrite router architecture, prune Plotters features, or change +release automation while implementing this design. + +## Risks and mitigations + +- **Doctor accidentally mutates state.** Keep collection pure/non-mutating and + assert filesystem snapshots before and after. +- **Launcher metadata is mistaken for trusted identity.** Label it as + launcher-reported, parse only known fields, and compare it with the native + facts instead of replacing them. +- **Daemon metadata breaks public Rust callers or old files.** Preserve + `DaemonInfo`; use a version-tolerant internal record and old-schema tests. +- **Global `55006` mapping creates false lock diagnoses.** Limit classification + to persistent attach and retain a non-attach regression test. +- **Additive result fields break strict ad hoc consumers.** Preserve all old + fields/content ordering and exercise every special response builder. +- **Database metadata drifts as tools are added.** Centralize injection and add + structural coverage over every routed handler. +- **Tool profiles are chosen by intuition.** Measure first and defer membership + until client/usage evidence exists. +- **Chart additions break published Rust struct literals.** Keep public + `ChartOptions` unchanged and use an internal extended renderer. +- **Log rendering silently misrepresents invalid values.** Validate all data and + ranges before Plotters and fail clearly. +- **Chart dependency cost grows unchecked.** Record current Plotters dependency + facts and require a future clean A/B measurement before pruning or removal. +- **Parallel agents create conflicting edits.** Assign non-overlapping ownership + where possible, run implementation tasks sequentially when they touch + `server.rs`, and prohibit reverting other agents' work. + +## Deferred decisions + +The following need evidence gathered by this work or later dogfooding: + +- exact core-profile membership and whether core should ever become the default; +- dynamic tool disclosure and client support for `tools/list_changed`; +- model/client-specific token savings from profiles or description changes; +- database-aware prompts and resources; +- Plotters feature pruning, package-size reduction, or chart removal; +- richer chart formatting, layout, labels, and log variants; +- OS-specific lock-owner identification; and +- an MCP doctor tool if enhanced status proves insufficient. + +## Acceptance summary + +The design is complete when: + +- doctor is useful even with no working engine and provably leaves state alone; +- installed wrapper/native/API/daemon identities are comparable; +- persistent lock contention is `RESOURCE_BUSY` with path and actionable next + steps, while unrelated `55006` behavior is unchanged; +- every routed success visibly names its canonical database; +- the default MCP still advertises the same 33 tools with measured schema cost; +- chart produces compatible defaults plus correct horizontal bars, legend + control, value labels, and positive logarithmic measure scales; +- documentation and code agree on status, routing, read-only behavior, chart, + and `hyperd` resolution; +- focused and workspace gates, including strict Clippy, have captured green + output; and +- independent per-task and integrated adversarial reviews have no unresolved + Critical or Important findings; and +- the dated `ssteiner-ai` implementation note contains the plan, evidence, + review record, deferred work, and durable memories needed for a future + implementation or maintenance session. diff --git a/hyperdb-api/tests/process_tests.rs b/hyperdb-api/tests/process_tests.rs index 00d01f6..280a21c 100644 --- a/hyperdb-api/tests/process_tests.rs +++ b/hyperdb-api/tests/process_tests.rs @@ -9,10 +9,100 @@ mod common; use hyperdb_api::{Connection, CreateMode, HyperProcess}; +use std::fs; use std::process::Command; use std::thread; use std::time::Duration; +const CALLBACK_PARENT_KILL_CHILD_ENV: &str = "HYPERDB_CALLBACK_PARENT_KILL_CHILD"; + +/// Killing the owning client process must close its callback connection, so +/// `hyperd` shuts itself down even though the client's `Drop` implementation +/// never gets a chance to run. +#[test] +fn callback_connection_shutdowns_hyperd_after_parent_kill() { + if let Some(pid_file) = std::env::var_os(CALLBACK_PARENT_KILL_CHILD_ENV) { + let params = common::test_hyper_params("callback_connection_parent_kill_child") + .expect("child must create Hyper parameters"); + let hyper = HyperProcess::new(None, Some(¶ms)).expect("child must start HyperProcess"); + let pid = hyper + .pid() + .expect("child must report HyperProcess public PID"); + fs::write(pid_file, pid.to_string()).expect("child must report Hyper PID to parent"); + + loop { + thread::park(); + } + } + + let temp_dir = tempfile::tempdir().expect("parent must create RAII temp directory"); + let pid_file = temp_dir.path().join("hyperd-pid"); + let test_name = "callback_connection_shutdowns_hyperd_after_parent_kill"; + let mut child = Command::new(std::env::current_exe().expect("test executable path")) + .args(["--exact", test_name, "--nocapture"]) + .env(CALLBACK_PARENT_KILL_CHILD_ENV, &pid_file) + .spawn() + .expect("parent must start exact helper child"); + + let pid = wait_for_reported_pid(&pid_file, Duration::from_secs(10)).unwrap_or_else(|message| { + let _ = child.kill(); + let _ = child.wait(); + panic!("callback helper failed before reporting hyperd PID: {message}"); + }); + assert!( + is_process_running(pid), + "reported hyperd PID {pid} must be live before its parent is killed" + ); + + child.kill().expect("parent must kill exact helper child"); + let child_status = child + .wait() + .expect("parent must wait for killed helper child"); + assert!( + !child_status.success(), + "the deliberately killed helper child must not report success" + ); + + let shutdown_detected = bounded_process_exit_poll(pid, Duration::from_secs(10)); + assert!( + shutdown_detected, + "hyperd PID {pid} remained live after its callback-owning parent was killed" + ); +} + +fn wait_for_reported_pid(pid_file: &std::path::Path, timeout: Duration) -> Result { + let deadline = std::time::Instant::now() + timeout; + loop { + match fs::read_to_string(pid_file) { + Ok(contents) => { + return contents + .trim() + .parse() + .map_err(|error| format!("invalid PID report {contents:?}: {error}")); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("could not read PID report: {error}")), + } + if std::time::Instant::now() >= deadline { + return Err(format!("no PID report appeared at {}", pid_file.display())); + } + thread::sleep(Duration::from_millis(20)); + } +} + +fn bounded_process_exit_poll(pid: u32, timeout: Duration) -> bool { + let deadline = std::time::Instant::now() + timeout; + loop { + if !is_process_running(pid) { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + thread::sleep(Duration::from_millis(20)); + } +} + #[test] fn test_hyper_process_start_stop() { let params = common::test_hyper_params("test_hyper_process_start_stop") diff --git a/hyperdb-mcp/CHANGELOG.md b/hyperdb-mcp/CHANGELOG.md index a446fbc..9a0adbe 100644 --- a/hyperdb-mcp/CHANGELOG.md +++ b/hyperdb-mcp/CHANGELOG.md @@ -31,9 +31,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/). backing table, its indexless shape, the ephemeral-vs-persistent durability rule, per-database isolation, and the `LEFT JOIN` pattern for enriching analytical tables with KV metadata. +- **Side-effect-free native `hyperdb-mcp doctor` diagnostics.** Human and + JSON reports compare authoritative native MCP/Rust API identity, bounded + launcher-reported npm provenance, resolved configuration, verified daemon + identity, and the measured MCP catalog without creating directories, + starting Hyper, or opening a database. +- **Canonical `resolved_database` result metadata.** Successful responses + from all 21 database-routed tools now identify the effective `local`, + `persistent`, or lowercase attached alias after routing precedence; + `copy_query` also retains `target_database`. +- **Diagnostic chart presentation controls.** MCP `chart` adds + `bar_orientation`, `label_values`, `show_legend`, and positive-only + `y_scale`, while preserving the public Rust `ChartOptions` surface and + existing rendering defaults. + +### Changed + +- **KV attachment/read-only clarification (supersedes the shorthand in the + Added notes above).** The global `--read-only` guard leaves the four KV + readers available, but every `kv_*` call targeting a user attachment still + requires that attachment to be registered writable because the backing table + may need initialization. +- **Status now has one full/degraded identity contract.** Both shapes report + correct MCP/Rust API installation identity, `default_database: "local"`, + attachments, watchers, and read-only state. `engine_busy: true` explicitly + means partial, inconclusive statistics that callers should retry. +- **Chart is documented and bounded as a quick SQL-to-image diagnostic.** Its + inline/file delivery, proportional temporal x axes, categorical override, + presentation controls, and finite/log range requirements are now consistent + across the MCP schema, smoke/demo guidance, the public README, and + `get_readme`. ### Fixed +- **Hyper-format export side-effect correction (supersedes the older + Unreleased note below).** Export does not mutate its source database, but it + creates or replaces the requested destination `.hyper` file and materializes + every user table from the selected source. It remains available under + `--read-only`; this is destination creation, not a raw database-file copy. +- **Daemon health-port targeting and diagnostics.** Explicit + `daemon status --port` now probes that exact health port, discovered-daemon + error reports target the effective health port, and best-effort health I/O + no longer retains the engine mutex. +- **Attachment contention is actionable for persistent *and* user attaches.** + A lock conflict (SQLSTATE `55006`, or a legacy "already attached" / "file is + locked" phrase from older hyperd) now returns `RESOURCE_BUSY` with the + effective path, preserved Hyper diagnostic/SQLSTATE, doctor guidance, and + non-accusatory possible-owner recovery. Previously only the reserved + persistent-attach path was reclassified, so a user `attach_database` on a + file another process already held surfaced as a generic `SqlError`; it now + routes through the same attach-context mapper. Unrelated `55006` errors + outside the attach context retain their existing mapping. +- **Chart geometry, ranges, and positive-log rendering.** Bars always treat x + as categorical, honor y ranges and in-range baselines, and validate finite, + increasing, representable spans. Horizontal ordering/grouping/labels and + extreme positive log ranges now render without zero baselines, collapsed + endpoints, unbounded ticks, or silently misrepresented measures. +- **Chart color parsing no longer panics on non-ASCII input.** A `color_map` + value that is six *bytes* but not six ASCII characters (e.g. `"1é234"`, where + `é` is two UTF-8 bytes) passed the length check and then panicked on a + non-char-boundary byte slice, aborting the request. `parse_hex_color` now + rejects any non-ASCII string up front and returns a normal "invalid color" + result instead. +- **`doctor` no longer presents an illustrative client-log path as fact in + ephemeral-only mode.** With no persistent database configured, the reported + client log path is derived from the doctor invocation's own temporary + directory and process id, so it cannot identify a separate running MCP + server's log directory. `doctor` now emits a warning making that explicit + instead of implying the path locates a live session. +- **npm launcher reports the correct manifest in the same-directory + fallback.** When the binary is resolved next to `bin.js` (rather than in a + platform subpackage), the launcher metadata's `platform.package_path` + pointed at a nonexistent platform-subdirectory `package.json`. It now + resolves the manifest that actually sits beside the binary, so + `doctor`/launcher diagnostics report a real path. - **I/O error fidelity on `value_path` and `load_file`.** File-read errors now preserve `PermissionDenied` → `ErrorCode::PermissionDenied` instead of collapsing every I/O failure to `FileNotFound`. A missing file still maps to `FileNotFound`; any other I/O error becomes a generic `InternalError` with the OS message. (Implemented via `McpError::from_io_error` in `error.rs`.) - **Misleading JSON-error `suggestion` text corrected.** The "requires a structured data type" (`42601`) and `JSON_VALUE`-not-implemented (`0A000`) errors previously suggested splitting the statement, which was wrong; they now advise casting the TEXT value to `json` first (`value::json ->> 'field'`), the actual fix. Applies narrowly to JSON-related cases; other `42601` syntax errors carry a generic message without a misleading hint. - **Caller-fixable argument errors now return `INVALID_ARGUMENT`, not @@ -63,15 +134,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [0.5.0] - 2026-06-07 -### Fixed - -- Query results now preserve the sign of negative `NUMERIC`/`DECIMAL` values - with magnitude less than 1. Previously a value like `CAST(-0.5 AS - numeric(10,4))` was serialized to JSON as `0.5` because `row_value_to_json` - stringifies NUMERIC via `Numeric::to_string()`, whose `Display` impl dropped - the sign for sub-unit magnitudes (fixed in `hyperdb-api-core`). This silently - flipped the sign of correlations, 0–1 indices, and regression residuals. - ### Added - The `status` tool now reports an `engine` block with the backing `hyperd` @@ -287,6 +349,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). lowercases `target_database` after the `LOCAL_ALIAS` filter so the registry lookup AND the qualified-SQL build path agree on the canonical lowercase form. +- Query results now preserve the sign of negative `NUMERIC`/`DECIMAL` values + with magnitude less than 1. Previously a value like `CAST(-0.5 AS + numeric(10,4))` was serialized to JSON as `0.5` because `row_value_to_json` + stringifies NUMERIC via `Numeric::to_string()`, whose `Display` impl dropped + the sign for sub-unit magnitudes (fixed in `hyperdb-api-core`). This silently + flipped the sign of correlations, 0–1 indices, and regression residuals. ## [0.1.1] - 2026-05-13 diff --git a/hyperdb-mcp/DEVELOPMENT.md b/hyperdb-mcp/DEVELOPMENT.md index 9033b65..facdd48 100644 --- a/hyperdb-mcp/DEVELOPMENT.md +++ b/hyperdb-mcp/DEVELOPMENT.md @@ -111,7 +111,10 @@ Three edges to this guarantee, all documented in `src/engine.rs`: ### Prerequisites - Rust toolchain (see `rust-version` in `Cargo.toml`) -- `hyperd` binary — set `HYPERD_PATH` or place on `PATH` +- `hyperd` binary — set `HYPERD_PATH` to the executable or its containing + directory. When the variable is absent or non-UTF-8, the runtime walks upward + through current-directory ancestors for `.hyperd/current/hyperd`; it does not + perform a general `PATH` lookup. ### Build diff --git a/hyperdb-mcp/README.md b/hyperdb-mcp/README.md index 769d160..2142c84 100644 --- a/hyperdb-mcp/README.md +++ b/hyperdb-mcp/README.md @@ -23,7 +23,7 @@ This means an LLM can: - **Build on prior work** — load yesterday's cleaned dataset and extend it without re-processing from scratch - **Maintain structured context** — store relationship graphs, timelines, or decision logs as proper tables with typed columns -The ephemeral database is scratch space (think: a whiteboard). The persistent database is long-term memory (think: a filing cabinet you can query). Multiple AI clients sharing the same daemon see the same persistent data — so Claude Code, Cursor, and VS Code Copilot can all read from and contribute to the same knowledge base. +The local database is ephemeral scratch space (think: a whiteboard). The persistent database is long-term memory (think: a filing cabinet you can query). Multiple AI clients sharing the same daemon see the same persistent data — so Claude Code, Cursor, and VS Code Copilot can all read from and contribute to the same knowledge base. **Table or key-value store?** For a handful of small facts, notes, or flags, prefer the built-in key-value store (`kv_set` with `persist: true`) over `CREATE TABLE` + `load_data` — it needs no schema and no DDL. Reach for a real table when you need typed columns, JOINs, or aggregation. See [Working with both databases](#working-with-both-databases) for the `persist` / `database` mechanics that apply to both paths. @@ -48,9 +48,9 @@ The ephemeral database is scratch space (think: a whiteboard). The persistent da - **Smart schema inference** — exact (Arrow/Parquet), structural (JSON), heuristic (CSV) with full-file numeric widening - **Pre-ingest file inspection** — `inspect_file` dry-runs the same inference without touching Hyper so LLMs can build safe schema overrides in one shot - **Partial schema overrides** — supply just the columns you want to correct (e.g. `{"population":"BIGINT"}`) — the rest keep their inferred type -- **Rich resource surface** — workspace readme, per-table JSON and CSV samples, and one JSON + one CSV resource per table so LLMs can orient themselves via `resources/list` without any tool calls +- **Rich resource surface** — database overview, per-table JSON and CSV samples, and one JSON + one CSV resource per table so LLMs can orient themselves via `resources/list` without any tool calls - **Saved queries** — register named read-only SQL with `save_query`; each query becomes `hyper://queries/{name}/definition` (metadata) + `hyper://queries/{name}/result` (live re-run). Persisted in the persistent attachment, session-only when `--ephemeral-only` -- **Key-value scratchpad** — lightweight `kv_set` / `kv_get` / `kv_list` / `kv_delete` / `kv_pop` / `kv_size` / `kv_clear` / `kv_list_stores` store for small notes and state without a `CREATE TABLE`. Ephemeral by default (lost on restart); pass `persist: true` (or `database: "persistent"`) to make a store durable across sessions +- **Key-value scratchpad** — lightweight `kv_set` / `kv_set_many` / `kv_get` / `kv_list` / `kv_delete` / `kv_pop` / `kv_size` / `kv_clear` / `kv_list_stores` store for small notes and state without a `CREATE TABLE`. Ephemeral by default (lost on restart); pass `persist: true` (or `database: "persistent"`) to make a store durable across sessions - **Live resource-update notifications** — MCP clients can `resources/subscribe` to any `hyper://...` URI; the server fires `notifications/resources/updated` after every ingest, DDL, watcher event, or saved-query mutation --- @@ -118,8 +118,11 @@ export HYPERD_PATH="$PWD/.hyperd/current" # or pass via your MCP config `hyperdb-bootstrap` also has a library API if you'd rather wire the download into your own build script — see its [README](../hyperdb-bootstrap/README.md). If you already have `hyperd` -elsewhere (Tableau Hyper API for C++/Python/Java ships one), point -`HYPERD_PATH` at it or add it to your `PATH`. +elsewhere (Tableau Hyper API for C++/Python/Java ships one), set +`HYPERD_PATH` to either the executable or its containing directory. +When that variable is absent or non-UTF-8, the runtime walks upward from its +current directory for `.hyperd/current/hyperd`; it does not perform a general +`PATH` lookup. ### MCP Client Configuration @@ -156,7 +159,12 @@ By default, persistent storage lives at the platform data dir (`~/Library/Applic "args": ["--persistent-db", "/path/to/my-project.hyper"] ``` -Multiple MCP clients can point at the **same** persistent file simultaneously — they all connect through the shared `hyperd` daemon and use Hyper's MVCC transaction isolation. See [Operating Modes](#operating-modes) below. +Multiple MCP clients can point at the **same** persistent file simultaneously +when they reuse the shared `hyperd` daemon; Hyper's MVCC transaction isolation +coordinates their connections. A separate private `hyperd`, Tableau, or another +process trying to attach the same file can instead receive contextual +`RESOURCE_BUSY`. See [Operating Modes](#operating-modes) and +[Error Handling](#error-handling). #### Claude Code / AI Suite @@ -182,7 +190,7 @@ Any tool that supports the MCP stdio transport can use this server. Point it at ## Operating Modes -Each session has **two databases**: an ephemeral primary (scratch space — always created fresh per session, deleted on exit) and a persistent database (queryable long-term memory — stored at the platform-default location or a path you supply, survives indefinitely). Unqualified SQL targets the ephemeral primary; the persistent database is reachable as the `"persistent"` alias. +Each session has **two databases**: the ephemeral **local** primary (scratch space — always created fresh per session, deleted on exit) and a **persistent** database (queryable long-term memory — stored at the platform-default location or a path you supply, survives indefinitely). Unqualified SQL targets local; the durable database is reachable as the `"persistent"` alias. Additional `.hyper` files are **attached databases** under user-chosen aliases. ### Hyper engine @@ -197,15 +205,15 @@ The shared daemon is the bigger win for users running multiple AI clients (Claud | Mode | Flag | Behavior | |---|---|---| -| **Default** | *(none)* | Ephemeral primary in `$TMPDIR/hyperdb-mcp--/scratch.hyper` + persistent attachment at the platform data dir (e.g. `~/Library/Application Support/hyperdb/workspace.hyper` on macOS). | +| **Default** | *(none)* | Ephemeral local database in `$TMPDIR/hyperdb-mcp--/scratch.hyper` + persistent attachment at the platform data dir (e.g. `~/Library/Application Support/hyperdb/workspace.hyper` on macOS). | | **Custom persistent path** | `--persistent-db ` | Same as default but the persistent file lives at ``. The deprecated `--workspace ` is accepted as an alias with a stderr warning. | -| **Ephemeral-only** | `--ephemeral-only` | No persistent attachment; the session has only the ephemeral primary plus any user-attached databases via `attach_database`. Saved queries fall back to in-memory storage and disappear when the session ends. | +| **Ephemeral-only** | `--ephemeral-only` | No persistent attachment; the session has only the local database plus any user-attached databases via `attach_database`. Saved queries fall back to in-memory storage and disappear when the session ends. | `HYPERDB_PERSISTENT_DB` overrides the default persistent path the same way `--persistent-db` does. ### Working with both databases -Tool calls default to the ephemeral primary — that's the LLM's scratch space for exploratory work that doesn't need to outlive the session. To store data in long-term memory (the persistent database), there are two ways to reach it: +Tool calls default to the local database — that's the LLM's ephemeral scratch space for exploratory work that doesn't need to outlive the session. To store data in long-term memory (the persistent database), there are two ways to reach it: **1. Per-tool `database` parameter** (preferred for ergonomic LLM workflows): @@ -222,7 +230,12 @@ describe({ database: "persistent" }) sample({ table: "customers", database: "persistent" }) ``` -The `database` parameter is available on `query`, `execute`, `load_data`, `load_file`, `load_files`, `watch_directory`, `describe`, `sample`, `chart`, `export`, and `set_table_metadata`. The shorthand `persist: true` (sugar for `database: "persistent"`) is available on `load_data`, `load_file`, `load_files`, and `watch_directory`. Pass any user-attached writable alias (created via `attach_database`) to target a custom database. +The `database` parameter is available on `query`, `execute`, `load_data`, `load_file`, `load_files`, `watch_directory`, `describe`, `sample`, `chart`, `export`, and `set_table_metadata`. The shorthand `persist: true` (sugar for `database: "persistent"`) is available on `load_data`, `load_file`, `load_files`, and `watch_directory`. Read tools generally accept a read-only user attachment; write tools require a writable one. The exception is the KV family: every `kv_*` call to a user attachment requires it to be writable because the backing table may need initialization. + +Every successful database-routed response includes the canonical +`resolved_database`: `"local"`, `"persistent"`, or the lowercase attached +alias after precedence is applied. An explicit `database` wins over +`persist: true`; `copy_query` additionally retains `target_database`. (`query_data` and `query_file` are one-shot tools that materialize the inline data into their own temp table and query it — they do not accept a `database` parameter because the data isn't in a persisted database to begin with.) @@ -237,7 +250,12 @@ CREATE TABLE "persistent"."public"."revenue_2026" AS SELECT region, SUM(amount) FROM scratch_orders GROUP BY region; ``` -**Per-database `_table_catalog`:** every writable database — persistent and any user-attached writable file — gets its own `_table_catalog` lazily seeded on first ingest. MCP-managed metadata (load tool, params, timestamps, prose fields set via `set_table_metadata`) lives alongside the data file, so opening a `.hyper` file later as a primary workspace finds the catalog ready. If you want a pristine `.hyper` file for export with no MCP bookkeeping, run `DROP TABLE ""."public"."_table_catalog"` once and subsequent sessions opening that file will leave it dropped. +**Metadata catalogs:** local and persistent tables share the persistent +`_table_catalog`, keyed by table name across their union. `set_table_metadata` +therefore targets an existing catalog entry rather than re-checking that the +table exists in a selected local/persistent database. Each writable +user-attached database has its own per-database catalog; read-only attachments +cannot be metadata targets. **Detach safety:** `detach_database` rejects with `InvalidArgument` if any active watcher targets the alias — call `unwatch_directory` first. This prevents the watcher's pool from silently writing into a now-detached file (or worse, the wrong file if the alias is later re-attached to a different path). @@ -255,7 +273,29 @@ hyperdb-mcp daemon # Run as a daemon explicitly (rarely needed) 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 ` reply before trusting a daemon, so an unrelated process occupying the port is skipped rather than mistaken for the daemon. +For installation and configuration diagnostics that also work before MCP can start, use the native doctor command: + +```bash +hyperdb-mcp doctor +hyperdb-mcp doctor --json +``` + +Doctor is side-effect-free: it creates no directories, does not start a daemon +or `hyperd`, and does not open or create a database. Its native executable, MCP build, +and compiled Rust API identities are authoritative; npm wrapper/platform +details are bounded, optional launcher-reported provenance. A live daemon is +attributed only after a fresh `STATUS` response is verified. Reports contain +local paths; review them before sharing. + +**Port discovery.** MCP auto-spawn discovers a live daemon first, then scans +upward from **7485** across 16 candidates before starting one at the selected +exact port. Setting `HYPERDB_DAEMON_PORT` pins auto-spawn to one candidate. +In contrast, a manually launched foreground `hyperdb-mcp daemon` never scans: +`--port ` binds that exact port, while an omitted `--port` binds the +configured/base port exactly. The health port doubles as a single-instance +lock and identity check: clients send `PING` and require a +`PONG hyperdb-mcp ` reply before trusting a daemon, so an unrelated +process is not mistaken for HyperDB. **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 ` or set `HYPERDB_DAEMON_IDLE_TIMEOUT`. @@ -273,7 +313,7 @@ If hyperd repeatedly fails to start (3 attempts within 60 seconds — e.g., misc | Flag | Behavior | |---|---| -| `--read-only` | Disables `execute`, `load_data`, `load_file`, `watch_directory`, `save_query`, `delete_query`, and the KV mutators (`kv_set`, `kv_delete`, `kv_pop`, `kv_clear`). Export (including `.hyper`) stays allowed — it's a read-only file copy. See [Read-Only Mode](#read-only-mode). | +| `--read-only` | Guards `execute`, all four `load_*` tools, `watch_directory`, saved-query mutations, `set_table_metadata`, `copy_query`, `kv_set`, `kv_set_many`, `kv_delete`, `kv_pop`, `kv_clear`, and writable/create attachment. Read-only attachment, `unwatch_directory`, and export (including `.hyper`) stay available. See [Read-Only Mode](#read-only-mode). | --- @@ -312,11 +352,11 @@ query_file(path: '/tmp/sales.parquet', sql: 'SELECT TOP 10 * FROM sales ORDER BY | `table_name` | string | no | Table name — defaults to filename stem | | `schema` | object | no | Partial column-name → type map (see [Schema Overrides](#schema-overrides)) | -### Workspace Tools +### Database Tools #### `load_data` -Load inline data into a named workspace table. +Load inline data into a named local, persistent, or attached-database table. ``` load_data(table: 'customers', data: '[{"id":1,"name":"Alice"},...]') @@ -332,7 +372,7 @@ load_data(table: 'customers', data: '[{"id":1,"name":"Alice"},...]') #### `load_file` -Load a file into a named workspace table. +Load a file into a named local, persistent, or attached-database table. ``` load_file(table: 'orders', path: '/tmp/orders.csv') @@ -353,7 +393,7 @@ so you can build a minimal, correct override in one shot. #### `load_iceberg` Load an [Apache Iceberg](https://iceberg.apache.org/) table into a named -workspace table. Pass the absolute path to the Iceberg table root (the +local table. Pass the absolute path to the Iceberg table root (the directory containing `metadata/` and `data/`); hyperd's native Iceberg reader derives the schema and resolves the snapshot. @@ -374,7 +414,7 @@ Iceberg table metadata. #### `query` -Run a **read-only** SQL query against the workspace. Accepts `SELECT`, `WITH`, `EXPLAIN`, `SHOW`, `VALUES`. For DDL/DML use `execute`. +Run a **read-only** SQL query against local (default), persistent, or an attached database. Accepts `SELECT`, `WITH`, `EXPLAIN`, `SHOW`, `VALUES`. For DDL/DML use `execute`. ``` query(sql: 'SELECT c.name, SUM(o.amount) FROM orders o JOIN customers c ON o.customer_id = c.id GROUP BY c.name') @@ -405,7 +445,7 @@ Validation rules enforced before any SQL hits the server: #### `describe` -List all workspace tables with their schemas, column types, and row counts. +List all tables in the selected database with their schemas, column types, and row counts. #### `sample` @@ -510,23 +550,24 @@ delete_query(name: 'top_5_customers') Returns `{ "deleted": true }` when the query existed, `{ "deleted": false }` when it did not (no error on unknown names). Disabled in read-only mode. -### Key-Value Store +### Key-Value Scratchpad Lightweight named scratchpad for stashing a value under `store` + `key` and recalling it later — remember a variable, a summary, a JSON config, or a work-queue entry without creating a table or running `load_data`. -> **Stores default to the EPHEMERAL database and are LOST on server restart.** +> **Stores default to the local database and are LOST on server restart.** > Pass `database="persistent"` (or `persist=true`) to make a store durable > across restarts, or an attached alias to target that database. Each database > has its own isolated set of stores; a store in one database is invisible from > another. -Eight tools cover the surface: +Nine tools cover the surface: | Tool | Purpose | Parameters | |---|---|---| | `kv_set` | Write/overwrite a value (upsert) | `store`, `key`, `value`, `database`, `persist` | +| `kv_set_many` | Atomically write an `entries` batch, optionally skipping existing keys | `store`, `entries`, `overwrite`, `database`, `persist` | | `kv_get` | Read a value by store + key (`value` is null when absent, not an error) | `store`, `key`, `database`, `persist` | | `kv_delete` | Remove one key (`{deleted: true/false}`, no error on unknown key) | `store`, `key`, `database`, `persist` | | `kv_list` | List all keys in a store, sorted ascending | `store`, `database`, `persist` | @@ -541,7 +582,12 @@ kv_get(store: 'session', key: 'last_report') ``` Key properties: -- **Read-only mode** — the four mutators (`kv_set`, `kv_delete`, `kv_pop`, `kv_clear`) are disabled and return `READ_ONLY_VIOLATION`; the four readers (`kv_get`, `kv_list`, `kv_size`, `kv_list_stores`) always work. +- **Read-only mode** — the five mutators (`kv_set`, `kv_set_many`, `kv_delete`, `kv_pop`, `kv_clear`) are disabled and return `READ_ONLY_VIOLATION`; the global guard leaves the four readers (`kv_get`, `kv_list`, `kv_size`, `kv_list_stores`) available. +- **Attached-database access** — every attached target must have been attached + with `writable=true`, even for readers, because a KV call may need to + initialize its backing table. The global `--read-only` guard still blocks + only the five mutators; an allowed reader can use local/persistent storage but + cannot use a read-only user attachment. - **Pop order** — `kv_pop` removes and returns the **lowest-keyed** entry in lexicographic key order (not insertion order), making a store usable as a simple work queue. - **No store registry** — a store that becomes empty simply **drops out** of `kv_list_stores`; there is no separate registry of store names. - **Backing table** — values live in `_hyperdb_kv_store(store_name, key, value)`, which is indexless (Hyper has no indexes) and hidden from `describe` by its `_hyperdb_` prefix, but is directly queryable — e.g. `LEFT JOIN` it to enrich an analytical table (always filter on `kv.store_name`). Uniqueness of `(store_name, key)` is enforced by the tool layer's upsert, atomic within a single server process. See the `hyper://schema/kv` resource for the schema and join pattern. @@ -564,13 +610,16 @@ export(sql: 'SELECT ...', path: '~/Desktop/analysis.hyper', format: 'hyper') | `path` | string | yes | Output file path | | `format` | string | yes | `"csv"`, `"parquet"`, `"iceberg"`, `"arrow_ipc"`, or `"hyper"` | -The `"hyper"` format produces a `.hyper` file that opens directly in **Tableau Desktop**. +The `"hyper"` format produces a `.hyper` file that opens directly in **Tableau +Desktop**. It does not mutate the source database; it creates or replaces the +destination and materializes every user table from the selected source into it. ### Visualization #### `chart` -Render a chart from a SQL query and return it inline as an image. +Render a bounded quick diagnostic from a SQL query. This convenience tool is +for inspecting or sharing one chart, not for dashboard/layout composition. ``` chart(sql: 'SELECT product, SUM(revenue) as total FROM sales GROUP BY product', chart_type: 'bar', x: 'product', y: 'total', title: 'Revenue by Product') @@ -579,17 +628,46 @@ chart(sql: 'SELECT product, SUM(revenue) as total FROM sales GROUP BY product', | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `sql` | string | yes | Read-only SQL query returning the data to plot | +| `database` | string | no | Route SQL to `local` (default), `persistent`, or an attached alias | | `chart_type` | string | yes | `bar`, `line`, `scatter`, or `histogram` | | `x` | string | yes* | X-axis column (for histogram, the value column) | | `y` | string | yes* | Y-axis column (not required for histogram) | | `series` | string | no | Grouping column for multi-series plots | +| `color_map` | object | no | Map series names to hex colors such as `{"East":"#e41a1c"}` | +| `label_points` | bool | no | Label line/scatter points by series and suppress their legend | | `title` | string | no | Chart title | | `format` | string | no | `png` (default) or `svg` | | `width` | int | no | Pixels (default 800, clamped 200..4096) | | `height` | int | no | Pixels (default 480, clamped 150..4096) | | `bins` | int | no | Histogram bins (default 20, clamped 1..500) | - -Returns an `ImageContent` (base64 PNG or SVG) plus a stats JSON block. +| `output_path` | string | no | Destination file; parent directories are created | +| `inline` | bool | no | Return image bytes inline (default `true`) | +| `overwrite` | bool | no | Permit replacing `output_path` (default `true`) | +| `bar_orientation` | string | no | `vertical` (default) or `horizontal`; bars only | +| `label_values` | bool | no | Draw each original y scalar beside its bar | +| `show_legend` | bool | no | Show series legend (default `true`) | +| `y_scale` | string | no | `linear` (default) or positive `log`; no log histograms | +| `x_as_category` | bool | no | Force even categorical spacing on line/scatter x values | +| `x_range` / `y_range` | number pair | no | Explicit finite, strictly increasing bounds | + +With neither path nor delivery override, the PNG (or requested SVG) is returned +inline and no file is written. `output_path` means write plus inline; set +`inline=false` for disk-only output, with an auto-generated temp path when no +path is supplied. Explicit `format` and the path extension must agree. The +result ends with a stats JSON block containing `resolved_database` and, when +written, `output_path`. + +Line/scatter DATE, TIMESTAMP, and TIMESTAMPTZ x columns use proportional +temporal spacing automatically; TEXT is categorical. Set `x_as_category=true` +only when even spacing is deliberate. Bars always treat x as categorical. +Horizontal rankings preserve SQL row order with the first row at the top. +Long or Unicode labels are accepted but not auto-sized, so increase width or +height when needed. + +All explicit ranges must be finite, strictly increasing, and representable. +Log y values and bounds must also be positive and the explicit range must +contain every plotted value. Log bars begin at the effective positive lower +bound, never zero. ### Incremental Ingest @@ -620,27 +698,32 @@ Key properties: #### `status` -Returns plugin health, workspace mode, table count, total rows, disk usage, read-only flag, and active directory watchers with per-watcher stats. +Returns MCP/native/API installation identity, daemon and Hyper connection facts, +`default_database: "local"`, persistent-path state, table/row/disk statistics, +read-only state, attachments, and active watchers. A full response has +`engine_busy: false`. When `engine_busy: true`, the prompt response is partial: +SQL-dependent statistics are intentionally omitted and `hyperd_running: false` +is inconclusive. Retry `status` after the in-progress operation completes. --- ## MCP Resources -The server exposes workspace state as MCP **Resources**, discoverable via +The server exposes local and persistent database state as MCP **Resources**, discoverable via `resources/list`. Each resource advertises its own MIME type so clients can route it appropriately (LLM context vs. file download vs. chart). | URI | MIME | Content | |-----|------|---------| -| `hyper://workspace` | `application/json` | Workspace mode, table count, total rows, disk usage | +| `hyper://workspace` | `application/json` | Local/persistent state, table count, total rows, disk usage | | `hyper://tables` | `application/json` | Full list of tables with schemas and row counts | -| `hyper://readme` | `text/markdown` | Workspace overview as markdown: table catalog, related resources per table, and tool hints for a cold-started LLM | +| `hyper://readme` | `text/markdown` | Database overview as markdown: table catalog, related resources per table, and tool hints for a cold-started LLM | | `hyper://tables/{name}/schema` | `application/json` | Columns, types, nullability, and row count for one table | | `hyper://tables/{name}/sample` | `application/json` | First 5 rows of a table as JSON, with schema | | `hyper://tables/{name}/csv-sample` | `text/csv` | First 20 rows of a table as CSV, header-first | | `hyper://queries/{name}/definition` | `application/json` | Stored SQL + metadata for a saved query | | `hyper://queries/{name}/result` | `application/json` | Live result of a saved query — re-runs on every read | -| `hyper://schema/kv` | `text/plain` | KV scratchpad schema: the `_hyperdb_kv_store(store_name, key, value)` backing table, its indexless shape, the ephemeral-vs-persistent durability rule, and the `LEFT JOIN` enrichment pattern | +| `hyper://schema/kv` | `text/plain` | KV scratchpad schema: backing table and `LEFT JOIN` pattern, local-vs-persistent durability, global read-only guards, and writable user-attachment requirement (including readers) | Resource templates (discoverable via `resources/templates/list`): @@ -670,8 +753,8 @@ of mutation: | `load_data` / `load_file` (replace mode) | `hyper://workspace`, `hyper://tables`, `hyper://readme`, per-table schema + sample + csv-sample | Yes | | `load_data` / `load_file` (append mode) | Same per-table + summary URIs | No ¹ | | `watch_directory` ingest of a `.ready` pair | Same per-table + summary URIs | No ¹ | -| `execute` (INSERT / UPDATE / DELETE) | Workspace summary URIs | No | -| `execute` (CREATE / DROP / ALTER / TRUNCATE / RENAME) | Workspace summary URIs | Yes | +| `execute` (INSERT / UPDATE / DELETE) | Database-summary URIs | No | +| `execute` (CREATE / DROP / ALTER / TRUNCATE / RENAME) | Database-summary URIs | Yes | | `save_query` | (none per-URI) | Yes — two new `hyper://queries/{name}/...` resources | | `delete_query` | `hyper://queries/{name}/definition`, `hyper://queries/{name}/result` | Yes — two resources disappeared | @@ -706,8 +789,8 @@ Four guided analytical workflows registered as MCP **Prompts**. hyperdb-mcp --persistent-db ~/analytics.hyper --read-only ``` -- **Allowed:** `query`, `query_data`, `query_file`, `describe`, `sample`, `inspect_file`, `status`, `export`, and the KV readers `kv_get`, `kv_list`, `kv_size`, `kv_list_stores` -- **Blocked:** `execute`, `load_data`, `load_file`, `watch_directory`, `save_query`, `delete_query`, and the KV mutators `kv_set`, `kv_delete`, `kv_pop`, `kv_clear` — return `READ_ONLY_VIOLATION` +- **Allowed:** `query`, `query_data`, `query_file`, `describe`, `sample`, `inspect_file`, `status`, `chart`, `export` in every format including Hyper, read-only `attach_database`, `detach_database`, `list_attached_databases`, `unwatch_directory`, `get_readme`, and the KV readers `kv_get`, `kv_list`, `kv_size`, `kv_list_stores` +- **Blocked:** `execute`, `load_data`, `load_file`, `load_files`, `load_iceberg`, `watch_directory`, `save_query`, `delete_query`, `set_table_metadata`, `copy_query`, `kv_set`, `kv_set_many`, `kv_delete`, `kv_pop`, and `kv_clear` — return `READ_ONLY_VIOLATION`. `attach_database` is also guarded when `writable: true` or `on_missing: "create"`; ordinary read-only attachment remains available. - **Resources, prompts, and resource subscriptions** work normally — read-only clients can still subscribe to `hyper://...` URIs and receive notifications when other (non-read-only) connections mutate state The `query` tool also enforces read-only at the SQL level — only `SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`VALUES` are accepted. @@ -837,7 +920,8 @@ Full reference: [Data Cloud SQL Reference](https://developer.salesforce.com/docs hyperdb-mcp [OPTIONS] [COMMAND] Commands: - daemon Run as a background daemon managing a shared hyperd process + daemon Run a foreground daemon managing a shared hyperd process + doctor Inspect identities/configuration without starting Hyper Options: --persistent-db Path to the persistent .hyper file. Defaults to the platform @@ -847,8 +931,8 @@ Options: the HYPERDB_PERSISTENT_DB env var. --ephemeral-only Skip the persistent attachment entirely. Disables save_query persistence (queries fall back to session storage). - --read-only Disable mutating tools (execute, load_data, load_file, - save_query, delete_query, watch_directory) + --read-only Guard all load/mutation tools and writable/create attachment; + read-only attachment, unwatch, and all export formats stay allowed --no-daemon Disable the shared daemon and spawn a private hyperd Deprecated: @@ -856,19 +940,21 @@ Deprecated: stderr warning, and will be removed in a future release. Daemon subcommand: - hyperdb-mcp daemon Start the daemon (usually auto-spawned) + hyperdb-mcp daemon Start foreground on the configured/base port exactly hyperdb-mcp daemon stop Gracefully stop the running daemon hyperdb-mcp daemon status Show running daemon info - hyperdb-mcp daemon --port Pin the health/lock port. When omitted, - scans upward from 7485 for a free port. + hyperdb-mcp daemon --port Bind this exact health/lock port; foreground + startup never performs the auto-spawn scan. hyperdb-mcp daemon --idle-timeout 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) + HYPERD_PATH Hyperd executable or containing directory; when absent or + non-UTF-8, walk upward for .hyperd/current/hyperd (no PATH lookup) HYPERDB_PERSISTENT_DB Override the default persistent-db path HYPERDB_STATE_DIR Override daemon state directory (default ~/.hyperdb/) - HYPERDB_DAEMON_PORT Pin daemon health/lock port (default: scan from 7485) + HYPERDB_DAEMON_PORT Pin auto-spawn discovery to one health/lock candidate; + foreground startup binds this configured/base port exactly HYPERDB_DAEMON_IDLE_TIMEOUT Opt into idle shutdown (seconds); default: stay resident ``` @@ -887,6 +973,7 @@ Errors include a machine-readable code and a suggestion: | `SQL_ERROR` | Invalid SQL | Fix the query | | `TABLE_NOT_FOUND` | Table doesn't exist | Use `describe` to list tables | | `READ_ONLY_VIOLATION` | Mutating op in read-only mode | Use `query_*` / `inspect_file`, or restart without `--read-only` | +| `RESOURCE_BUSY` | The reserved persistent attachment hit file contention (SQLSTATE 55006) | Run `hyperdb-mcp doctor`; compare client/daemon identities; close the possible owner (Hyper, Tableau, or another process), or copy/select another `.hyper` file; retry | | `CONNECTION_LOST` | `hyperd` crashed or wire protocol desynchronized | Retry — the server tears down the engine and reconnects on the next call | Server-returned errors include a machine-readable `code`, a `message`, and a @@ -895,6 +982,12 @@ an overflow names the workflow directly: "call `inspect_file`, then retry with a partial schema override", so the LLM does not need to infer the recovery steps from the SQLSTATE alone. +`RESOURCE_BUSY` is contextual: only contention while attaching the configured +persistent file gets this classification. The error preserves the effective +path, raw Hyper diagnostic, and SQLSTATE; unrelated `55006` SQL errors remain +`SQL_ERROR`. Doctor compares evidence but does not claim which possible owner +holds the file and never kills a process. + --- ## Troubleshooting @@ -903,7 +996,9 @@ steps from the SQLSTATE alone. **Server registered but tools not callable (Claude Code)** — Add `"mcp__HyperDB__*"` to the `permissions.allow` array in `~/.claude/settings.json`. -**hyperd not found** — Set `HYPERD_PATH` in the MCP server's `env` config, or place `hyperd` on your `PATH`. +**hyperd not found** — Set `HYPERD_PATH` in the MCP server's `env` config to +the executable or its containing directory, or install it under an ancestor's +`.hyperd/current/` directory. The runtime does not search the general `PATH`. --- diff --git a/hyperdb-mcp/SMOKE_TESTS.md b/hyperdb-mcp/SMOKE_TESTS.md index 57b8aa3..5ebbd91 100644 --- a/hyperdb-mcp/SMOKE_TESTS.md +++ b/hyperdb-mcp/SMOKE_TESTS.md @@ -25,15 +25,15 @@ tests deliberately don't. The MCP has two databases per session: -- the **ephemeral** primary (the default target; a fresh temp `.hyper` that - is deleted on server restart), and +- the **local** database (ephemeral and the default target; a fresh + temp `.hyper` that is deleted on server restart), and - the **persistent** database (`database: "persistent"` / `persist: true`) - which is the user's durable workspace and **may already hold real data**. + which is the user's durable database and **may already hold real data**. **Rules for smoke testing:** -1. **Default to the ephemeral store.** Omit `database` on every call unless - you are explicitly testing routing. Ephemeral writes cost nothing and +1. **Default to the local store.** Omit `database` on every call unless + you are explicitly testing routing. Local writes cost nothing and vanish on restart. 2. **Never create, drop, or overwrite a table without checking first — scoped to the database you're about to write to.** A real `products` @@ -41,7 +41,7 @@ The MCP has two databases per session: Before any `CREATE`/`DROP`, confirm the name is free *in that database*: run `describe table= database=persistent` (or a `SELECT COUNT(*) FROM ` via `query database=persistent`) when the - target is persistent — a bare `describe` inspects only the **ephemeral** + target is persistent — a bare `describe` inspects only the **local** primary and would miss a persistent collision, and `status` never lists table *names* (only aggregate counts), so neither alone protects you. Always use a `smoke_`-prefixed name for any scratch table you create. @@ -59,7 +59,10 @@ it started in. The final section is a verification checklist for that. ## Preconditions -- `hyperd` available (`HYPERD_PATH` set, or on `PATH`). +- `hyperd` available. `HYPERD_PATH` may name the executable or its containing + directory. When it is absent or non-UTF-8, the runtime will search upward from + the current directory for `.hyperd/current/hyperd`; it does not search the + general executable path. - The `hyperdb` MCP tools connected and responding. - Confirm the server is up and note its mode before you start: @@ -67,22 +70,50 @@ it started in. The final section is a verification checklist for that. status ``` -Expected: `{"hyperd_running": true, ..., "read_only": false, "engine": {"mode": "daemon"|"local", ...}}`. -Note `read_only` — if `true`, the four KV **mutators** (`kv_set`, -`kv_delete`, `kv_pop`, `kv_clear`) are expected to be **rejected** (see -§7); the four readers still work. +Expected full response: `{"hyperd_running": true, "engine_busy": false, +"default_database": "local", ..., "read_only": false, "engine": +{"mode": "daemon"|"local", ...}}`. Note `read_only` — if `true`, the five +KV **mutators** (`kv_set`, `kv_set_many`, `kv_delete`, `kv_pop`, `kv_clear`) +are expected to be **rejected** (see §7); the four readers still work. + +If `engine_busy: true`, status is deliberately partial. SQL-dependent counts +are omitted and `hyperd_running: false` is inconclusive; retry after the +in-progress operation completes rather than treating the degraded response as +a definitive outage. Throughout, `→` shows the expected JSON the tool returns. Store/key names below all begin with `smoke` so they're easy to spot and purge. --- +## Diagnostic preflight + +Before touching real data, run both native doctor presentations: + +```bash +hyperdb-mcp doctor +hyperdb-mcp doctor --json +``` + +Doctor is side-effect-free: it creates no directories, starts no daemon or +`hyperd`, and opens or creates no database. Compare its authoritative native +MCP/Rust API identity with optional launcher-reported npm identity and any +freshly verified daemon `STATUS`. Review local paths before sharing the report. + +If persistent warm-up or a persistent-routed call returns `RESOURCE_BUSY`, +confirm the message includes the effective `.hyper` path, raw diagnostic, and +SQLSTATE `55006`. Run doctor, compare identities, and close the possible owner +(another Hyper/Tableau process) or copy/select another persistent file before +retrying. Do not treat unrelated `55006` errors as lock contention. + +--- + ## 1. Server + KV surface present -The server should expose 8 `kv_*` tools and the `hyper://schema/kv` +The server should expose 9 `kv_*` tools and the `hyper://schema/kv` resource. -- `kv_*` tools: `kv_set`, `kv_get`, `kv_delete`, `kv_list`, +- `kv_*` tools: `kv_set`, `kv_set_many`, `kv_get`, `kv_delete`, `kv_list`, `kv_list_stores`, `kv_size`, `kv_pop`, `kv_clear`. - Reading `hyper://schema/kv` returns text mentioning `_hyperdb_kv_store` and a `LEFT JOIN` template. @@ -92,21 +123,32 @@ resource. ## 2. Create / read / overwrite (upsert) ``` -kv_set store=smoke key=greeting value="hello world" → {"stored": true, "store": "smoke", "key": "greeting"} -kv_get store=smoke key=greeting → {"found": true, "value": "hello world"} -kv_get store=smoke key=does_not_exist → {"found": false, "value": null} +kv_set store=smoke key=greeting value="hello world" → {"stored": true, "created": true, "value_bytes": 11, "store": "smoke", "key": "greeting", "resolved_database": "local"} +kv_get store=smoke key=greeting → {"found": true, "value": "hello world", "resolved_database": "local"} +kv_get store=smoke key=does_not_exist → {"found": false, "value": null, "resolved_database": "local"} ``` A miss is **not** an error — `found: false` with a `null` value. +Batch writes are atomic and validate every key before writing: + +``` +kv_set_many store=smoke_batch entries=[{"key":"batch_a","value":"A"},{"key":"batch_b","value":"B"}] + → {"stored": 2, "created": 2, "overwritten": 0, "total_bytes": 2, "resolved_database": "local"} +kv_list store=smoke_batch + → {"store": "smoke_batch", "count": 2, "keys": ["batch_a","batch_b"], "resolved_database": "local"} +kv_clear store=smoke_batch + → {"store": "smoke_batch", "removed": 2, "resolved_database": "local"} +``` + **Overwrite must not create a duplicate row** (the backing table is indexless; `kv_set` is an app-side upsert): ``` -kv_size store=smoke → {"store": "smoke", "size": 1} -kv_set store=smoke key=greeting value="HELLO AGAIN" → {"stored": true, ...} -kv_size store=smoke → {"store": "smoke", "size": 1} # still 1, not 2 -kv_get store=smoke key=greeting → {"found": true, "value": "HELLO AGAIN"} +kv_size store=smoke → {"store": "smoke", "size": 1, "bytes": 11, "resolved_database": "local"} +kv_set store=smoke key=greeting value="HELLO AGAIN" → {"stored": true, "resolved_database": "local", ...} +kv_size store=smoke → {"store": "smoke", "size": 1, "bytes": 11, "resolved_database": "local"} # still 1, not 2 +kv_get store=smoke key=greeting → {"found": true, "value": "HELLO AGAIN", "resolved_database": "local"} ``` --- @@ -120,9 +162,9 @@ kv_set store=smoke key=alpha value=1 kv_set store=smoke key=bravo value=2 kv_set store=smoke key=charlie value=3 -kv_list store=smoke → {"store": "smoke", "count": 4, "keys": ["alpha","bravo","charlie","greeting"]} # sorted ascending -kv_size store=smoke → {"store": "smoke", "size": 4} -kv_list_stores → {"count": 1, "stores": ["smoke"]} +kv_list store=smoke → {"store": "smoke", "count": 4, "keys": ["alpha","bravo","charlie","greeting"], "resolved_database": "local"} # sorted ascending +kv_size store=smoke → {"store": "smoke", "size": 4, "bytes": 14, "resolved_database": "local"} +kv_list_stores → {"count": 1, "stores": ["smoke"], "resolved_database": "local"} ``` `kv_list` keys are always sorted ascending. `kv_list_stores` reflects only @@ -135,13 +177,13 @@ emptied store disappears from the list; see §5). ``` kv_set store=smoke key=config value='{"retries": 3, "nested": {"flag": true}}' -kv_get store=smoke key=config → {"found": true, "value": "{\"retries\": 3, \"nested\": {\"flag\": true}}"} # byte-for-byte +kv_get store=smoke key=config → {"found": true, "value": "{\"retries\": 3, \"nested\": {\"flag\": true}}", "resolved_database": "local"} # byte-for-byte kv_set store=smoke key=empty_val value="" -kv_get store=smoke key=empty_val → {"found": true, "value": ""} # empty string, NOT a miss +kv_get store=smoke key=empty_val → {"found": true, "value": "", "resolved_database": "local"} # empty string, NOT a miss kv_set store=smoke key=big_blob value="" -kv_get store=smoke key=big_blob → {"found": true, "value": ""} +kv_get store=smoke key=big_blob → {"found": true, "value": "", "resolved_database": "local"} ``` The empty-string case is the important one: `{"found": true, "value": ""}` @@ -154,9 +196,9 @@ must stay distinct from a miss `{"found": false, "value": null}`. **Delete is idempotent and reports whether the key existed:** ``` -kv_delete store=smoke key=greeting → {"deleted": true, ...} # existed -kv_delete store=smoke key=greeting → {"deleted": false, ...} # already gone — no error -kv_delete store=smoke key=never_existed → {"deleted": false, ...} +kv_delete store=smoke key=greeting → {"deleted": true, "resolved_database": "local", ...} # existed +kv_delete store=smoke key=greeting → {"deleted": false, "resolved_database": "local", ...} # already gone — no error +kv_delete store=smoke key=never_existed → {"deleted": false, "resolved_database": "local", ...} ``` **`kv_pop` destructively removes the lowest-keyed entry** (a work-queue @@ -164,25 +206,28 @@ drain in ascending key order): ``` # with keys [alpha, bravo, charlie, config, empty_val, big_blob] present -kv_pop store=smoke → {"found": true, "key": "alpha", "value": "1"} -kv_pop store=smoke → {"found": true, "key": "big_blob", "value": "..."} # 'b' < 'c' -kv_pop store=smoke → {"found": true, "key": "bravo", "value": "2"} +kv_pop store=smoke → {"found": true, "key": "alpha", "value": "1", "resolved_database": "local"} +kv_pop store=smoke → {"found": true, "key": "big_blob", "value": "...", "resolved_database": "local"} # 'b' < 'c' +kv_pop store=smoke → {"found": true, "key": "bravo", "value": "2", "resolved_database": "local"} ``` **`kv_clear` empties the store and returns the count removed:** ``` -kv_size store=smoke → {"store": "smoke", "size": N} -kv_clear store=smoke → {"store": "smoke", "removed": N} -kv_size store=smoke → {"store": "smoke", "size": 0} +kv_size store=smoke → {"store": "smoke", "size": N, "bytes": B, "resolved_database": "local"} +kv_clear store=smoke → {"store": "smoke", "removed": N, "resolved_database": "local"} +kv_size store=smoke → {"store": "smoke", "size": 0, "bytes": 0, "resolved_database": "local"} ``` +Here `N` is the key count immediately before the clear, and `B` is the sum +of the remaining values' UTF-8 byte lengths at that point. + **Empty-store edge cases:** ``` -kv_pop store=smoke → {"found": false} # nothing to pop -kv_clear store=smoke → {"store": "smoke", "removed": 0} # idempotent -kv_list_stores → {"count": 0, "stores": []} # emptied store drops out +kv_pop store=smoke → {"found": false, "resolved_database": "local"} # nothing to pop +kv_clear store=smoke → {"store": "smoke", "removed": 0, "resolved_database": "local"} # idempotent +kv_list_stores → {"count": 0, "stores": [], "resolved_database": "local"} # emptied store drops out ``` --- @@ -217,18 +262,25 @@ assume the shared daemon is read-only. ``` # readers work: -kv_get store=smoke key=k → {"found": ...} -kv_list store=smoke → {...} -kv_size store=smoke → {...} -kv_list_stores → {...} +kv_get store=smoke key=k → {"found": false, "value": null, "resolved_database": "local"} +kv_list store=smoke → {"store": "smoke", "count": 0, "keys": [], "resolved_database": "local"} +kv_size store=smoke → {"store": "smoke", "size": 0, "bytes": 0, "resolved_database": "local"} +kv_list_stores → {"count": 0, "stores": [], "resolved_database": "local"} # mutators are blocked: kv_set store=smoke key=k value=v → error READ_ONLY_VIOLATION ("... not permitted in read-only mode") +kv_set_many store=smoke entries=[{"key":"k","value":"v"}] → error READ_ONLY_VIOLATION kv_delete store=smoke key=k → error READ_ONLY_VIOLATION kv_pop store=smoke → error READ_ONLY_VIOLATION kv_clear store=smoke → error READ_ONLY_VIOLATION ``` +The same mode guards `execute`, `load_data`, `load_file`, `load_files`, +`load_iceberg`, `watch_directory`, `save_query`, `delete_query`, +`set_table_metadata`, `copy_query`, and writable/create `attach_database`. +Read-only attachment, `unwatch_directory`, and export in every format +(including Hyper) remain available. + --- ## 8. Database routing + isolation @@ -239,20 +291,28 @@ Each database keeps its own isolated set of stores. The same store name in two databases holds independent values. `persist: true` and `database: "persistent"` target the same place. +Every successful database-routed call also returns canonical +`resolved_database`: `"local"`, `"persistent"`, or a lowercased attached +alias. Verify it on every response below. When both selectors are supplied, +an explicit `database` wins over `persist: true` (for example, +`database=local persist=true` resolves to `local`). Mixed-case +`database=PeRsIsTeNt` resolves to `persistent`; mixed-case attached aliases +resolve to the registry's lowercase alias. + ``` -kv_set store=smoke_routing key=where value="ephemeral" # → ephemeral (default) +kv_set store=smoke_routing key=where value="local" # → local (default) kv_set store=smoke_routing key=where value="persistent" database=persistent # → persistent kv_set store=smoke_routing key=where2 value="via-flag" persist=true # → persistent (same DB) -kv_get store=smoke_routing key=where → {"found": true, "value": "ephemeral"} -kv_get store=smoke_routing key=where database=persistent → {"found": true, "value": "persistent"} -kv_get store=smoke_routing key=where2 persist=true → {"found": true, "value": "via-flag"} +kv_get store=smoke_routing key=where → {"found": true, "value": "local", "resolved_database": "local"} +kv_get store=smoke_routing key=where database=persistent → {"found": true, "value": "persistent", "resolved_database": "persistent"} +kv_get store=smoke_routing key=where2 persist=true → {"found": true, "value": "via-flag", "resolved_database": "persistent"} -kv_list store=smoke_routing → {"store": "smoke_routing", "count": 1, "keys": ["where"]} # ephemeral -kv_list store=smoke_routing database=persistent → {"store": "smoke_routing", "count": 2, "keys": ["where","where2"]} # persistent +kv_list store=smoke_routing → {"store": "smoke_routing", "count": 1, "keys": ["where"], "resolved_database": "local"} +kv_list store=smoke_routing database=persistent → {"store": "smoke_routing", "count": 2, "keys": ["where","where2"], "resolved_database": "persistent"} ``` -The ephemeral and persistent `where` values differ → isolation holds. +The local and persistent `where` values differ → isolation holds. `persist=true` and `database=persistent` landed in the same store → both keys present in persistent. @@ -267,7 +327,7 @@ panic). The backing table `_hyperdb_kv_store(store_name, key, value)` is hidden from `describe`/`status` but queryable directly. This is the point of the KV store: annotate analytical rows with scratchpad metadata via a plain SQL -join. **Run this in the ephemeral DB** (create a `smoke_`-prefixed table): +join. **Run this in the local DB** (create a `smoke_`-prefixed table): ``` kv_set store=product_notes key=P1 value="flagship - review pricing Q3" @@ -306,7 +366,7 @@ The backing table has **no index**; uniqueness on overwrite and single-serve on pop rely on the engine serializing writes within one server process. To stress this against a live server, fan out concurrent calls (e.g. from a script or a fleet of parallel tool calls) to a scratch store -named `smoke_concurrency` (keep it ephemeral — omit `database` — and purge +named `smoke_concurrency` (keep it local — omit `database` — and purge it in §12): - **N concurrent `kv_set` to the same key** → the store ends with exactly @@ -337,7 +397,7 @@ kv_clear store=product_notes execute ["DROP TABLE IF EXISTS smoke_products"] # verify nothing of ours remains: -kv_list_stores → {"count": 0, "stores": []} # (or only pre-existing non-smoke stores) +kv_list_stores → {"count": 0, "stores": [], "resolved_database": "local"} # (or only pre-existing non-smoke stores) kv_list_stores database=persistent → no smoke_* / product_notes stores describe database=persistent → only the real, pre-existing tables (no smoke_*) ``` diff --git a/hyperdb-mcp/examples/demo.rs b/hyperdb-mcp/examples/demo.rs index a98cf09..2f21725 100644 --- a/hyperdb-mcp/examples/demo.rs +++ b/hyperdb-mcp/examples/demo.rs @@ -228,7 +228,7 @@ fn main() -> Result<(), Box> { println!(" {}", csv_path.display()); // ── Step 1: spin up engine ───────────────────────────────────────── - section("Step 1 · Launch the engine (ephemeral workspace)"); + section("Step 1 · Launch the engine (local database)"); let engine = Engine::new(None)?; println!(" Ephemeral DB: {}", engine.ephemeral_path().display()); println!(" Log dir: {}", engine.log_dir().display()); @@ -263,7 +263,7 @@ fn main() -> Result<(), Box> { } // ── Step 3: describe ─────────────────────────────────────────────── - section("Step 3 · Describe — what's in the workspace?"); + section("Step 3 · Describe — what's in the local database?"); let tables = engine.describe_tables()?; print_rows_as_table(&tables); @@ -357,10 +357,8 @@ fn main() -> Result<(), Box> { format: ChartFormat::Png, width: 900, height: 400, - // `day` is a DATE column — plot it as categorical so the - // axis ticks render as ISO strings instead of failing - // numeric parse. - x_as_category: Some(true), + // `day` is a DATE column, so line charts automatically use + // a proportional temporal axis with ISO date tick labels. ..ChartOptions::default() }, )?; @@ -416,8 +414,8 @@ fn main() -> Result<(), Box> { println!(" · {}", line_path.display()); println!(" · {}", scatter_path.display()); println!(); - println!(" The engine's temp workspace will be removed when this"); - println!(" process exits (workspace is ephemeral, `is_persistent = false`)."); + println!(" The engine's local temp database will be removed when this"); + println!(" process exits (`is_persistent = false`)."); println!(); Ok(()) diff --git a/hyperdb-mcp/npm/bin.js b/hyperdb-mcp/npm/bin.js index 4a574de..2fc3ce2 100644 --- a/hyperdb-mcp/npm/bin.js +++ b/hyperdb-mcp/npm/bin.js @@ -2,7 +2,7 @@ // Copyright (c) 2026, Salesforce, Inc. All rights reserved. // SPDX-License-Identifier: Apache-2.0 OR MIT -const { execFileSync } = require('child_process') +const { spawnSync } = require('child_process') const { join, dirname } = require('path') const { existsSync } = require('fs') @@ -39,20 +39,39 @@ function findBinary() { // Try resolving from the installed platform package try { - const pkgDir = dirname(require.resolve(`${pkg}/package.json`)) + const packagePath = require.resolve(`${pkg}/package.json`) + const pkgDir = dirname(packagePath) const bin = join(pkgDir, getBinaryName()) - if (existsSync(bin)) return { bin, dir: pkgDir } + if (existsSync(bin)) return { bin, dir: pkgDir, pkg, packagePath } } catch (_) {} // Fallback: binary in platform subdirectory (local dev / assemble-npm.sh) const platformDir = pkg.replace('hyperdb-mcp-', '') const subdir = join(__dirname, platformDir) const subdirBin = join(subdir, getBinaryName()) - if (existsSync(subdirBin)) return { bin: subdirBin, dir: subdir } + const sourcePackagePath = join(subdir, 'package.json') + if (existsSync(subdirBin)) { + return { + bin: subdirBin, + dir: subdir, + pkg, + packagePath: sourcePackagePath, + } + } // Fallback: binary in same directory const localBin = join(__dirname, getBinaryName()) - if (existsSync(localBin)) return { bin: localBin, dir: __dirname } + if (existsSync(localBin)) { + return { + bin: localBin, + dir: __dirname, + pkg, + // The manifest sits next to the binary in this branch — not in the + // platform subdirectory `sourcePackagePath` points at (that path does + // not exist here), so recompute it relative to __dirname. + packagePath: join(__dirname, 'package.json'), + } + } throw new Error( `Could not find hyperdb-mcp binary for ${platform}-${arch}. ` + @@ -60,24 +79,91 @@ function findBinary() { ) } -const { bin, dir } = findBinary() +function packageIdentity(packagePath, fallbackName) { + let manifest = {} + try { + manifest = require(packagePath) + } catch (_) {} + + return { + name: typeof manifest.name === 'string' ? manifest.name : fallbackName, + version: typeof manifest.version === 'string' ? manifest.version : null, + package_path: packagePath, + } +} + +function buildLauncherInfo({ wrapper, platform, executable_path }) { + return { + wrapper: { + name: wrapper.name, + version: wrapper.version ?? null, + package_path: wrapper.package_path, + }, + platform: { + name: platform.name, + version: platform.version ?? null, + package_path: platform.package_path, + }, + executable_path, + } +} + +function prepareLauncherEnvironment({ + inherited_env, + configured_hyperd, + bundled_hyperd, + launcher_info, +}) { + const env = { ...inherited_env } + if (!configured_hyperd && bundled_hyperd !== undefined) { + env.HYPERD_PATH = bundled_hyperd + } + env.HYPERDB_MCP_LAUNCHER_INFO = JSON.stringify(launcher_info) + return env +} + +function launch({ executable_path, args, env, spawnSync }) { + const result = spawnSync(executable_path, args, { + stdio: 'inherit', + env, + }) -// Point hyperdb-mcp at the bundled hyperd if not already set -if (!process.env.HYPERD_PATH) { - const hyperd = join(dir, getHyperdName()) - if (existsSync(hyperd)) { - process.env.HYPERD_PATH = hyperd + if (result.error) { + throw result.error } + + return result.status ?? 1 } -// Spawn the MCP server, inheriting stdio for MCP protocol communication -const result = require('child_process').spawnSync(bin, process.argv.slice(2), { - stdio: 'inherit', - env: process.env, -}) +function main() { + const { bin, dir, pkg, packagePath } = findBinary() + const configuredHyperd = process.env.HYPERD_PATH + + // Point hyperdb-mcp at the bundled hyperd if not already set + const bundledHyperd = join(dir, getHyperdName()) + const launcherInfo = buildLauncherInfo({ + wrapper: packageIdentity(join(__dirname, 'package.json'), 'hyperdb-mcp'), + platform: packageIdentity(packagePath, pkg), + executable_path: bin, + }) + const env = prepareLauncherEnvironment({ + inherited_env: process.env, + configured_hyperd: configuredHyperd, + bundled_hyperd: existsSync(bundledHyperd) ? bundledHyperd : undefined, + launcher_info: launcherInfo, + }) + + // Spawn the MCP server, inheriting stdio for MCP protocol communication + return launch({ + executable_path: bin, + args: process.argv.slice(2), + env, + spawnSync, + }) +} -if (result.error) { - throw result.error +if (require.main === module) { + process.exit(main()) } -process.exit(result.status ?? 1) +module.exports = { buildLauncherInfo, prepareLauncherEnvironment, launch } diff --git a/hyperdb-mcp/npm/bin.test.js b/hyperdb-mcp/npm/bin.test.js new file mode 100644 index 0000000..1e367ac --- /dev/null +++ b/hyperdb-mcp/npm/bin.test.js @@ -0,0 +1,275 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const { spawnSync } = require('node:child_process') +const { resolve } = require('node:path') + +const launcherModule = resolve(__dirname, 'bin.js') + +function runLauncherModule(scriptBody) { + return spawnSync( + process.execPath, + ['-e', `const launcherModule = ${JSON.stringify(launcherModule)};\n${scriptBody}`], + { encoding: 'utf8' } + ) +} + +function assertChildSucceeded(result) { + assert.equal( + result.status, + 0, + `child exited ${result.status}; signal=${result.signal}; stderr=${result.stderr}` + ) +} + +test('launcher_module_is_import_safe', () => { + const result = runLauncherModule(` + const launcher = require(launcherModule) + process.stdout.write(JSON.stringify({ + buildLauncherInfo: typeof launcher.buildLauncherInfo, + launch: typeof launcher.launch, + })) + `) + + assertChildSucceeded(result) + assert.deepEqual(JSON.parse(result.stdout), { + buildLauncherInfo: 'function', + launch: 'function', + }) +}) + +test('launcher_info_contains_only_allowlisted_fields', () => { + const result = runLauncherModule(` + const { buildLauncherInfo } = require(launcherModule) + const secret = 'NODE_SECRET_SENTINEL_00e727' + const withVersions = buildLauncherInfo({ + wrapper: { + name: 'hyperdb-mcp', + version: '1.2.3', + package_path: '/wrapper/package.json', + token: secret, + }, + platform: { + name: 'hyperdb-mcp-linux-x64-gnu', + version: '1.2.3', + package_path: '/platform/package.json', + credentials: { secret }, + }, + executable_path: '/platform/hyperdb-mcp', + environment: { secret }, + }) + const sourceManifests = buildLauncherInfo({ + wrapper: { name: 'hyperdb-mcp', package_path: '/source/package.json' }, + platform: { + name: 'hyperdb-mcp-linux-x64-gnu', + package_path: '/source/linux-x64-gnu/package.json', + }, + executable_path: '/source/linux-x64-gnu/hyperdb-mcp', + }) + process.stdout.write(JSON.stringify({ withVersions, sourceManifests })) + `) + + assertChildSucceeded(result) + assert.deepEqual(JSON.parse(result.stdout), { + withVersions: { + wrapper: { + name: 'hyperdb-mcp', + version: '1.2.3', + package_path: '/wrapper/package.json', + }, + platform: { + name: 'hyperdb-mcp-linux-x64-gnu', + version: '1.2.3', + package_path: '/platform/package.json', + }, + executable_path: '/platform/hyperdb-mcp', + }, + sourceManifests: { + wrapper: { + name: 'hyperdb-mcp', + version: null, + package_path: '/source/package.json', + }, + platform: { + name: 'hyperdb-mcp-linux-x64-gnu', + version: null, + package_path: '/source/linux-x64-gnu/package.json', + }, + executable_path: '/source/linux-x64-gnu/hyperdb-mcp', + }, + }) + assert.doesNotMatch(result.stdout, /NODE_SECRET_SENTINEL_00e727/) +}) + +test('launcher_preserves_case_insensitive_configured_hyperd', () => { + const result = runLauncherModule(` + const { prepareLauncherEnvironment } = require(launcherModule) + const launcher_info = { + wrapper: { + name: 'hyperdb-mcp', + version: '1.2.3', + package_path: '/wrapper/package.json', + }, + platform: { + name: 'hyperdb-mcp-win32-x64-msvc', + version: '1.2.3', + package_path: '/platform/package.json', + }, + executable_path: '/platform/hyperdb-mcp.exe', + } + const inheritedConfigured = { + Path: 'C:\\\\Windows\\\\System32', + Hyperd_Path: '/configured/hyperd', + KEEP_ME: 'configured', + } + const inheritedBundled = { + Path: 'C:\\\\Windows\\\\System32', + KEEP_ME: 'bundled', + } + const inheritedEmpty = { + Path: 'C:\\\\Windows\\\\System32', + HYPERD_PATH: '', + KEEP_ME: 'empty', + } + const configured = prepareLauncherEnvironment({ + inherited_env: inheritedConfigured, + configured_hyperd: '/configured/hyperd', + bundled_hyperd: '/bundled/hyperd', + launcher_info, + }) + const bundled = prepareLauncherEnvironment({ + inherited_env: inheritedBundled, + configured_hyperd: undefined, + bundled_hyperd: '/bundled/hyperd', + launcher_info, + }) + const emptyConfigured = prepareLauncherEnvironment({ + inherited_env: inheritedEmpty, + configured_hyperd: '', + bundled_hyperd: '/bundled/hyperd', + launcher_info, + }) + process.stdout.write(JSON.stringify({ + configured, + bundled, + emptyConfigured, + inheritedConfigured, + inheritedBundled, + inheritedEmpty, + })) + `) + + assertChildSucceeded(result) + const launcherInfoJson = '{"wrapper":{"name":"hyperdb-mcp","version":"1.2.3","package_path":"/wrapper/package.json"},"platform":{"name":"hyperdb-mcp-win32-x64-msvc","version":"1.2.3","package_path":"/platform/package.json"},"executable_path":"/platform/hyperdb-mcp.exe"}' + assert.deepEqual(JSON.parse(result.stdout), { + configured: { + Path: 'C:\\Windows\\System32', + Hyperd_Path: '/configured/hyperd', + KEEP_ME: 'configured', + HYPERDB_MCP_LAUNCHER_INFO: launcherInfoJson, + }, + bundled: { + Path: 'C:\\Windows\\System32', + KEEP_ME: 'bundled', + HYPERD_PATH: '/bundled/hyperd', + HYPERDB_MCP_LAUNCHER_INFO: launcherInfoJson, + }, + emptyConfigured: { + Path: 'C:\\Windows\\System32', + HYPERD_PATH: '/bundled/hyperd', + KEEP_ME: 'empty', + HYPERDB_MCP_LAUNCHER_INFO: launcherInfoJson, + }, + inheritedConfigured: { + Path: 'C:\\Windows\\System32', + Hyperd_Path: '/configured/hyperd', + KEEP_ME: 'configured', + }, + inheritedBundled: { + Path: 'C:\\Windows\\System32', + KEEP_ME: 'bundled', + }, + inheritedEmpty: { + Path: 'C:\\Windows\\System32', + HYPERD_PATH: '', + KEEP_ME: 'empty', + }, + }) + assert.equal( + Object.hasOwn(JSON.parse(result.stdout).configured, 'HYPERD_PATH'), + false, + 'a case-insensitive configured value must not gain a competing uppercase key' + ) +}) + +test('launcher_preserves_spawn_error_semantics', () => { + const result = runLauncherModule(` + const { launch } = require(launcherModule) + const expected = new Error('spawn failed') + let sameError = false + try { + launch({ + executable_path: '/platform/hyperdb-mcp', + args: [], + env: {}, + spawnSync: () => ({ error: expected }), + }) + } catch (error) { + sameError = error === expected + } + process.stdout.write(JSON.stringify({ sameError })) + `) + + assertChildSucceeded(result) + assert.deepEqual(JSON.parse(result.stdout), { sameError: true }) +}) + +test('launcher_preserves_numeric_exit_status', () => { + const result = runLauncherModule(` + const { launch } = require(launcherModule) + let observed + const status = launch({ + executable_path: '/platform/hyperdb-mcp', + args: ['--read-only'], + env: { HYPERD_PATH: '/platform/hyperd' }, + spawnSync: (file, args, options) => { + observed = { file, args, options } + return { status: 37, signal: null } + }, + }) + process.stdout.write(JSON.stringify({ status, observed })) + `) + + assertChildSucceeded(result) + assert.deepEqual(JSON.parse(result.stdout), { + status: 37, + observed: { + file: '/platform/hyperdb-mcp', + args: ['--read-only'], + options: { + stdio: 'inherit', + env: { HYPERD_PATH: '/platform/hyperd' }, + }, + }, + }) +}) + +test('launcher_preserves_signal_termination', () => { + const result = runLauncherModule(` + const { launch } = require(launcherModule) + const status = launch({ + executable_path: '/platform/hyperdb-mcp', + args: [], + env: {}, + spawnSync: () => ({ status: null, signal: 'SIGTERM' }), + }) + process.stdout.write(JSON.stringify({ status })) + `) + + assertChildSucceeded(result) + assert.deepEqual(JSON.parse(result.stdout), { status: 1 }) +}) diff --git a/hyperdb-mcp/src/attach.rs b/hyperdb-mcp/src/attach.rs index 915ed4f..1a53976 100644 --- a/hyperdb-mcp/src/attach.rs +++ b/hyperdb-mcp/src/attach.rs @@ -331,7 +331,13 @@ impl AttachRegistry { } }; - engine.execute_command(&sql)?; + // Route the ATTACH through the attach-context error mapper so a + // lock conflict (another process already owns the file) surfaces as + // `RESOURCE_BUSY` with recovery guidance, not a generic `SqlError`. + let target_path = match &req.source { + AttachSource::LocalFile { path } => path.clone(), + }; + engine.execute_attach_command(&sql, &target_path)?; // Hyper's default `schema_search_path = "$single"` stops // resolving unqualified names the moment the connection has diff --git a/hyperdb-mcp/src/chart.rs b/hyperdb-mcp/src/chart.rs index 0f1772d..7e2ab11 100644 --- a/hyperdb-mcp/src/chart.rs +++ b/hyperdb-mcp/src/chart.rs @@ -37,8 +37,11 @@ reason = "chart rendering: rows/columns displayed to user; any values approaching 2^53 would saturate to Infinity in the chart anyway" )] +use crate::engine::ChartMeasureValue; use crate::error::{ErrorCode, McpError}; use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use plotters::coord::ranged1d::ValueFormatter; +use plotters::coord::types::RangedCoordf64; use plotters::prelude::*; use plotters::style::colors; use serde_json::Value; @@ -52,13 +55,28 @@ use std::collections::BTreeMap; /// human-readable tick labels (the `group_series` function maps /// category strings through a `BTreeMap` to assign /// stable, deterministic x positions). +#[cfg(test)] type SeriesPoints = Vec<(f64, f64, String)>; /// Series name → its points. Uses `BTreeMap` (not `HashMap`) so /// multi-series charts render in deterministic order, which makes /// the resulting image bytes reproducible across runs. +#[cfg(test)] type SeriesMap = BTreeMap; +/// Renderer point retaining both numeric coordinates and the caller-visible +/// scalar text. The latter is required for bar value labels: formatting the +/// converted `f64` would lose the exact representation returned by SQL. +#[derive(Debug, Clone)] +struct ChartPoint { + x: f64, + y: f64, + x_label: String, + y_label: String, +} + +type ChartSeriesMap = BTreeMap>; + /// Supported chart types. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChartType { @@ -399,8 +417,8 @@ pub struct ChartOptions { /// x positions, original strings as tick labels). Useful when you /// want even spacing on temporal data — e.g. one bar per business /// day with no visual gap for weekends. - /// - `Some(false)`: force numeric x. Errors for non-numeric inputs - /// on `Line` / `Scatter`. Rarely useful on `Bar`. + /// - `Some(false)`: force numeric x for `Line` / `Scatter`. Bar charts + /// remain categorical regardless of this setting. /// /// When categorical mode is active the rendered x axis uses the /// original string representation of each distinct x value as its @@ -413,7 +431,9 @@ pub struct ChartOptions { /// side-by-side comparisons or animation where a consistent scale /// matters. Ignored for bar charts (which use categorical positions). pub x_range: Option<[f64; 2]>, - /// Fix the y-axis range as `[min, max]`. Same semantics as `x_range`. + /// Fix the data-role y measure range as `[min, max]`. Unlike `x_range`, + /// this applies to bar charts; horizontal bars render it on the physical + /// x axis. Log ranges must be positive and contain every plotted value. pub y_range: Option<[f64; 2]>, /// Map series names to hex colors (`"#rrggbb"`). Entries that match a /// series name override the default palette; unmatched series still @@ -460,6 +480,124 @@ pub struct ChartResult { pub rows_plotted: usize, } +/// Physical layout for bar marks. This remains crate-private so the public +/// Rust renderer keeps its legacy [`ChartOptions`] surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BarOrientation { + Vertical, + Horizontal, +} + +/// Scale applied to the data-role y measure. Horizontal bars still use this +/// as their physical x scale. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MeasureScale { + Linear, + Log, +} + +/// MCP-only presentation controls for the private extended renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ChartPresentation { + bar_orientation: BarOrientation, + label_values: bool, + show_legend: bool, + y_scale: MeasureScale, +} + +impl Default for ChartPresentation { + fn default() -> Self { + Self { + bar_orientation: BarOrientation::Vertical, + label_values: false, + show_legend: true, + y_scale: MeasureScale::Linear, + } + } +} + +impl ChartPresentation { + /// Parse MCP string controls without placing serde enums at the transport + /// boundary. Unknown values therefore become the same structured + /// `INVALID_ARGUMENT` tool errors as invalid cross-chart combinations. + pub(crate) fn from_mcp( + chart_type: ChartType, + bar_orientation: Option<&str>, + label_values: Option, + show_legend: Option, + y_scale: Option<&str>, + ) -> Result { + if bar_orientation.is_some() && chart_type != ChartType::Bar { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "bar_orientation is only valid for bar charts", + )); + } + if label_values == Some(true) && chart_type != ChartType::Bar { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "label_values=true is only valid for bar charts", + )); + } + + let bar_orientation = match bar_orientation { + None => BarOrientation::Vertical, + Some(value) if value.eq_ignore_ascii_case("vertical") => BarOrientation::Vertical, + Some(value) if value.eq_ignore_ascii_case("horizontal") => BarOrientation::Horizontal, + Some(value) => { + return Err(McpError::new( + ErrorCode::InvalidArgument, + format!( + "Unknown bar_orientation '{value}'. Expected 'vertical' or 'horizontal'" + ), + )); + } + }; + let y_scale = match y_scale { + None => MeasureScale::Linear, + Some(value) if value.eq_ignore_ascii_case("linear") => MeasureScale::Linear, + Some(value) if value.eq_ignore_ascii_case("log") => MeasureScale::Log, + Some(value) => { + return Err(McpError::new( + ErrorCode::InvalidArgument, + format!("Unknown y_scale '{value}'. Expected 'linear' or 'log'"), + )); + } + }; + + let presentation = Self { + bar_orientation, + label_values: label_values.unwrap_or(false), + show_legend: show_legend.unwrap_or(true), + y_scale, + }; + presentation.validate(chart_type)?; + Ok(presentation) + } + + fn validate(self, chart_type: ChartType) -> Result<(), McpError> { + if self.label_values && chart_type != ChartType::Bar { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "label_values=true is only valid for bar charts", + )); + } + if self.bar_orientation == BarOrientation::Horizontal && chart_type != ChartType::Bar { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "horizontal bar_orientation is only valid for bar charts", + )); + } + if self.y_scale == MeasureScale::Log && chart_type == ChartType::Histogram { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "y_scale=log is not supported for histograms", + )); + } + Ok(()) + } +} + /// Render a chart from a list of JSON row objects. /// /// `rows` is expected to be the output of `execute_query_to_json`: each entry @@ -476,8 +614,40 @@ pub struct ChartResult { /// - Returns [`ErrorCode::InternalError`] wrapping failures from the /// underlying `plotters` backend during rendering or PNG/SVG encoding. /// - Returns [`ErrorCode::InvalidArgument`] if the result set exceeds -/// 50,000 rows. +/// 50,000 rows or an explicit range is non-finite, non-increasing, or lacks +/// a finite representable span in the selected coordinate system. pub fn render_chart(rows: &[Value], opts: &ChartOptions) -> Result { + render_chart_with_presentation(rows, opts, ChartPresentation::default()) +} + +/// Extended renderer used by the MCP chart tool while the public Rust API +/// remains source-compatible. +pub(crate) fn render_chart_with_presentation( + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, +) -> Result { + render_chart_impl(rows, opts, presentation, None) +} + +/// Extended MCP renderer that consumes row-aligned typed measure metadata. +/// The public JSON renderer delegates without this sidecar and retains its +/// established source and behavior contract. +pub(crate) fn render_chart_with_measure_metadata( + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, + measures: &[ChartMeasureValue], +) -> Result { + render_chart_impl(rows, opts, presentation, Some(measures)) +} + +fn render_chart_impl( + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, +) -> Result { const MAX_CHART_ROWS: usize = 50_000; if rows.is_empty() { return Err(McpError::new( @@ -498,14 +668,51 @@ pub fn render_chart(rows: &[Value], opts: &ChartOptions) -> Result render_png(rows, opts), - ChartFormat::Svg => render_svg(rows, opts), + ChartFormat::Png => render_png(rows, opts, presentation, measures), + ChartFormat::Svg => render_svg(rows, opts, presentation, measures), + } +} + +fn validate_explicit_range(name: &str, range: Option<[f64; 2]>) -> Result<(), McpError> { + let Some([lo, hi]) = range else { + return Ok(()); + }; + validate_effective_linear_range(name, (lo, hi)).map(|_| ()) +} + +fn validate_effective_linear_range( + name: &str, + (lo, hi): (f64, f64), +) -> Result<(f64, f64), McpError> { + if !lo.is_finite() || !hi.is_finite() || lo >= hi || !(hi - lo).is_finite() { + return Err(McpError::new( + ErrorCode::InvalidArgument, + format!( + "{name} must contain two finite values in strictly increasing order with a finite span" + ), + )); } + Ok((lo, hi)) } -fn render_png(rows: &[Value], opts: &ChartOptions) -> Result { +fn render_png( + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, +) -> Result { let tmp = tempfile::Builder::new() .suffix(".png") .tempfile() @@ -518,7 +725,7 @@ fn render_png(rows: &[Value], opts: &ChartOptions) -> Result Result Result { +fn render_svg( + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, +) -> Result { let mut svg_string = String::new(); let rows_plotted = { let backend = SVGBackend::with_string(&mut svg_string, (opts.width, opts.height)); - draw_on_backend(backend, rows, opts)? + draw_on_backend(backend, rows, opts, presentation, measures)? }; Ok(ChartResult { bytes: svg_string.into_bytes(), @@ -552,6 +764,8 @@ fn draw_on_backend( backend: DB, rows: &[Value], opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, ) -> Result where ::ErrorType: 'static, @@ -560,10 +774,10 @@ where root.fill(&WHITE).map_err(draw_err)?; match opts.chart_type { - ChartType::Bar => draw_bar(&root, rows, opts), - ChartType::Line => draw_line(&root, rows, opts), - ChartType::Scatter => draw_scatter(&root, rows, opts), - ChartType::Histogram => draw_histogram(&root, rows, opts), + ChartType::Bar => draw_bar(&root, rows, opts, presentation, measures), + ChartType::Line => draw_line(&root, rows, opts, presentation, measures), + ChartType::Scatter => draw_scatter(&root, rows, opts, presentation, measures), + ChartType::Histogram => draw_histogram(&root, rows, opts, measures), } } @@ -692,6 +906,24 @@ fn tick_count_for_label_width(label_chars: usize, chart_width: u32) -> usize { fits.max(2) } +/// Bound horizontal categorical tick labels by the vertical pixels available +/// to the plot. The fixed deduction covers the caption, margins, and physical +/// x-axis label area; the remaining height uses a conservative twelve-pixel +/// pitch for the mesh font. A single category always keeps its one label. +fn horizontal_category_tick_count(category_count: usize, chart_height: u32) -> usize { + const NON_PLOT_HEIGHT_PX: u32 = 100; + const MIN_LABEL_PITCH_PX: u32 = 12; + + if category_count <= 1 { + return category_count; + } + let available_height = chart_height.saturating_sub(NON_PLOT_HEIGHT_PX); + let fits = usize::try_from(available_height / MIN_LABEL_PITCH_PX) + .unwrap_or(usize::MAX) + .max(2); + fits.min(category_count) +} + /// If all labels share a trailing timezone offset pattern like `+00:00` /// or `-05:30`, return that suffix. Returns `None` if labels differ or /// have no offset. @@ -730,6 +962,7 @@ fn shared_tz_suffix(labels: &[String]) -> Option { /// for charts over `DATE` / enum / name-keyed data where `x_val` is a /// synthetic sequential index assigned by `group_series`'s category /// mode rather than a meaningful number. +#[cfg(test)] fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> { // Dedup by bit pattern so NaN handling stays consistent with how // `BTreeMap` would behave (we store as `u64` bits because @@ -752,6 +985,26 @@ fn collect_categories(groups: &SeriesMap) -> Vec<(f64, String)> { .collect() } +fn collect_chart_categories(groups: &ChartSeriesMap) -> Vec<(f64, String)> { + let mut seen: BTreeMap = BTreeMap::new(); + for points in groups.values() { + for point in points { + seen.entry(point.x.to_bits()) + .or_insert_with(|| point.x_label.clone()); + } + } + let mut entries: Vec<_> = seen.into_iter().collect(); + entries.sort_by(|a, b| { + f64::from_bits(a.0) + .partial_cmp(&f64::from_bits(b.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + entries + .into_iter() + .map(|(bits, label)| (f64::from_bits(bits), label)) + .collect() +} + /// Discriminator for temporal x-axis input formats. Drives both the /// date parser ([`parse_temporal`]) and the time-axis label formatter, /// so a chart with `DATE` x values doesn't waste pixels on `00:00:00` @@ -910,6 +1163,7 @@ fn format_temporal_tick(seconds: f64, kind: TemporalKind) -> String { /// Group rows into (`series_name`, points) buckets, extracting x and y values. /// When `series_col` is None, all points land in a single unnamed series. +#[cfg(test)] fn group_series( rows: &[Value], x_col: &str, @@ -917,18 +1171,45 @@ fn group_series( series_col: Option<&str>, x_mode: XMode, ) -> Result { - let mut groups: SeriesMap = BTreeMap::new(); + group_chart_series(rows, x_col, y_col, series_col, x_mode, None).map(|groups| { + groups + .into_iter() + .map(|(series, points)| { + let points = points + .into_iter() + .map(|point| (point.x, point.y, point.x_label)) + .collect(); + (series, points) + }) + .collect() + }) +} + +fn group_chart_series( + rows: &[Value], + x_col: &str, + y_col: &str, + series_col: Option<&str>, + x_mode: XMode, + measures: Option<&[ChartMeasureValue]>, +) -> Result { + let mut groups: ChartSeriesMap = BTreeMap::new(); let mut category_index: BTreeMap = BTreeMap::new(); - for row in rows { + for (row_index, row) in rows.iter().enumerate() { let Some(obj) = row.as_object() else { continue }; - let y_val = obj.get(y_col).and_then(as_number).ok_or_else(|| { + let y_raw = obj.get(y_col).ok_or_else(|| { McpError::new( ErrorCode::SchemaMismatch, format!("Column '{y_col}' is missing or not numeric in at least one row"), ) })?; + let (y_val, y_label) = chart_measure_coordinate_and_label( + measures.and_then(|values| values.get(row_index)), + y_raw, + y_col, + )?; let x_raw = obj.get(x_col).cloned().unwrap_or(Value::Null); let x_label = as_string(&x_raw); @@ -960,10 +1241,12 @@ fn group_series( None => String::new(), }; - groups - .entry(series_key) - .or_default() - .push((x_val, y_val, x_label)); + groups.entry(series_key).or_default().push(ChartPoint { + x: x_val, + y: y_val, + x_label, + y_label, + }); } if groups.values().all(std::vec::Vec::is_empty) { @@ -976,6 +1259,36 @@ fn group_series( Ok(groups) } +fn chart_measure_coordinate_and_label( + measure: Option<&ChartMeasureValue>, + json_value: &Value, + column: &str, +) -> Result<(f64, String), McpError> { + match measure { + Some(ChartMeasureValue::Finite { + coordinate, + display, + }) => Ok((*coordinate, display.clone())), + Some(ChartMeasureValue::NonFinite) => Err(McpError::new( + ErrorCode::InvalidArgument, + format!("Column '{column}' contains a non-finite numeric value"), + )), + Some(ChartMeasureValue::Null | ChartMeasureValue::NonNumeric) => { + Err(non_numeric_measure_error(column)) + } + None => as_number(json_value) + .map(|coordinate| (coordinate, as_string(json_value))) + .ok_or_else(|| non_numeric_measure_error(column)), + } +} + +fn non_numeric_measure_error(column: &str) -> McpError { + McpError::new( + ErrorCode::SchemaMismatch, + format!("Column '{column}' is missing or not numeric in at least one row"), + ) +} + /// Pick a color from the palette by index, cycling as needed. fn series_color(idx: usize) -> RGBColor { // 8 distinct colors that work on white background; cycles for more series. @@ -1007,7 +1320,11 @@ fn series_color_for(series_name: &str, idx: usize, opts: &ChartOptions) -> RGBCo #[must_use] pub fn parse_hex_color(s: &str) -> Option { let s = s.strip_prefix('#').unwrap_or(s); - if s.len() != 6 { + // Guard `is_ascii()` before the byte slices below: a 6-*byte* multi-byte + // string (e.g. "1é234", where `é` is two bytes) passes `len() != 6` but + // `&s[0..2]` would land mid-codepoint and panic. ASCII guarantees one + // byte per char, so every `[0..2]`/`[2..4]`/`[4..6]` is a char boundary. + if !s.is_ascii() || s.len() != 6 { return None; } let r = u8::from_str_radix(&s[0..2], 16).ok()?; @@ -1020,151 +1337,536 @@ fn draw_bar( root: &DrawingArea, rows: &[Value], opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, ) -> Result where ::ErrorType: 'static, { let x_col = require_column(&opts.x_column, "x")?; let y_col = require_column(&opts.y_column, "y")?; - - // Bar charts default to categorical x axis; `ChartOptions::x_as_category=Some(false)` - // lets callers force numeric if they really want to. Bar charts never - // use temporal mode — even time-series bar charts visually expect - // discrete bars at evenly-spaced positions. - let x_mode = if opts.x_as_category == Some(false) { - XMode::Numeric - } else { - XMode::Categorical + // A bar's x value is always a category. In particular, a numeric JSON + // scalar must not be interpreted as a physical x coordinate when the + // legacy `x_as_category:false` flag is present. + let groups = group_chart_series( + rows, + x_col, + y_col, + opts.series_column.as_deref(), + XMode::Categorical, + measures, + )?; + let categories = collect_chart_categories(&groups); + let values: Vec = groups + .values() + .flat_map(|points| points.iter().map(|point| point.y)) + .collect(); + let measure_range = match presentation.y_scale { + MeasureScale::Linear => linear_bar_range(&values, opts.y_range)?, + MeasureScale::Log => log_measure_range(&values, opts.y_range)?, }; - let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?; - - let categories = collect_categories(&groups); + let title = opts + .title + .clone() + .unwrap_or_else(|| format!("{y_col} by {x_col}")); - let x_min = -0.5_f64; - let x_max = categories.len() as f64 - 0.5; + match (presentation.bar_orientation, presentation.y_scale) { + (BarOrientation::Vertical, MeasureScale::Linear) => draw_vertical_bar_linear( + root, + &groups, + &categories, + opts, + x_col, + y_col, + &title, + measure_range, + presentation, + ), + (BarOrientation::Vertical, MeasureScale::Log) => draw_vertical_bar_log( + root, + &groups, + &categories, + opts, + x_col, + y_col, + &title, + measure_range, + presentation, + ), + (BarOrientation::Horizontal, MeasureScale::Linear) => draw_horizontal_bar_linear( + root, + &groups, + &categories, + opts, + x_col, + y_col, + &title, + measure_range, + presentation, + ), + (BarOrientation::Horizontal, MeasureScale::Log) => draw_horizontal_bar_log( + root, + &groups, + &categories, + opts, + x_col, + y_col, + &title, + measure_range, + presentation, + ), + } +} - let y_min = groups - .values() - .flat_map(|pts| pts.iter().map(|(_, y, _)| *y)) +fn linear_bar_range(values: &[f64], explicit: Option<[f64; 2]>) -> Result<(f64, f64), McpError> { + if let Some([lo, hi]) = explicit { + return validate_effective_linear_range("y_range", (lo, hi)); + } + let lo = values + .iter() + .copied() .fold(f64::INFINITY, f64::min) .min(0.0); - let y_max = groups - .values() - .flat_map(|pts| pts.iter().map(|(_, y, _)| *y)) + let hi = values + .iter() + .copied() .fold(f64::NEG_INFINITY, f64::max) .max(0.0); - let y_pad = (y_max - y_min).abs() * 0.1 + 1.0; + let pad = (hi - lo).abs() * 0.1 + 1.0; + validate_effective_linear_range("derived bar y-axis range", (lo - pad, hi + pad)) +} - let title = opts - .title - .clone() - .unwrap_or_else(|| format!("{y_col} by {x_col}")); +fn linear_bar_baseline((lo, hi): (f64, f64)) -> f64 { + if lo <= 0.0 && hi >= 0.0 { + 0.0 + } else if lo > 0.0 { + lo + } else { + hi + } +} +fn draw_vertical_bar_linear( + root: &DrawingArea, + groups: &ChartSeriesMap, + categories: &[(f64, String)], + opts: &ChartOptions, + x_col: &str, + y_col: &str, + title: &str, + measure_range: (f64, f64), + presentation: ChartPresentation, +) -> Result +where + ::ErrorType: 'static, +{ + let category_range = -0.5_f64..categories.len() as f64 - 0.5; let mut chart = ChartBuilder::on(root) - .caption(&title, ("sans-serif", 22)) + .caption(title, ("sans-serif", 22)) .margin(10) .x_label_area_size(60) .y_label_area_size(70) - .build_cartesian_2d(x_min..x_max, (y_min - y_pad)..(y_max + y_pad)) - .map_err(draw_err)?; - - let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); - let labels = strip_shared_tz_suffix(&raw_labels); - let tick_count = auto_tick_count(&labels, opts.width); - chart - .configure_mesh() - .x_labels(tick_count) - .x_label_formatter(&|v| { - #[expect( - clippy::cast_possible_truncation, - reason = "axis tick value originated as an integer index into `labels`; the subsequent `usize::try_from` + length check make out-of-range ticks render as the empty-string branch" - )] - let idx = v.round() as isize; - usize::try_from(idx) - .ok() - .and_then(|i| labels.get(i).cloned()) - .unwrap_or_default() - }) - .y_desc(y_col) - .x_desc(x_col) - .draw() + .build_cartesian_2d(category_range, measure_range.0..measure_range.1) .map_err(draw_err)?; + configure_vertical_bar_mesh(&mut chart, categories, opts.width, x_col, y_col)?; + let baseline = linear_bar_baseline(measure_range); let num_series = groups.len().max(1); let total_width = 0.8_f64; let bar_width = total_width / num_series as f64; + let label_style = ("sans-serif", 11).into_font().color(&BLACK); let mut total_plotted = 0usize; - for (idx, (series_key, pts)) in groups.iter().enumerate() { + for (idx, (series_key, points)) in groups.iter().enumerate() { let color = series_color_for(series_key, idx, opts); let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5); - let name = if series_key.is_empty() { - y_col.to_string() - } else { - series_key.clone() - }; - chart - .draw_series(pts.iter().map(|(x, y, _)| { - let left = x + offset - bar_width / 2.0; - let right = x + offset + bar_width / 2.0; - Rectangle::new([(left, 0.0), (right, *y)], color.filled()) + let name = bar_series_name(series_key, y_col); + let annotation = chart + .draw_series(points.iter().map(|point| { + let left = point.x + offset - bar_width / 2.0; + let right = point.x + offset + bar_width / 2.0; + Rectangle::new([(left, baseline), (right, point.y)], color.filled()) })) - .map_err(draw_err)? - .label(name) - .legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled())); - total_plotted += pts.len(); + .map_err(draw_err)?; + if presentation.show_legend { + annotation.label(name).legend(move |(x, y)| { + Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()) + }); + } + if presentation.label_values { + chart + .draw_series(points.iter().map(|point| { + EmptyElement::at((point.x + offset, point.y)) + + Text::new(point.y_label.clone(), (0, -5), label_style.clone()) + })) + .map_err(draw_err)?; + } + total_plotted += points.len(); + } + if presentation.show_legend { + draw_series_legend(&mut chart)?; } - - chart - .configure_series_labels() - .background_style(colors::WHITE.mix(0.9)) - .border_style(colors::BLACK) - .draw() - .map_err(draw_err)?; - root.present().map_err(draw_err)?; Ok(total_plotted) } -fn draw_line( +fn draw_vertical_bar_log( root: &DrawingArea, - rows: &[Value], + groups: &ChartSeriesMap, + categories: &[(f64, String)], opts: &ChartOptions, + x_col: &str, + y_col: &str, + title: &str, + measure_range: (f64, f64), + presentation: ChartPresentation, ) -> Result where ::ErrorType: 'static, { - line_or_scatter(root, rows, opts, true) + let category_range = -0.5_f64..categories.len() as f64 - 0.5; + let mut chart = ChartBuilder::on(root) + .caption(title, ("sans-serif", 22)) + .margin(10) + .x_label_area_size(60) + .y_label_area_size(70) + .build_cartesian_2d( + category_range, + (measure_range.0..measure_range.1) + .log_scale() + .with_key_points(bounded_log_key_points(measure_range)), + ) + .map_err(draw_err)?; + configure_vertical_bar_mesh(&mut chart, categories, opts.width, x_col, y_col)?; + + let baseline = measure_range.0; + let num_series = groups.len().max(1); + let total_width = 0.8_f64; + let bar_width = total_width / num_series as f64; + let label_style = ("sans-serif", 11).into_font().color(&BLACK); + let mut total_plotted = 0usize; + for (idx, (series_key, points)) in groups.iter().enumerate() { + let color = series_color_for(series_key, idx, opts); + let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5); + let name = bar_series_name(series_key, y_col); + let annotation = chart + .draw_series(points.iter().map(|point| { + let left = point.x + offset - bar_width / 2.0; + let right = point.x + offset + bar_width / 2.0; + Rectangle::new([(left, baseline), (right, point.y)], color.filled()) + })) + .map_err(draw_err)?; + if presentation.show_legend { + annotation.label(name).legend(move |(x, y)| { + Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()) + }); + } + if presentation.label_values { + chart + .draw_series(points.iter().map(|point| { + EmptyElement::at((point.x + offset, point.y)) + + Text::new(point.y_label.clone(), (0, -5), label_style.clone()) + })) + .map_err(draw_err)?; + } + total_plotted += points.len(); + } + if presentation.show_legend { + draw_series_legend(&mut chart)?; + } + root.present().map_err(draw_err)?; + Ok(total_plotted) } -fn draw_scatter( +fn draw_horizontal_bar_linear( root: &DrawingArea, - rows: &[Value], + groups: &ChartSeriesMap, + categories: &[(f64, String)], opts: &ChartOptions, + x_col: &str, + y_col: &str, + title: &str, + measure_range: (f64, f64), + presentation: ChartPresentation, ) -> Result where ::ErrorType: 'static, { - line_or_scatter(root, rows, opts, false) + let category_range = categories.len() as f64 - 0.5..-0.5_f64; + let mut chart = ChartBuilder::on(root) + .caption(title, ("sans-serif", 22)) + .margin(10) + .x_label_area_size(70) + .y_label_area_size(160) + .build_cartesian_2d(measure_range.0..measure_range.1, category_range) + .map_err(draw_err)?; + configure_horizontal_bar_mesh(&mut chart, categories, opts.height, x_col, y_col)?; + + let baseline = linear_bar_baseline(measure_range); + let num_series = groups.len().max(1); + let total_width = 0.8_f64; + let bar_width = total_width / num_series as f64; + let label_style = ("sans-serif", 11).into_font().color(&BLACK); + let mut total_plotted = 0usize; + for (idx, (series_key, points)) in groups.iter().enumerate() { + let color = series_color_for(series_key, idx, opts); + let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5); + let name = bar_series_name(series_key, y_col); + let annotation = chart + .draw_series(points.iter().map(|point| { + let top = point.x + offset - bar_width / 2.0; + let bottom = point.x + offset + bar_width / 2.0; + Rectangle::new([(baseline, top), (point.y, bottom)], color.filled()) + })) + .map_err(draw_err)?; + if presentation.show_legend { + annotation.label(name).legend(move |(x, y)| { + Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()) + }); + } + if presentation.label_values { + chart + .draw_series(points.iter().map(|point| { + EmptyElement::at((point.y, point.x + offset)) + + Text::new(point.y_label.clone(), (5, 0), label_style.clone()) + })) + .map_err(draw_err)?; + } + total_plotted += points.len(); + } + if presentation.show_legend { + draw_series_legend(&mut chart)?; + } + root.present().map_err(draw_err)?; + Ok(total_plotted) } -#[expect( - clippy::similar_names, - reason = "paired bindings (request/response, reader/writer, etc.) are more readable with symmetric names than artificially distinct ones" -)] -/// Shared implementation for line and scatter charts. `connect_points` controls -/// whether successive points are joined with a line. -fn line_or_scatter( +fn draw_horizontal_bar_log( root: &DrawingArea, - rows: &[Value], + groups: &ChartSeriesMap, + categories: &[(f64, String)], opts: &ChartOptions, - connect_points: bool, + x_col: &str, + y_col: &str, + title: &str, + measure_range: (f64, f64), + presentation: ChartPresentation, ) -> Result where ::ErrorType: 'static, { - let x_col = require_column(&opts.x_column, "x")?; - let y_col = require_column(&opts.y_column, "y")?; - // Decide the x mode: + let category_range = categories.len() as f64 - 0.5..-0.5_f64; + let mut chart = ChartBuilder::on(root) + .caption(title, ("sans-serif", 22)) + .margin(10) + .x_label_area_size(70) + .y_label_area_size(160) + .build_cartesian_2d( + (measure_range.0..measure_range.1) + .log_scale() + .with_key_points(bounded_log_key_points(measure_range)), + category_range, + ) + .map_err(draw_err)?; + configure_horizontal_bar_mesh(&mut chart, categories, opts.height, x_col, y_col)?; + + let baseline = measure_range.0; + let num_series = groups.len().max(1); + let total_width = 0.8_f64; + let bar_width = total_width / num_series as f64; + let label_style = ("sans-serif", 11).into_font().color(&BLACK); + let mut total_plotted = 0usize; + for (idx, (series_key, points)) in groups.iter().enumerate() { + let color = series_color_for(series_key, idx, opts); + let offset = -total_width / 2.0 + bar_width * (idx as f64 + 0.5); + let name = bar_series_name(series_key, y_col); + let annotation = chart + .draw_series(points.iter().map(|point| { + let top = point.x + offset - bar_width / 2.0; + let bottom = point.x + offset + bar_width / 2.0; + Rectangle::new([(baseline, top), (point.y, bottom)], color.filled()) + })) + .map_err(draw_err)?; + if presentation.show_legend { + annotation.label(name).legend(move |(x, y)| { + Rectangle::new([(x, y - 5), (x + 12, y + 5)], color.filled()) + }); + } + if presentation.label_values { + chart + .draw_series(points.iter().map(|point| { + EmptyElement::at((point.y, point.x + offset)) + + Text::new(point.y_label.clone(), (5, 0), label_style.clone()) + })) + .map_err(draw_err)?; + } + total_plotted += points.len(); + } + if presentation.show_legend { + draw_series_legend(&mut chart)?; + } + root.present().map_err(draw_err)?; + Ok(total_plotted) +} + +fn configure_vertical_bar_mesh( + chart: &mut ChartContext<'_, DB, Cartesian2d>, + categories: &[(f64, String)], + chart_width: u32, + x_col: &str, + y_col: &str, +) -> Result<(), McpError> +where + DB: DrawingBackend, + DB::ErrorType: 'static, + Y: Ranged + ValueFormatter, +{ + let raw_labels: Vec = categories.iter().map(|(_, label)| label.clone()).collect(); + let labels = strip_shared_tz_suffix(&raw_labels); + let label_map = category_label_map(categories, &labels); + let tick_count = auto_tick_count(&labels, chart_width); + chart + .configure_mesh() + .x_labels(tick_count) + .x_label_formatter(&|value| category_label(*value, &label_map)) + .y_desc(y_col) + .x_desc(x_col) + .draw() + .map_err(draw_err) +} + +fn configure_horizontal_bar_mesh( + chart: &mut ChartContext<'_, DB, Cartesian2d>, + categories: &[(f64, String)], + chart_height: u32, + x_col: &str, + y_col: &str, +) -> Result<(), McpError> +where + DB: DrawingBackend, + DB::ErrorType: 'static, + X: Ranged + ValueFormatter, +{ + let raw_labels: Vec = categories.iter().map(|(_, label)| label.clone()).collect(); + let labels = strip_shared_tz_suffix(&raw_labels); + let label_map = category_label_map(categories, &labels); + let tick_count = horizontal_category_tick_count(labels.len(), chart_height); + chart + .configure_mesh() + .y_labels(tick_count) + .y_label_formatter(&|value| category_label(*value, &label_map)) + .x_desc(y_col) + .y_desc(x_col) + .draw() + .map_err(draw_err) +} + +fn category_label_map(categories: &[(f64, String)], labels: &[String]) -> BTreeMap { + categories + .iter() + .zip(labels) + .map(|((position, _), label)| (position.to_bits(), label.clone())) + .collect() +} + +fn category_label(value: f64, labels: &BTreeMap) -> String { + value + .is_finite() + .then(|| value.round().to_bits()) + .and_then(|position| labels.get(&position).cloned()) + .unwrap_or_default() +} + +fn bar_series_name(series_key: &str, y_col: &str) -> String { + if series_key.is_empty() { + y_col.to_string() + } else { + series_key.to_string() + } +} + +fn draw_series_legend<'a, DB, CT>(chart: &mut ChartContext<'a, DB, CT>) -> Result<(), McpError> +where + DB: DrawingBackend + 'a, + DB::ErrorType: 'static, + CT: CoordTranslate, +{ + chart + .configure_series_labels() + .background_style(colors::WHITE.mix(0.9)) + .border_style(colors::BLACK) + .draw() + .map_err(draw_err) +} + +fn bounded_log_key_points((lo, hi): (f64, f64)) -> Vec { + const TICK_COUNT: usize = 7; + const LAST_TICK: usize = TICK_COUNT - 1; + + let log_lo = lo.ln(); + let log_span = hi.ln() - log_lo; + let denominator = LAST_TICK as f64; + let mut ticks = Vec::with_capacity(TICK_COUNT); + for index in 0..TICK_COUNT { + let tick = if index == 0 { + lo + } else if index == LAST_TICK { + hi + } else { + (log_lo + log_span * (index as f64 / denominator)).exp() + }; + let follows_previous = match ticks.last() { + Some(previous) => tick > *previous, + None => true, + }; + if tick.is_finite() && tick > 0.0 && tick >= lo && tick <= hi && follows_previous { + ticks.push(tick); + } + } + ticks +} + +fn draw_line( + root: &DrawingArea, + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, +) -> Result +where + ::ErrorType: 'static, +{ + line_or_scatter(root, rows, opts, true, presentation, measures) +} + +fn draw_scatter( + root: &DrawingArea, + rows: &[Value], + opts: &ChartOptions, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, +) -> Result +where + ::ErrorType: 'static, +{ + line_or_scatter(root, rows, opts, false, presentation, measures) +} + +/// Shared implementation for line and scatter charts. `connect_points` controls +/// whether successive points are joined with a line. +fn line_or_scatter( + root: &DrawingArea, + rows: &[Value], + opts: &ChartOptions, + connect_points: bool, + presentation: ChartPresentation, + measures: Option<&[ChartMeasureValue]>, +) -> Result +where + ::ErrorType: 'static, +{ + let x_col = require_column(&opts.x_column, "x")?; + let y_col = require_column(&opts.y_column, "y")?; + // Decide the x mode: // - Explicit `x_as_category=Some(true)` → Categorical (force). // - Explicit `x_as_category=Some(false)` → Numeric (force). // - Default (None): peek at the first row's x value: @@ -1176,10 +1878,14 @@ where Some(false) => XMode::Numeric, None => detect_line_x_mode(rows, x_col), }; - let groups = group_series(rows, x_col, y_col, opts.series_column.as_deref(), x_mode)?; - - let auto = bounds(&groups); - let (rx_min, rx_max, ry_min, ry_max) = apply_ranges(auto, opts); + let groups = group_chart_series( + rows, + x_col, + y_col, + opts.series_column.as_deref(), + x_mode, + measures, + )?; let default_title = if connect_points { "Line chart" @@ -1188,15 +1894,58 @@ where }; let title = opts.title.clone().unwrap_or_else(|| default_title.into()); + match presentation.y_scale { + MeasureScale::Linear => draw_line_or_scatter_linear( + root, + &groups, + opts, + x_col, + y_col, + x_mode, + &title, + connect_points, + presentation, + ), + MeasureScale::Log => draw_line_or_scatter_log( + root, + &groups, + opts, + x_col, + y_col, + x_mode, + &title, + connect_points, + presentation, + ), + } +} + +fn draw_line_or_scatter_linear( + root: &DrawingArea, + groups: &ChartSeriesMap, + opts: &ChartOptions, + x_col: &str, + y_col: &str, + x_mode: XMode, + title: &str, + connect_points: bool, + presentation: ChartPresentation, +) -> Result +where + ::ErrorType: 'static, +{ + let auto = bounds(groups); + let (x_start, x_end, measure_floor, measure_ceiling) = apply_ranges(auto, opts)?; + let mut chart = ChartBuilder::on(root) - .caption(&title, ("sans-serif", 22)) + .caption(title, ("sans-serif", 22)) .margin(10) .x_label_area_size(match x_mode { XMode::Categorical | XMode::Temporal(_) => 60, XMode::Numeric => 50, }) .y_label_area_size(70) - .build_cartesian_2d(rx_min..rx_max, ry_min..ry_max) + .build_cartesian_2d(x_start..x_end, measure_floor..measure_ceiling) .map_err(draw_err)?; // Configure the x-axis ticks per mode: @@ -1208,7 +1957,7 @@ where // - Numeric: pass-through; plotters' default float formatter is fine. match x_mode { XMode::Categorical => { - let categories = collect_categories(&groups); + let categories = collect_chart_categories(groups); let raw_labels: Vec = categories.iter().map(|(_, l)| l.clone()).collect(); let labels = strip_shared_tz_suffix(&raw_labels); let tick_count = auto_tick_count(&labels, opts.width); @@ -1236,7 +1985,7 @@ where // DATE → 10 chars, TIMESTAMP → 19, TIMESTAMPTZ → 25 (with // `+HH:MM`). Floor at 10 so a degenerate sample still gets // a reasonable per-label budget. - let sample = format_temporal_tick(rx_min, kind); + let sample = format_temporal_tick(x_start, kind); let sample_chars = sample.chars().count().max(10); let tick_count = tick_count_for_label_width(sample_chars, opts.width); chart @@ -1259,16 +2008,16 @@ where } let mut total_plotted = 0usize; - for (idx, (series_key, pts)) in groups.iter().enumerate() { + for (idx, (series_key, points)) in groups.iter().enumerate() { let color = series_color_for(series_key, idx, opts); let name = if series_key.is_empty() { y_col.to_string() } else { series_key.clone() }; - let mut sorted = pts.clone(); + let mut sorted = points.clone(); if connect_points { - sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal)); + sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal)); } if opts.label_points { @@ -1277,7 +2026,7 @@ where if connect_points { chart .draw_series(LineSeries::new( - sorted.iter().map(|(x, y, _)| (*x, *y)), + sorted.iter().map(|point| (point.x, point.y)), color.stroke_width(2), )) .map_err(draw_err)?; @@ -1286,7 +2035,7 @@ where .draw_series( sorted .iter() - .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())), + .map(|point| Circle::new((point.x, point.y), 4, color.filled())), ) .map_err(draw_err)?; } @@ -1294,11 +2043,11 @@ where // the right 25% of the x range, flip the label left so it stays // inside the chart area. When near the bottom 15% of y, flip up // so the label isn't below the axis line. - let x_flip_threshold = rx_min + (rx_max - rx_min) * 0.75; - let y_flip_threshold = ry_min + (ry_max - ry_min) * 0.15; + let x_flip_threshold = x_start + (x_end - x_start) * 0.75; + let y_flip_threshold = measure_floor + (measure_ceiling - measure_floor) * 0.15; let label_style = ("sans-serif", 11).into_font().color(&BLACK); chart - .draw_series(sorted.iter().map(|(x, y, _)| { + .draw_series(sorted.iter().map(|point| { let label = name.clone(); // Estimate pixel width: ~7px per Unicode character for 11pt font. // This is still approximate but handles multi-byte UTF-8 correctly. @@ -1310,75 +2059,256 @@ where let char_px = i32::try_from(label.chars().count()) .unwrap_or(i32::MAX) .saturating_mul(7); - let x_off = if *x >= x_flip_threshold { + let x_off = if point.x >= x_flip_threshold { -(char_px + 6) } else { 6 }; - let y_off = if *y <= y_flip_threshold { -20 } else { -12 }; - EmptyElement::at((*x, *y)) + let y_off = if point.y <= y_flip_threshold { + -20 + } else { + -12 + }; + EmptyElement::at((point.x, point.y)) + Text::new(label, (x_off, y_off), label_style.clone()) })) .map_err(draw_err)?; } else { // Default: dots/lines with legend entry. - if connect_points { + let annotation = if connect_points { chart .draw_series(LineSeries::new( - sorted.iter().map(|(x, y, _)| (*x, *y)), + sorted.iter().map(|point| (point.x, point.y)), color.stroke_width(2), )) .map_err(draw_err)? - .label(name) - .legend(move |(x, y)| { - PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2)) - }); } else { chart .draw_series( sorted .iter() - .map(|(x, y, _)| Circle::new((*x, *y), 4, color.filled())), + .map(|point| Circle::new((point.x, point.y), 4, color.filled())), ) .map_err(draw_err)? - .label(name) - .legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled())); + }; + if presentation.show_legend { + if connect_points { + annotation.label(name).legend(move |(x, y)| { + PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2)) + }); + } else { + annotation + .label(name) + .legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled())); + } } } - total_plotted += pts.len(); + total_plotted += points.len(); } // Only draw the legend box when label_points is off — with labels // on the dots, the legend is redundant and takes up chart space. - if !opts.label_points { - chart - .configure_series_labels() - .background_style(colors::WHITE.mix(0.9)) - .border_style(colors::BLACK) - .draw() - .map_err(draw_err)?; + if !opts.label_points && presentation.show_legend { + draw_series_legend(&mut chart)?; + } + + root.present().map_err(draw_err)?; + Ok(total_plotted) +} + +fn draw_line_or_scatter_log( + root: &DrawingArea, + groups: &ChartSeriesMap, + opts: &ChartOptions, + x_col: &str, + y_col: &str, + x_mode: XMode, + title: &str, + connect_points: bool, + presentation: ChartPresentation, +) -> Result +where + ::ErrorType: 'static, +{ + let auto = bounds(groups); + let x_pad = (auto.1 - auto.0).abs() * 0.05 + 1e-9; + let (x_start, x_end) = match opts.x_range { + Some([lo, hi]) => (lo, hi), + None => (auto.0 - x_pad, auto.1 + x_pad), + }; + let (x_start, x_end) = + validate_effective_linear_range("effective x-axis range", (x_start, x_end))?; + let values: Vec = groups + .values() + .flat_map(|points| points.iter().map(|point| point.y)) + .collect(); + let (measure_floor, measure_ceiling) = log_measure_range(&values, opts.y_range)?; + + let mut chart = ChartBuilder::on(root) + .caption(title, ("sans-serif", 22)) + .margin(10) + .x_label_area_size(match x_mode { + XMode::Categorical | XMode::Temporal(_) => 60, + XMode::Numeric => 50, + }) + .y_label_area_size(70) + .build_cartesian_2d( + x_start..x_end, + (measure_floor..measure_ceiling) + .log_scale() + .with_key_points(bounded_log_key_points((measure_floor, measure_ceiling))), + ) + .map_err(draw_err)?; + + match x_mode { + XMode::Categorical => { + let categories = collect_chart_categories(groups); + let raw_labels: Vec = + categories.iter().map(|(_, label)| label.clone()).collect(); + let labels = strip_shared_tz_suffix(&raw_labels); + let label_map = category_label_map(&categories, &labels); + let tick_count = auto_tick_count(&labels, opts.width); + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .x_labels(tick_count) + .x_label_formatter(&|value| category_label(*value, &label_map)) + .draw() + .map_err(draw_err)?; + } + XMode::Temporal(kind) => { + let sample = format_temporal_tick(x_start, kind); + let sample_chars = sample.chars().count().max(10); + let tick_count = tick_count_for_label_width(sample_chars, opts.width); + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .x_labels(tick_count) + .x_label_formatter(&|value| format_temporal_tick(*value, kind)) + .draw() + .map_err(draw_err)?; + } + XMode::Numeric => { + chart + .configure_mesh() + .x_desc(x_col) + .y_desc(y_col) + .draw() + .map_err(draw_err)?; + } + } + + let mut total_plotted = 0usize; + for (idx, (series_key, points)) in groups.iter().enumerate() { + let color = series_color_for(series_key, idx, opts); + let name = if series_key.is_empty() { + y_col.to_string() + } else { + series_key.clone() + }; + let mut sorted = points.clone(); + if connect_points { + sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal)); + } + + if opts.label_points { + if connect_points { + chart + .draw_series(LineSeries::new( + sorted.iter().map(|point| (point.x, point.y)), + color.stroke_width(2), + )) + .map_err(draw_err)?; + } else { + chart + .draw_series( + sorted + .iter() + .map(|point| Circle::new((point.x, point.y), 4, color.filled())), + ) + .map_err(draw_err)?; + } + let x_flip_threshold = x_start + (x_end - x_start) * 0.75; + let y_flip_threshold = + (measure_floor.ln() + (measure_ceiling.ln() - measure_floor.ln()) * 0.15).exp(); + let label_style = ("sans-serif", 11).into_font().color(&BLACK); + chart + .draw_series(sorted.iter().map(|point| { + let label = name.clone(); + let char_px = i32::try_from(label.chars().count()) + .unwrap_or(i32::MAX) + .saturating_mul(7); + let x_off = if point.x >= x_flip_threshold { + -(char_px + 6) + } else { + 6 + }; + let y_off = if point.y <= y_flip_threshold { + -20 + } else { + -12 + }; + EmptyElement::at((point.x, point.y)) + + Text::new(label, (x_off, y_off), label_style.clone()) + })) + .map_err(draw_err)?; + } else { + let annotation = if connect_points { + chart + .draw_series(LineSeries::new( + sorted.iter().map(|point| (point.x, point.y)), + color.stroke_width(2), + )) + .map_err(draw_err)? + } else { + chart + .draw_series( + sorted + .iter() + .map(|point| Circle::new((point.x, point.y), 4, color.filled())), + ) + .map_err(draw_err)? + }; + if presentation.show_legend { + if connect_points { + annotation.label(name).legend(move |(x, y)| { + PathElement::new(vec![(x, y), (x + 16, y)], color.stroke_width(2)) + }); + } else { + annotation + .label(name) + .legend(move |(x, y)| Circle::new((x + 8, y), 4, color.filled())); + } + } + } + total_plotted += points.len(); } + if !opts.label_points && presentation.show_legend { + draw_series_legend(&mut chart)?; + } root.present().map_err(draw_err)?; Ok(total_plotted) } -fn bounds(groups: &SeriesMap) -> (f64, f64, f64, f64) { +fn bounds(groups: &ChartSeriesMap) -> (f64, f64, f64, f64) { let (mut x_min, mut x_max) = (f64::INFINITY, f64::NEG_INFINITY); let (mut y_min, mut y_max) = (f64::INFINITY, f64::NEG_INFINITY); for pts in groups.values() { - for (x, y, _) in pts { - if *x < x_min { - x_min = *x; + for point in pts { + if point.x < x_min { + x_min = point.x; } - if *x > x_max { - x_max = *x; + if point.x > x_max { + x_max = point.x; } - if *y < y_min { - y_min = *y; + if point.y < y_min { + y_min = point.y; } - if *y > y_max { - y_max = *y; + if point.y > y_max { + y_max = point.y; } } } @@ -1413,7 +2343,10 @@ fn bounds(groups: &SeriesMap) -> (f64, f64, f64, f64) { /// When a range is provided the auto-computed bound is replaced entirely — /// no padding is added on the overridden axes. Auto-computed axes still /// receive their normal 5% padding so they don't clip the outermost point. -fn apply_ranges(auto: (f64, f64, f64, f64), opts: &ChartOptions) -> (f64, f64, f64, f64) { +fn apply_ranges( + auto: (f64, f64, f64, f64), + opts: &ChartOptions, +) -> Result<(f64, f64, f64, f64), McpError> { let (x_min, x_max, y_min, y_max) = auto; let x_pad = (x_max - x_min).abs() * 0.05 + 1e-9; let y_pad = (y_max - y_min).abs() * 0.05 + 1e-9; @@ -1425,13 +2358,18 @@ fn apply_ranges(auto: (f64, f64, f64, f64), opts: &ChartOptions) -> (f64, f64, f Some([lo, hi]) => (lo, hi), None => (y_min - y_pad, y_max + y_pad), }; - (final_x_min, final_x_max, final_y_min, final_y_max) + let (final_x_min, final_x_max) = + validate_effective_linear_range("effective x-axis range", (final_x_min, final_x_max))?; + let (final_y_min, final_y_max) = + validate_effective_linear_range("effective y-axis range", (final_y_min, final_y_max))?; + Ok((final_x_min, final_x_max, final_y_min, final_y_max)) } fn draw_histogram( root: &DrawingArea, rows: &[Value], opts: &ChartOptions, + measures: Option<&[ChartMeasureValue]>, ) -> Result where ::ErrorType: 'static, @@ -1448,10 +2386,28 @@ where ) })?; - let values: Vec = rows - .iter() - .filter_map(|r| r.as_object().and_then(|o| o.get(col)).and_then(as_number)) - .collect(); + let mut values = Vec::new(); + for (row_index, row) in rows.iter().enumerate() { + match measures.and_then(|typed| typed.get(row_index)) { + Some(ChartMeasureValue::Finite { coordinate, .. }) => values.push(*coordinate), + Some(ChartMeasureValue::NonFinite) => { + return Err(McpError::new( + ErrorCode::InvalidArgument, + format!("Column '{col}' contains a non-finite numeric value"), + )); + } + Some(ChartMeasureValue::Null | ChartMeasureValue::NonNumeric) => {} + None => { + if let Some(value) = row + .as_object() + .and_then(|object| object.get(col)) + .and_then(as_number) + { + values.push(value); + } + } + } + } if values.is_empty() { return Err(McpError::new( ErrorCode::SchemaMismatch, @@ -1462,12 +2418,25 @@ where let bin_count = opts.bins.max(1) as usize; let min = values.iter().copied().fold(f64::INFINITY, f64::min); let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); - let span = if (max - min).abs() < 1e-12 { + let raw_span = max - min; + if !raw_span.is_finite() { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "Histogram values do not form a finite representable span", + )); + } + let span = if raw_span.abs() < 1e-12 { 1.0 } else { - max - min + raw_span }; let bin_width = span / bin_count as f64; + if !bin_width.is_finite() || bin_width <= 0.0 { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "Histogram bin width must be finite and strictly positive", + )); + } let mut bins = vec![0u64; bin_count]; for v in &values { @@ -1491,13 +2460,20 @@ where .title .clone() .unwrap_or_else(|| format!("Distribution of {col}")); + let histogram_end = max + bin_width * 0.01; + let (histogram_start, histogram_end) = + validate_effective_linear_range("effective histogram x-axis range", (min, histogram_end))?; + let count_range = validate_effective_linear_range( + "effective histogram count-axis range", + (0.0, y_max * 1.1 + 1.0), + )?; let mut chart = ChartBuilder::on(root) .caption(&title, ("sans-serif", 22)) .margin(10) .x_label_area_size(50) .y_label_area_size(60) - .build_cartesian_2d(min..(max + bin_width * 0.01), 0.0..(y_max * 1.1 + 1.0)) + .build_cartesian_2d(histogram_start..histogram_end, count_range.0..count_range.1) .map_err(draw_err)?; chart @@ -1516,8 +2492,114 @@ where })) .map_err(draw_err)?; - root.present().map_err(draw_err)?; - Ok(values.len()) + root.present().map_err(draw_err)?; + Ok(values.len()) +} + +fn log_measure_range(values: &[f64], explicit: Option<[f64; 2]>) -> Result<(f64, f64), McpError> { + if values.is_empty() { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "A logarithmic measure scale requires at least one value", + )); + } + if values + .iter() + .any(|value| !value.is_finite() || *value <= 0.0) + { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "A logarithmic measure scale requires every plotted value to be finite and strictly positive", + )); + } + + let data_min = values.iter().copied().fold(f64::INFINITY, f64::min); + let data_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max); + if let Some([lo, hi]) = explicit { + let (lo, hi) = validate_log_coordinate_range("explicit logarithmic y_range", (lo, hi))?; + if lo > data_min || hi < data_max { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "An explicit logarithmic y_range must contain every plotted value", + )); + } + return Ok((lo, hi)); + } + + let positive_floor = f64::from_bits(1); + let log_floor = positive_floor.ln(); + let log_ceiling = f64::MAX.ln(); + let log_min = data_min.ln(); + let log_max = data_max.ln(); + let pad = if log_max <= log_min { + 0.05 * std::f64::consts::LN_10 + } else { + 0.05 * (log_max - log_min) + }; + let padded_log_lo = (log_min - pad).max(log_floor); + let padded_log_hi = (log_max + pad).min(log_ceiling); + + let mut lo = if padded_log_lo <= log_floor { + positive_floor + } else { + padded_log_lo.exp() + }; + let mut hi = if padded_log_hi >= log_ceiling { + f64::MAX + } else { + padded_log_hi.exp() + }; + + if lo > data_min || (lo.to_bits() == data_min.to_bits() && padded_log_lo < log_min) { + lo = next_positive_down(data_min).unwrap_or(data_min); + } + if hi < data_max || (hi.to_bits() == data_max.to_bits() && padded_log_hi > log_max) { + hi = next_positive_up(data_max).unwrap_or(data_max); + } + if lo >= hi { + if let Some(expanded_lo) = next_positive_down(lo) { + lo = expanded_lo; + } else if let Some(expanded_hi) = next_positive_up(hi) { + hi = expanded_hi; + } + } + + if lo > data_min || hi < data_max { + return Err(McpError::new( + ErrorCode::InvalidArgument, + "Could not construct a finite increasing logarithmic range that encloses every plotted value", + )); + } + validate_log_coordinate_range("automatic logarithmic y range", (lo, hi)) +} + +fn validate_log_coordinate_range(name: &str, (lo, hi): (f64, f64)) -> Result<(f64, f64), McpError> { + let log_lo = lo.ln(); + let log_hi = hi.ln(); + if !lo.is_finite() + || !hi.is_finite() + || lo <= 0.0 + || lo >= hi + || !log_lo.is_finite() + || !log_hi.is_finite() + || log_lo >= log_hi + { + return Err(McpError::new( + ErrorCode::InvalidArgument, + format!( + "The {name} must be finite, strictly positive, strictly increasing, and have a finite representable logarithmic span" + ), + )); + } + Ok((lo, hi)) +} + +fn next_positive_down(value: f64) -> Option { + (value > f64::from_bits(1)).then(|| f64::from_bits(value.to_bits() - 1)) +} + +fn next_positive_up(value: f64) -> Option { + (value < f64::MAX).then(|| f64::from_bits(value.to_bits() + 1)) } #[cfg(test)] @@ -1765,4 +2847,1363 @@ mod tests { let mode = detect_line_x_mode(&rows, "x"); assert!(matches!(mode, XMode::Numeric)); } + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct SvgRect { + x: i32, + y: i32, + width: i32, + height: i32, + } + + fn svg_i32_attr(line: &str, name: &str) -> Option { + let marker = format!("{name}=\""); + line.split_once(&marker)?.1.split_once('"')?.0.parse().ok() + } + + fn svg_rects_with_fill(svg: &str, fill: &str) -> Vec { + svg.lines() + .filter(|line| line.starts_with(" Option<(i32, i32)> { + svg.lines() + .filter(|line| line.starts_with("().ok()?, y.parse::().ok()?)) + }); + let first = coords.next()?; + let second = coords.next()?; + (first.0 == second.0).then_some((first.1.min(second.1), first.1.max(second.1))) + }) + .max_by_key(|(top, bottom)| bottom - top) + } + + fn bar_svg( + rows: &[Value], + x_as_category: Option, + y_range: Option<[f64; 2]>, + ) -> Result { + let opts = ChartOptions { + chart_type: ChartType::Bar, + x_column: Some("category".into()), + y_column: Some("value".into()), + format: ChartFormat::Svg, + width: 400, + height: 300, + x_as_category, + y_range, + ..ChartOptions::default() + }; + render_chart(rows, &opts).and_then(|result| { + String::from_utf8(result.bytes).map_err(|error| { + McpError::new( + ErrorCode::InternalError, + format!("renderer returned non-UTF-8 SVG: {error}"), + ) + }) + }) + } + + const RANGE_VALIDATION_CHILD_ENV: &str = "HYPERDB_MCP_CHART_RANGE_VALIDATION_CHILD"; + + fn assert_invalid_range_case(case: &str) { + let ordinary_rows = vec![ + serde_json::json!({"category": 1.0, "value": 2.0}), + serde_json::json!({"category": 2.0, "value": 3.0}), + ]; + let (rows, chart_type, x_range, y_range) = match case { + "reversed-x" => (ordinary_rows, ChartType::Bar, Some([2.0, 1.0]), None), + "equal-y" => (ordinary_rows, ChartType::Bar, None, Some([2.0, 2.0])), + "nan-x" => (ordinary_rows, ChartType::Line, Some([f64::NAN, 2.0]), None), + "infinite-y" => ( + ordinary_rows, + ChartType::Scatter, + None, + Some([0.0, f64::INFINITY]), + ), + "histogram-equal-x" => (ordinary_rows, ChartType::Histogram, Some([1.0, 1.0]), None), + "finite-extreme-explicit-x" => ( + ordinary_rows, + ChartType::Line, + Some([f64::MIN, f64::MAX]), + None, + ), + "finite-extreme-explicit-y" => ( + ordinary_rows, + ChartType::Scatter, + None, + Some([f64::MIN, f64::MAX]), + ), + "line-auto-x-padding-overflow" => ( + vec![ + serde_json::json!({"category": f64::MIN, "value": 2.0}), + serde_json::json!({"category": f64::MAX, "value": 3.0}), + ], + ChartType::Line, + None, + Some([1.0, 4.0]), + ), + "line-auto-y-padding-overflow" => ( + vec![ + serde_json::json!({"category": 1.0, "value": f64::MIN}), + serde_json::json!({"category": 2.0, "value": f64::MAX}), + ], + ChartType::Line, + Some([0.0, 3.0]), + None, + ), + "bar-auto-y-padding-overflow" => ( + vec![ + serde_json::json!({"category": "low", "value": f64::MIN}), + serde_json::json!({"category": "high", "value": f64::MAX}), + ], + ChartType::Bar, + None, + None, + ), + "histogram-auto-padding-overflow" => ( + vec![ + serde_json::json!({"category": f64::MIN, "value": 1.0}), + serde_json::json!({"category": f64::MAX, "value": 2.0}), + ], + ChartType::Histogram, + None, + None, + ), + other => panic!("unknown chart range validation case {other}"), + }; + let opts = ChartOptions { + chart_type, + x_column: Some("category".into()), + y_column: Some("value".into()), + format: ChartFormat::Svg, + x_range, + y_range, + ..ChartOptions::default() + }; + + match render_chart(&rows, &opts) { + Err(error) if error.code == ErrorCode::InvalidArgument => {} + Err(error) => panic!( + "{case}: expected InvalidArgument, got {:?}: {}", + error.code, error.message + ), + Ok(_) => panic!("{case}: unsafe effective range was accepted"), + } + } + + fn record_bounded_range_case(failures: &mut Vec, case: &str) { + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + let mut child = Command::new(std::env::current_exe().expect("unit test executable path")) + .args([ + "--exact", + "chart::tests::bar_ranges_and_categories_are_validated", + "--nocapture", + ]) + .env(RANGE_VALIDATION_CHILD_ENV, case) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("range validation parent must spawn its exact helper child"); + + let deadline = Instant::now() + Duration::from_secs(4); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let output = child + .wait_with_output() + .expect("range validation parent must collect child output"); + if !status.success() { + failures.push(format!( + "{case}: validation child failed with {status}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + return; + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + Ok(None) => { + let kill_error = child.kill().err(); + let output = child + .wait_with_output() + .expect("range validation parent must wait for timed-out child"); + failures.push(format!( + "{case}: renderer exceeded the 4s pre-validation bound and was killed ({kill_error:?})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + return; + } + Err(error) => { + let _ = child.kill(); + let output = child + .wait_with_output() + .expect("range validation parent must wait after status error"); + failures.push(format!( + "{case}: validation child status failed: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + return; + } + } + } + } + + /// Mutations caught: honoring `x_as_category:false` for bars (which pushes + /// numeric categories off-canvas), ignoring the fixed measure range, using + /// zero outside a positive-/negative-only range, accepting malformed or + /// arithmetic-unsafe explicit ranges, and allowing derived linear padding + /// or histogram spans to overflow before Plotters sees them. + #[test] + fn bar_ranges_and_categories_are_validated() { + if let Ok(case) = std::env::var(RANGE_VALIDATION_CHILD_ENV) { + assert_invalid_range_case(&case); + return; + } + + let mut failures = Vec::new(); + + let numeric_rows = vec![ + serde_json::json!({"category": 1000, "value": 12}), + serde_json::json!({"category": 2000, "value": 18}), + ]; + match bar_svg(&numeric_rows, Some(false), None) { + Ok(svg) => { + let bars: Vec<_> = svg_rects_with_fill(&svg, "#1F77B4") + .into_iter() + .filter(|rect| rect.width > 20 && rect.height > 0) + .collect(); + if bars.len() != 2 { + failures.push(format!( + "numeric bar x values must remain categorical even with x_as_category:false; expected two visible bars, got {bars:?}" + )); + } + } + Err(error) => failures.push(format!( + "numeric categorical bar render unexpectedly failed: {error}" + )), + } + + for (case, value, range, baseline_at_bottom) in [ + ("positive-only", 15.0, [10.0, 20.0], true), + ("negative-only", -15.0, [-20.0, -10.0], false), + ] { + let rows = vec![serde_json::json!({"category": "A", "value": value})]; + match bar_svg(&rows, None, Some(range)) { + Ok(svg) => { + let Some((plot_top, plot_bottom)) = svg_plot_y_bounds(&svg) else { + failures.push(format!("{case}: could not locate SVG plot bounds")); + continue; + }; + let Some(bar) = svg_rects_with_fill(&svg, "#1F77B4") + .into_iter() + .max_by_key(|rect| rect.width.saturating_mul(rect.height)) + else { + failures.push(format!("{case}: no bar rectangle was rendered")); + continue; + }; + let baseline = if baseline_at_bottom { + bar.y + bar.height + } else { + bar.y + }; + let expected = if baseline_at_bottom { + plot_bottom + } else { + plot_top + }; + if (baseline - expected).abs() > 2 { + failures.push(format!( + "{case}: explicit y_range {range:?} must anchor the bar at its nearer boundary {expected}, got rectangle {bar:?} within plot {plot_top}..{plot_bottom}" + )); + } + } + Err(error) => failures.push(format!("{case}: render unexpectedly failed: {error}")), + } + } + + for case in [ + "reversed-x", + "equal-y", + "nan-x", + "infinite-y", + "histogram-equal-x", + "finite-extreme-explicit-x", + "finite-extreme-explicit-y", + "line-auto-x-padding-overflow", + "line-auto-y-padding-overflow", + "bar-auto-y-padding-overflow", + "histogram-auto-padding-overflow", + ] { + record_bounded_range_case(&mut failures, case); + } + + assert!( + failures.is_empty(), + "bar range/category contract failures:\n{}", + failures.join("\n") + ); + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct SvgText { + x: i32, + y: i32, + text: String, + opening_tag: String, + } + + fn svg_text_elements(svg: &str) -> Vec { + let lines: Vec<_> = svg.lines().collect(); + let mut elements = Vec::new(); + let mut index = 0; + while index < lines.len() { + let opening = lines[index]; + if !opening.starts_with("") { + content.push(lines[index].trim()); + index += 1; + } + elements.push(SvgText { + x, + y, + text: content.join("\n"), + opening_tag: opening.to_string(), + }); + index += 1; + } + elements + } + + fn primary_bar_rects(svg: &str) -> Vec<(&str, SvgRect)> { + const FILLS: [&str; 8] = [ + "#1F77B4", "#FF7F0E", "#2CA02C", "#D62728", "#9467BD", "#8C564B", "#E377C2", "#7F7F7F", + ]; + let mut rectangles = Vec::new(); + for fill in FILLS { + rectangles.extend( + svg_rects_with_fill(svg, fill) + .into_iter() + .filter(|rect| rect.width > 0 && rect.height > 10) + .map(|rect| (fill, rect)), + ); + } + rectangles + } + + fn chart_svg_with_presentation( + rows: &[Value], + chart_type: ChartType, + series_column: Option<&str>, + label_points: bool, + y_range: Option<[f64; 2]>, + presentation: ChartPresentation, + ) -> Result { + let opts = ChartOptions { + chart_type, + x_column: Some("category".into()), + y_column: Some("value".into()), + series_column: series_column.map(str::to_string), + format: ChartFormat::Svg, + width: 520, + height: 360, + label_points, + y_range, + ..ChartOptions::default() + }; + render_chart_with_presentation(rows, &opts, presentation).and_then(|result| { + String::from_utf8(result.bytes).map_err(|error| { + McpError::new( + ErrorCode::InternalError, + format!("renderer returned non-UTF-8 SVG: {error}"), + ) + }) + }) + } + + /// Mutations caught: reordering categories or series, deduplicating rows, + /// filling absent category/series cells, clipping label text in the data + /// model, drawing horizontal bars on the old axes, or placing the first SQL + /// category at the bottom. + #[test] + fn horizontal_bar_layout_contract() { + let long_first = format!( + "First SQL category — 東京 — {}", + "wide ".repeat(14).trim_end() + ); + let rows = vec![ + serde_json::json!({"category": long_first, "value": 10, "series": "B"}), + serde_json::json!({"category": long_first, "value": 20, "series": "A"}), + serde_json::json!({"category": long_first, "value": 25, "series": "A"}), + serde_json::json!({"category": "Second category", "value": 30, "series": "A"}), + ]; + + // Characterize the existing grouping contract before asking the + // horizontal renderer to consume it. + let groups = group_series( + &rows, + "category", + "value", + Some("series"), + XMode::Categorical, + ) + .expect("legacy category grouping must succeed"); + assert_eq!( + groups.keys().map(String::as_str).collect::>(), + ["A", "B"] + ); + let a_points = &groups["A"]; + assert_eq!( + a_points + .iter() + .map(|(x, y, label)| (*x, *y, label.as_str())) + .collect::>(), + [ + (0.0, 20.0, long_first.as_str()), + (0.0, 25.0, long_first.as_str()), + (1.0, 30.0, "Second category"), + ], + "duplicate category+series rows must remain in input order" + ); + assert_eq!( + groups["B"].len(), + 1, + "missing B/second cell must remain a gap" + ); + assert_eq!( + collect_categories(&groups) + .into_iter() + .map(|(_, label)| label) + .collect::>(), + [long_first.clone(), "Second category".to_string()], + "categories must retain first-seen SQL order" + ); + assert_eq!( + series_color(0), + series_color(8), + "the eight-color palette must cycle" + ); + assert_ne!(series_color(0), series_color(1)); + + let mut failures = Vec::new(); + let horizontal = ChartPresentation { + bar_orientation: BarOrientation::Horizontal, + show_legend: false, + ..ChartPresentation::default() + }; + match chart_svg_with_presentation( + &rows, + ChartType::Bar, + Some("series"), + false, + None, + horizontal, + ) { + Ok(svg) => { + let texts = svg_text_elements(&svg); + let first = texts.iter().find(|element| element.text == long_first); + let second = texts + .iter() + .find(|element| element.text == "Second category"); + match (first, second) { + (Some(first), Some(second)) if first.y < second.y => {} + (Some(first), Some(second)) => failures.push(format!( + "first SQL category must be above the second: first={first:?}, second={second:?}" + )), + _ => failures.push(format!( + "horizontal SVG must retain full long/Unicode labels; texts={texts:?}" + )), + } + if !texts.iter().any(|element| { + element.text == "category" && element.opening_tag.contains("rotate(270") + }) || !texts.iter().any(|element| { + element.text == "value" && !element.opening_tag.contains("rotate(270") + }) { + failures.push( + "horizontal axes must describe category vertically and value horizontally" + .into(), + ); + } + if !svg.starts_with(" failures.push(format!("horizontal SVG render failed: {error}")), + } + + let one_row = vec![serde_json::json!({"category": "Only", "value": 7})]; + match chart_svg_with_presentation(&one_row, ChartType::Bar, None, false, None, horizontal) { + Ok(svg) if primary_bar_rects(&svg).len() == 1 => {} + Ok(svg) => failures.push(format!( + "one-category horizontal bar must render exactly one mark: {:?}", + primary_bar_rects(&svg) + )), + Err(error) => failures.push(format!( + "one-category horizontal bar unexpectedly failed: {error}" + )), + } + + assert!( + failures.is_empty(), + "horizontal bar layout failures:\n{}", + failures.join("\n") + ); + } + + /// A fixed-height horizontal chart must not emit one full SVG text/tick + /// node per SQL category. Twelve pixels is a conservative minimum pitch + /// for the renderer's roughly ten-pixel label font; using the full image + /// height (rather than only the smaller plot area) keeps this upper bound + /// intentionally generous. + #[test] + fn horizontal_bar_category_labels_are_pixel_bounded() { + const CATEGORY_COUNT: usize = 256; + const SVG_HEIGHT_PX: usize = 360; + const MIN_LABEL_PITCH_PX: usize = 12; + const LABEL_LIMIT: usize = SVG_HEIGHT_PX / MIN_LABEL_PITCH_PX + 2; + + let rows: Vec<_> = (0..CATEGORY_COUNT) + .map(|index| { + serde_json::json!({ + "category": format!("category-{index:03} — 東京 — {}", "wide".repeat(8)), + "value": index + 1, + }) + }) + .collect(); + let horizontal = ChartPresentation { + bar_orientation: BarOrientation::Horizontal, + show_legend: false, + ..ChartPresentation::default() + }; + let svg = chart_svg_with_presentation(&rows, ChartType::Bar, None, false, None, horizontal) + .expect("many-category horizontal SVG must render"); + let category_label_count = svg_text_elements(&svg) + .iter() + .filter(|element| element.text.starts_with("category-")) + .count(); + assert!( + category_label_count <= LABEL_LIMIT, + "fixed {SVG_HEIGHT_PX}px horizontal SVG emitted {category_label_count} full category labels for {CATEGORY_COUNT} rows; pixel-derived upper bound is {LABEL_LIMIT}" + ); + } + + /// Mutations caught: changing the legacy legend default, drawing a legend + /// when suppressed (including `label_points`), formatting values from the + /// converted f64 instead of their original scalar, or implementing SVG but + /// omitting the horizontal PNG backend. + #[test] + fn legend_and_value_label_contract() { + let rows = vec![ + serde_json::json!({"category": "北", "value": 12345, "series": "Series α"}), + serde_json::json!({"category": "南", "value": -678, "series": "Series β"}), + ]; + let mut failures = Vec::new(); + + match chart_svg_with_presentation( + &rows, + ChartType::Bar, + Some("series"), + false, + Some([-1000.0, 20_000.0]), + ChartPresentation::default(), + ) { + Ok(svg) + if svg.contains("Series α") + && svg.contains("Series β") + && svg.contains("opacity=\"0.9\" fill=\"#FFFFFF\"") => {} + Ok(_) => failures.push("legacy/default bar presentation must keep its legend".into()), + Err(error) => failures.push(format!("legacy/default SVG render failed: {error}")), + } + + let labels_without_legend = ChartPresentation { + label_values: true, + show_legend: false, + ..ChartPresentation::default() + }; + match chart_svg_with_presentation( + &rows, + ChartType::Bar, + Some("series"), + false, + Some([-1000.0, 20_000.0]), + labels_without_legend, + ) { + Ok(svg) => { + let texts = svg_text_elements(&svg); + for exact in ["12345", "-678", "北", "南"] { + if !texts.iter().any(|element| element.text == exact) { + failures.push(format!( + "value/category scalar {exact:?} must survive unchanged in SVG text" + )); + } + } + if svg.contains("Series α") + || svg.contains("Series β") + || svg.contains("opacity=\"0.9\" fill=\"#FFFFFF\"") + { + failures.push("show_legend:false must suppress the complete bar legend".into()); + } + } + Err(error) => failures.push(format!( + "bar value-label/legend-suppression render failed: {error}" + )), + } + + for chart_type in [ChartType::Line, ChartType::Scatter] { + let numeric_rows = vec![ + serde_json::json!({"category": 1, "value": 2, "series": "Hidden α"}), + serde_json::json!({"category": 2, "value": 3, "series": "Hidden β"}), + ]; + let presentation = ChartPresentation { + show_legend: false, + ..ChartPresentation::default() + }; + match chart_svg_with_presentation( + &numeric_rows, + chart_type, + Some("series"), + false, + None, + presentation, + ) { + Ok(svg) + if !svg.contains("Hidden α") + && !svg.contains("Hidden β") + && !svg.contains("opacity=\"0.9\" fill=\"#FFFFFF\"") => {} + Ok(_) => failures.push(format!( + "show_legend:false must suppress the {chart_type:?} legend" + )), + Err(error) => failures.push(format!( + "show_legend:false {chart_type:?} render failed: {error}" + )), + } + } + + let point_label_rows = vec![ + serde_json::json!({"category": 1, "value": 2, "series": "Point α"}), + serde_json::json!({"category": 2, "value": 3, "series": "Point β"}), + ]; + match chart_svg_with_presentation( + &point_label_rows, + ChartType::Line, + Some("series"), + true, + None, + ChartPresentation::default(), + ) { + Ok(svg) if !svg.contains("opacity=\"0.9\" fill=\"#FFFFFF\"") => {} + Ok(_) => failures.push("label_points:true must suppress the line legend".into()), + Err(error) => failures.push(format!("point-label SVG render failed: {error}")), + } + + let png_opts = ChartOptions { + chart_type: ChartType::Bar, + x_column: Some("category".into()), + y_column: Some("value".into()), + series_column: Some("series".into()), + format: ChartFormat::Png, + width: 360, + height: 260, + y_range: Some([-1000.0, 20_000.0]), + ..ChartOptions::default() + }; + let horizontal_png = ChartPresentation { + bar_orientation: BarOrientation::Horizontal, + label_values: true, + show_legend: false, + y_scale: MeasureScale::Linear, + }; + match render_chart_with_presentation(&rows, &png_opts, horizontal_png) { + Ok(result) + if result.mime_type == "image/png" + && result + .bytes + .starts_with(&[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) => {} + Ok(result) => failures.push(format!( + "horizontal PNG must preserve PNG MIME/magic, got {} and {:?}", + result.mime_type, + result.bytes.get(..8) + )), + Err(error) => failures.push(format!("horizontal PNG render failed: {error}")), + } + + assert!( + failures.is_empty(), + "legend/value-label failures:\n{}", + failures.join("\n") + ); + } + + fn close_enough(actual: f64, expected: f64) -> bool { + let scale = expected.abs().max(1.0); + (actual - expected).abs() <= scale * 1e-12 + } + + /// Mutations caught: computing padding in linear space, collapsing an + /// equal-value domain, flushing the minimum subnormal to zero, overflowing + /// the maximum finite bound, and accepting a non-positive/non-containing + /// explicit logarithmic range. + #[test] + fn log_range_handles_finite_extremes() { + let mut failures = Vec::new(); + + match log_measure_range(&[1.0, 100.0], None) { + Ok((lo, hi)) + if close_enough(lo, 0.794_328_234_724_281_5) + && close_enough(hi, 125.892_541_179_416_75) => {} + Ok(actual) => failures.push(format!( + "1..100 auto log range must apply five-percent ln-span padding, got {actual:?}" + )), + Err(error) => failures.push(format!("ordinary auto log range failed: {error}")), + } + + match log_measure_range(&[10.0, 10.0], None) { + Ok((lo, hi)) + if close_enough(lo, 8.912_509_381_337_454) + && close_enough(hi, 11.220_184_543_019_634) => {} + Ok(actual) => failures.push(format!( + "repeated value must use a fixed five-percent decade pad, got {actual:?}" + )), + Err(error) => failures.push(format!("repeated-value log range failed: {error}")), + } + + let smallest = f64::from_bits(1); + match log_measure_range(&[smallest], None) { + Ok((lo, hi)) + if lo.to_bits() == smallest.to_bits() + && hi.is_finite() + && hi.is_sign_positive() + && hi.to_bits() > smallest.to_bits() => {} + Ok(actual) => failures.push(format!( + "minimum-subnormal range must retain the value and expand on the available side, got {actual:?}" + )), + Err(error) => failures.push(format!("minimum-subnormal log range failed: {error}")), + } + + match log_measure_range(&[f64::MAX], None) { + Ok((lo, hi)) + if hi.to_bits() == f64::MAX.to_bits() + && lo.is_finite() + && lo > 0.0 + && lo < hi => {} + Ok(actual) => failures.push(format!( + "maximum-finite range must retain the value and expand on the available side, got {actual:?}" + )), + Err(error) => failures.push(format!("maximum-finite log range failed: {error}")), + } + + match log_measure_range(&[smallest, f64::MAX], None) { + Ok((lo, hi)) + if lo.to_bits() == smallest.to_bits() + && hi.to_bits() == f64::MAX.to_bits() + && lo < hi => {} + Ok(actual) => failures.push(format!( + "full finite-positive domain must clamp without excluding either extreme, got {actual:?}" + )), + Err(error) => failures.push(format!("full-domain log range failed: {error}")), + } + + match log_measure_range(&[10.0, 100.0], Some([1.0, 1000.0])) { + Ok((lo, hi)) + if lo.to_bits() == 1.0_f64.to_bits() && hi.to_bits() == 1000.0_f64.to_bits() => {} + Ok(actual) => failures.push(format!( + "valid explicit log range must be preserved exactly, got {actual:?}" + )), + Err(error) => failures.push(format!("valid explicit log range failed: {error}")), + } + + for (case, values, explicit) in [ + ("zero", vec![0.0], None), + ("negative", vec![-1.0], None), + ("mixed sign", vec![-1.0, 1.0], None), + ("NaN", vec![f64::NAN], None), + ("infinity", vec![f64::INFINITY], None), + ("reversed explicit", vec![10.0], Some([100.0, 1.0])), + ("equal explicit", vec![10.0], Some([10.0, 10.0])), + ("zero explicit", vec![10.0], Some([0.0, 100.0])), + ( + "non-finite explicit", + vec![10.0], + Some([1.0, f64::INFINITY]), + ), + ( + "explicit excludes low value", + vec![10.0, 100.0], + Some([20.0, 200.0]), + ), + ( + "explicit excludes high value", + vec![10.0, 100.0], + Some([1.0, 50.0]), + ), + ] { + match log_measure_range(&values, explicit) { + Err(error) if error.code == ErrorCode::InvalidArgument => {} + Err(error) => failures.push(format!( + "{case}: expected InvalidArgument, got {:?}: {}", + error.code, error.message + )), + Ok(range) => failures.push(format!( + "{case}: invalid log domain was accepted as {range:?}" + )), + } + } + + assert!( + failures.is_empty(), + "log range failures:\n{}", + failures.join("\n") + ); + } + + fn svg_plot_x_bounds(svg: &str) -> Option<(i32, i32)> { + svg.lines() + .filter(|line| line.starts_with("().ok()?, y.parse::().ok()?)) + }); + let first = coords.next()?; + let second = coords.next()?; + (first.1 == second.1).then_some((first.0.min(second.0), first.0.max(second.0))) + }) + .max_by_key(|(left, right)| right - left) + } + + fn log_presentation(bar_orientation: BarOrientation) -> ChartPresentation { + ChartPresentation { + bar_orientation, + y_scale: MeasureScale::Log, + ..ChartPresentation::default() + } + } + + const LOG_RENDERING_CHILD_ENV: &str = "HYPERDB_MCP_LOG_RENDERING_CHILD"; + const LOG_RENDERING_AGGREGATE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(28); + + fn assert_full_domain_log_semantics() { + let mut failures = Vec::new(); + let rows = vec![ + serde_json::json!({"category": "smallest positive", "value": f64::from_bits(1)}), + serde_json::json!({"category": "one", "value": 1.0}), + serde_json::json!({"category": "largest finite", "value": f64::MAX}), + ]; + match chart_svg_with_presentation( + &rows, + ChartType::Bar, + None, + false, + None, + log_presentation(BarOrientation::Vertical), + ) { + Ok(svg) => { + let texts = svg_text_elements(&svg); + for expected in ["smallest positive", "one", "largest finite"] { + if !texts.iter().any(|element| element.text == expected) { + failures.push(format!( + "full-domain log chart must retain category label {expected:?}" + )); + } + } + + let category_desc = texts.iter().find(|element| { + element.text == "category" && !element.opening_tag.contains("rotate(270") + }); + let value_desc = texts.iter().find(|element| { + element.text == "value" && element.opening_tag.contains("rotate(270") + }); + if category_desc.is_none() || value_desc.is_none() { + failures.push(format!( + "full-domain vertical log chart must render category-x/value-y axis descriptions, got category={category_desc:?}, value={value_desc:?}" + )); + } + + let measure_ticks: Vec<_> = texts + .iter() + .filter_map(|element| element.text.parse::().ok()) + .filter(|value| value.is_finite() && *value > 0.0) + .collect(); + if measure_ticks.len() < 2 { + failures.push(format!( + "full-domain log chart must retain normal positive measure-scale tick labels, got {measure_ticks:?}" + )); + } + + let mut heights: Vec<_> = primary_bar_rects(&svg) + .into_iter() + .map(|(_, rect)| rect.height) + .collect(); + heights.sort_unstable(); + match heights.as_slice() { + [middle, maximum] if *middle > 0 => { + let ratio = f64::from(*maximum) / f64::from(*middle); + if !(1.7..=2.3).contains(&ratio) { + failures.push(format!( + "full-domain log geometry must place 1.0 near the logarithmic midpoint between the minimum subnormal and f64::MAX; got heights {heights:?}, ratio={ratio}" + )); + } + } + _ => failures.push(format!( + "full-domain log chart must contain visible midpoint and maximum bars, got heights {heights:?}" + )), + } + } + Err(error) => failures.push(format!("full-domain semantic log SVG failed: {error}")), + } + + assert!( + failures.is_empty(), + "full-domain log semantic failures:\n{}", + failures.join("\n") + ); + } + + fn assert_explicit_adjacent_high_log_rejected() { + let lower = f64::from_bits(f64::MAX.to_bits() - 1); + let rows = vec![ + serde_json::json!({"category": "lower", "value": lower}), + serde_json::json!({"category": "maximum", "value": f64::MAX}), + ]; + match chart_svg_with_presentation( + &rows, + ChartType::Bar, + None, + false, + Some([lower, f64::MAX]), + log_presentation(BarOrientation::Vertical), + ) { + Err(error) if error.code == ErrorCode::InvalidArgument => {} + Err(error) => panic!( + "adjacent high-end explicit log range must return InvalidArgument, got {:?}: {}", + error.code, error.message + ), + Ok(_) => panic!( + "adjacent high-end explicit log endpoints with equal logarithms were accepted" + ), + } + } + + fn assert_auto_adjacent_high_log_expands() { + let lower = f64::from_bits(f64::MAX.to_bits() - 1); + match log_measure_range(&[lower, f64::MAX], None) { + Ok((lo, hi)) + if lo <= lower + && hi >= f64::MAX + && lo.ln().is_finite() + && hi.ln().is_finite() + && lo.ln() < hi.ln() => {} + Ok(range) => panic!( + "automatic adjacent high-end values must expand to distinct finite logarithmic endpoints without exclusion, got {range:?} with logs {:?}", + (range.0.ln(), range.1.ln()) + ), + Err(error) => panic!( + "automatic adjacent high-end values must follow repeated-value expansion policy, got {:?}: {}", + error.code, error.message + ), + } + + let rows = vec![ + serde_json::json!({"category": "lower", "value": lower}), + serde_json::json!({"category": "maximum", "value": f64::MAX}), + ]; + match chart_svg_with_presentation( + &rows, + ChartType::Bar, + None, + false, + None, + log_presentation(BarOrientation::Vertical), + ) { + Ok(svg) if svg.starts_with(" {} + Ok(_) => panic!("automatic adjacent high-end log render returned malformed SVG"), + Err(error) => panic!("automatic adjacent high-end log render failed: {error}"), + } + } + + fn assert_finite_extremes_line_renders() { + let rows = vec![ + serde_json::json!({"category": 1, "value": f64::from_bits(1)}), + serde_json::json!({"category": 2, "value": f64::MAX}), + ]; + match chart_svg_with_presentation( + &rows, + ChartType::Line, + None, + false, + None, + log_presentation(BarOrientation::Vertical), + ) { + Ok(svg) if svg.starts_with(" {} + Ok(_) => panic!("finite extremes: renderer returned malformed SVG"), + Err(error) => panic!("finite extremes: log SVG failed: {error}"), + } + } + + fn run_log_rendering_child_case(case: &str) { + match case { + "finite-extremes-line" => assert_finite_extremes_line_renders(), + "full-domain-semantics" => assert_full_domain_log_semantics(), + "explicit-adjacent-high" => assert_explicit_adjacent_high_log_rejected(), + "auto-adjacent-high" => assert_auto_adjacent_high_log_expands(), + other => panic!("unknown bounded log-rendering case {other}"), + } + } + + fn record_bounded_log_rendering_case( + failures: &mut Vec, + case: &str, + aggregate_deadline: std::time::Instant, + ) { + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + if Instant::now() >= aggregate_deadline { + failures.push(format!( + "{case}: shared {}s log-rendering deadline elapsed before child launch", + LOG_RENDERING_AGGREGATE_TIMEOUT.as_secs() + )); + return; + } + + let mut child = Command::new(std::env::current_exe().expect("unit test executable path")) + .args([ + "--exact", + "chart::tests::log_rendering_contract", + "--nocapture", + ]) + .env(LOG_RENDERING_CHILD_ENV, case) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("log rendering parent must spawn its exact helper child"); + + loop { + match child.try_wait() { + Ok(Some(status)) => { + let output = child + .wait_with_output() + .expect("log rendering parent must collect child output"); + if !status.success() { + failures.push(format!( + "{case}: child failed with {status}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + return; + } + Ok(None) if Instant::now() < aggregate_deadline => { + std::thread::sleep(Duration::from_millis(20)); + } + Ok(None) => { + let kill_error = child.kill().err(); + let output = child + .wait_with_output() + .expect("log rendering parent must wait for timed-out child"); + failures.push(format!( + "{case}: renderer exceeded the shared {}s log-rendering deadline and was killed ({kill_error:?})\nstdout:\n{}\nstderr:\n{}", + LOG_RENDERING_AGGREGATE_TIMEOUT.as_secs(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + return; + } + Err(error) => { + let _ = child.kill(); + let output = child + .wait_with_output() + .expect("log rendering parent must wait after status error"); + failures.push(format!( + "{case}: child status failed: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + return; + } + } + } + } + + /// Mutations caught: applying log to the physical rather than data-role y + /// axis, starting log bars at numeric zero, inverted rectangles, omitting a + /// backend/orientation branch, silently filtering invalid values, and + /// permitting log histograms or ranges that exclude plotted data. + #[test] + fn log_rendering_contract() { + if let Ok(case) = std::env::var(LOG_RENDERING_CHILD_ENV) { + run_log_rendering_child_case(&case); + return; + } + + let mut failures = Vec::new(); + let bar_rows = vec![ + serde_json::json!({"category": "First", "value": 10.0}), + serde_json::json!({"category": "Second", "value": 100.0}), + ]; + + match chart_svg_with_presentation( + &bar_rows, + ChartType::Bar, + None, + false, + Some([1.0, 1000.0]), + log_presentation(BarOrientation::Vertical), + ) { + Ok(svg) => { + if let Some((_, plot_bottom)) = svg_plot_y_bounds(&svg) { + let bars = primary_bar_rects(&svg); + if bars.len() != 2 + || bars.iter().any(|(_, rect)| { + rect.width <= 0 + || rect.height <= 0 + || (rect.y + rect.height - plot_bottom).abs() > 2 + }) + { + failures.push(format!( + "vertical log bars must be non-inverted and start at the positive lower bound {plot_bottom}: {bars:?}" + )); + } + } else { + failures.push("vertical log bar SVG is missing plot bounds".into()); + } + } + Err(error) => failures.push(format!("vertical log bar SVG failed: {error}")), + } + + match chart_svg_with_presentation( + &bar_rows, + ChartType::Bar, + None, + false, + Some([1.0, 1000.0]), + log_presentation(BarOrientation::Horizontal), + ) { + Ok(svg) => { + if let Some((plot_left, _)) = svg_plot_x_bounds(&svg) { + let bars = primary_bar_rects(&svg); + if bars.len() != 2 + || bars.iter().any(|(_, rect)| { + rect.width <= 0 || rect.height <= 0 || (rect.x - plot_left).abs() > 2 + }) + { + failures.push(format!( + "horizontal log bars must map data-role y to physical x and start at positive lower bound {plot_left}: {bars:?}" + )); + } + } else { + failures.push("horizontal log bar SVG is missing plot bounds".into()); + } + } + Err(error) => failures.push(format!("horizontal log bar SVG failed: {error}")), + } + + for (case, chart_type, rows) in [ + ( + "positive line", + ChartType::Line, + vec![ + serde_json::json!({"category": 1, "value": 1.0}), + serde_json::json!({"category": 2, "value": 10.0}), + serde_json::json!({"category": 3, "value": 100.0}), + ], + ), + ( + "positive scatter", + ChartType::Scatter, + vec![ + serde_json::json!({"category": 1, "value": 1.0}), + serde_json::json!({"category": 2, "value": 10.0}), + ], + ), + ( + "repeated value", + ChartType::Line, + vec![ + serde_json::json!({"category": 1, "value": 10.0}), + serde_json::json!({"category": 2, "value": 10.0}), + ], + ), + ] { + match chart_svg_with_presentation( + &rows, + chart_type, + None, + false, + None, + log_presentation(BarOrientation::Vertical), + ) { + Ok(svg) if svg.starts_with(" {} + Ok(_) => failures.push(format!("{case}: renderer returned malformed SVG")), + Err(error) => failures.push(format!("{case}: log SVG failed: {error}")), + } + } + + let aggregate_deadline = std::time::Instant::now() + LOG_RENDERING_AGGREGATE_TIMEOUT; + for case in [ + "finite-extremes-line", + "full-domain-semantics", + "explicit-adjacent-high", + "auto-adjacent-high", + ] { + record_bounded_log_rendering_case(&mut failures, case, aggregate_deadline); + } + + for orientation in [BarOrientation::Vertical, BarOrientation::Horizontal] { + let opts = ChartOptions { + chart_type: ChartType::Bar, + x_column: Some("category".into()), + y_column: Some("value".into()), + format: ChartFormat::Png, + width: 400, + height: 300, + y_range: Some([1.0, 1000.0]), + ..ChartOptions::default() + }; + match render_chart_with_presentation(&bar_rows, &opts, log_presentation(orientation)) { + Ok(result) + if result.mime_type == "image/png" + && result + .bytes + .starts_with(&[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) => {} + Ok(result) => failures.push(format!( + "{orientation:?} log PNG has wrong MIME/magic: {}, {:?}", + result.mime_type, + result.bytes.get(..8) + )), + Err(error) => failures.push(format!("{orientation:?} log PNG failed: {error}")), + } + } + + for (case, chart_type, rows, range) in [ + ( + "zero", + ChartType::Bar, + vec![serde_json::json!({"category": "A", "value": 0.0})], + None, + ), + ( + "negative", + ChartType::Line, + vec![serde_json::json!({"category": 1, "value": -1.0})], + None, + ), + ( + "mixed sign", + ChartType::Scatter, + vec![ + serde_json::json!({"category": 1, "value": -1.0}), + serde_json::json!({"category": 2, "value": 1.0}), + ], + None, + ), + ( + "histogram", + ChartType::Histogram, + vec![serde_json::json!({"category": 1.0, "value": 10.0})], + None, + ), + ( + "range excludes value", + ChartType::Bar, + bar_rows.clone(), + Some([20.0, 200.0]), + ), + ( + "non-positive range", + ChartType::Bar, + bar_rows.clone(), + Some([0.0, 200.0]), + ), + ( + "reversed range", + ChartType::Bar, + bar_rows.clone(), + Some([200.0, 1.0]), + ), + ] { + let opts = ChartOptions { + chart_type, + x_column: Some("category".into()), + y_column: Some("value".into()), + format: ChartFormat::Svg, + y_range: range, + ..ChartOptions::default() + }; + match render_chart_with_presentation( + &rows, + &opts, + log_presentation(BarOrientation::Vertical), + ) { + Err(error) if error.code == ErrorCode::InvalidArgument => {} + Err(error) => failures.push(format!( + "{case}: expected InvalidArgument, got {:?}: {}", + error.code, error.message + )), + Ok(_) => failures.push(format!("{case}: invalid log chart rendered successfully")), + } + } + + assert!( + failures.is_empty(), + "log rendering failures:\n{}", + failures.join("\n") + ); + } } diff --git a/hyperdb-mcp/src/daemon/discovery.rs b/hyperdb-mcp/src/daemon/discovery.rs index 27c647d..f6406f7 100644 --- a/hyperdb-mcp/src/daemon/discovery.rs +++ b/hyperdb-mcp/src/daemon/discovery.rs @@ -7,14 +7,16 @@ //! PID and the `hyperd` endpoint. Clients read this file to locate the running //! daemon, validating liveness via a TCP health check before trusting it. -use std::io; -use std::path::PathBuf; +use std::io::{self, Read as _}; +use std::path::{Path, PathBuf}; use std::time::Duration; use serde::{Deserialize, Serialize}; use super::{DAEMON_PORT_SCAN_SPAN, DEFAULT_DAEMON_BASE_PORT}; +const MAX_DISCOVERY_FILE_BYTES: usize = 64 * 1024; + /// Information written by the daemon so clients can discover and connect. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonInfo { @@ -30,6 +32,181 @@ pub struct DaemonInfo { pub version: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct DaemonBuildIdentity { + mcp_version: String, + executable_path: crate::diagnostics::ReportedPath, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct DaemonRecord { + #[serde(flatten)] + info: DaemonInfo, + #[serde(default, skip_serializing_if = "Option::is_none")] + identity: Option, +} + +impl DaemonRecord { + pub(super) fn with_current_identity(info: &DaemonInfo) -> io::Result { + let executable = std::env::current_exe()?; + Ok(Self { + info: info.clone(), + identity: Some(DaemonBuildIdentity { + mcp_version: crate::version::mcp_version_string(), + executable_path: crate::diagnostics::ReportedPath::from_os_str( + executable.as_os_str(), + ), + }), + }) + } + + pub(crate) fn info(&self) -> &DaemonInfo { + &self.info + } + + pub(crate) fn identity(&self) -> Option<&DaemonBuildIdentity> { + self.identity.as_ref() + } +} + +impl DaemonBuildIdentity { + pub(crate) fn mcp_version(&self) -> &str { + &self.mcp_version + } + + pub(crate) fn executable_path(&self) -> &crate::diagnostics::ReportedPath { + &self.executable_path + } +} + +#[derive(Debug)] +pub(crate) enum RawDiscoveryRead { + Missing { + path: crate::diagnostics::ReportedPath, + }, + Unreadable { + path: crate::diagnostics::ReportedPath, + kind: io::ErrorKind, + }, + Malformed { + path: crate::diagnostics::ReportedPath, + }, + Parsed { + path: crate::diagnostics::ReportedPath, + record: DaemonRecord, + }, +} + +pub(crate) fn read_discovery_file_raw(path: &Path) -> RawDiscoveryRead { + let reported_path = crate::diagnostics::ReportedPath::from_os_str(path.as_os_str()); + let file = match open_discovery_file(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return RawDiscoveryRead::Missing { + path: reported_path, + }; + } + Err(error) => { + return RawDiscoveryRead::Unreadable { + path: reported_path, + kind: error.kind(), + }; + } + }; + + let is_regular_file = match file.metadata() { + Ok(metadata) => metadata.file_type().is_file(), + Err(error) => { + return RawDiscoveryRead::Unreadable { + path: reported_path, + kind: error.kind(), + }; + } + }; + if !is_regular_file { + return RawDiscoveryRead::Unreadable { + path: reported_path, + kind: io::ErrorKind::InvalidInput, + }; + } + + let mut contents = Vec::with_capacity(MAX_DISCOVERY_FILE_BYTES + 1); + let read_limit = u64::try_from(MAX_DISCOVERY_FILE_BYTES + 1).unwrap_or(u64::MAX); + if let Err(error) = file.take(read_limit).read_to_end(&mut contents) { + return RawDiscoveryRead::Unreadable { + path: reported_path, + kind: error.kind(), + }; + } + if contents.len() > MAX_DISCOVERY_FILE_BYTES { + return RawDiscoveryRead::Malformed { + path: reported_path, + }; + } + + parse_discovery_contents(reported_path, &contents) +} + +fn read_discovery_file_legacy(path: &Path) -> RawDiscoveryRead { + let reported_path = crate::diagnostics::ReportedPath::from_os_str(path.as_os_str()); + let contents = match std::fs::read(path) { + Ok(contents) => contents, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return RawDiscoveryRead::Missing { + path: reported_path, + }; + } + Err(error) => { + return RawDiscoveryRead::Unreadable { + path: reported_path, + kind: error.kind(), + }; + } + }; + parse_discovery_contents(reported_path, &contents) +} + +fn parse_discovery_contents( + reported_path: crate::diagnostics::ReportedPath, + contents: &[u8], +) -> RawDiscoveryRead { + match serde_json::from_slice(contents) { + Ok(record) => RawDiscoveryRead::Parsed { + path: reported_path, + record, + }, + Err(_) => RawDiscoveryRead::Malformed { + path: reported_path, + }, + } +} + +fn open_discovery_file(path: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + + // `O_NONBLOCK` makes FIFO/device rejection prompt, while `O_NOFOLLOW` + // prevents a symlink swap from turning the checked input into a + // blocking special file between path inspection and open. + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(path) + } + #[cfg(not(unix))] + { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "daemon discovery source is not a regular file", + )); + } + std::fs::File::open(path) + } +} + /// Returns the directory used for daemon state files. /// /// Resolution order: @@ -61,12 +238,21 @@ pub fn discovery_file_path() -> io::Result { /// # Errors /// Returns an error if the state directory cannot be created or the file cannot be written. pub fn write_discovery_file(info: &DaemonInfo) -> io::Result<()> { + write_discovery_record(info) +} + +pub(super) fn write_enriched_discovery_file(info: &DaemonInfo) -> io::Result<()> { + let record = DaemonRecord::with_current_identity(info)?; + write_discovery_record(&record) +} + +fn write_discovery_record(record: &(impl Serialize + ?Sized)) -> io::Result<()> { let dir = state_dir()?; std::fs::create_dir_all(&dir)?; let path = dir.join("daemon.json"); let tmp_path = dir.join("daemon.json.tmp"); - let json = serde_json::to_string_pretty(info).map_err(|e| io::Error::other(e.to_string()))?; + let json = serde_json::to_string_pretty(record).map_err(|e| io::Error::other(e.to_string()))?; std::fs::write(&tmp_path, json.as_bytes())?; // On Windows, rename fails if target exists. Remove stale target first. let _ = std::fs::remove_file(&path); @@ -78,8 +264,29 @@ pub fn write_discovery_file(info: &DaemonInfo) -> io::Result<()> { /// Returns `None` if no daemon is running (file missing, stale, or unreachable). pub fn discover() -> Option { let path = discovery_file_path().ok()?; - let contents = std::fs::read_to_string(&path).ok()?; - let info: DaemonInfo = serde_json::from_str(&contents).ok()?; + // Preserve the historical client-discovery contract: normal discovery + // follows symlinks and accepts any valid record size. Doctor uses the + // separate bounded, no-follow raw reader above because it must never + // mutate or block on a special file. + let record = match read_discovery_file_legacy(&path) { + RawDiscoveryRead::Missing { path } => { + tracing::debug!(encoding = ?path.encoding, "daemon discovery file is missing"); + return None; + } + RawDiscoveryRead::Unreadable { path, kind } => { + tracing::debug!(?kind, encoding = ?path.encoding, "daemon discovery file is unreadable"); + return None; + } + RawDiscoveryRead::Malformed { path } => { + tracing::debug!(encoding = ?path.encoding, "daemon discovery file is malformed"); + return None; + } + RawDiscoveryRead::Parsed { path, record } => { + tracing::debug!(encoding = ?path.encoding, "daemon discovery file parsed"); + record + } + }; + let info = record.info().clone(); // Validate liveness by connecting to the health port if is_daemon_alive(info.health_port) { @@ -250,3 +457,759 @@ pub fn find_running_daemon() -> Option { _ => None, }) } + +#[cfg(test)] +mod tests { + use std::ffi::{OsStr, OsString}; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::sync::{Arc, Mutex}; + + use serde_json::json; + use tempfile::TempDir; + + use crate::daemon::health::{DaemonState, HealthListener}; + use crate::diagnostics::{PathEncoding, ReportedPath}; + + use super::*; + + fn legacy_info() -> DaemonInfo { + DaemonInfo { + pid: 4242, + hyperd_endpoint: "127.0.0.1:54321".to_string(), + health_port: 7485, + started_at: "2026-08-13T12:34:56Z".to_string(), + version: "0.7.0".to_string(), + } + } + + fn catch_serde(operation: impl FnOnce() -> serde_json::Result) -> Result { + catch_unwind(AssertUnwindSafe(operation)) + .map_err(|_| "operation panicked".to_string())? + .map_err(|error| error.to_string()) + } + + fn catch_raw_read(path: &Path) -> Result { + catch_unwind(AssertUnwindSafe(|| read_discovery_file_raw(path))) + .map_err(|_| "raw discovery read panicked".to_string()) + } + + fn directory_entries(path: &Path) -> Vec { + let mut entries = std::fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + entries.sort(); + entries + } + + fn run_discovery_compatibility_child(test_name: &str, child_sentinel_env: &str) { + use std::process::{Command, Stdio}; + use std::time::Instant; + + let tmp = TempDir::new().unwrap(); + let state_dir = tmp.path().join("state"); + let child_marker = tmp.path().join("child-started"); + let mut child = Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(test_name) + .arg("--nocapture") + .env(child_sentinel_env, &child_marker) + .env("HYPERDB_STATE_DIR", &state_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let timed_out = loop { + match child.try_wait() { + Ok(Some(_)) => break false, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => break true, + Err(error) => { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!( + "discovery compatibility child status failed: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + }; + + if timed_out { + let kill_error = child.kill().err(); + let output = child.wait_with_output().unwrap(); + panic!( + "discovery compatibility child exceeded 5s and was killed ({kill_error:?})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + let output = child.wait_with_output().unwrap(); + assert!( + child_marker.is_file(), + "exact discovery compatibility child branch did not start\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + output.status.success(), + "discovery compatibility child failed with {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn daemon_record_old_and_new_flat_wire_contract() { + let old_wire = json!({ + "pid": 4242, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7485, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0" + }); + let identity = DaemonBuildIdentity { + mcp_version: "0.7.0.rabc123".to_string(), + executable_path: ReportedPath::from_os_str(OsStr::new("/opt/hyperdb/bin/hyperdb-mcp")), + }; + let expected_new_wire = json!({ + "pid": 4242, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7485, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0", + "identity": { + "mcp_version": "0.7.0.rabc123", + "executable_path": { + "display": "/opt/hyperdb/bin/hyperdb-mcp", + "encoding": "utf8" + } + } + }); + let mut failures = Vec::new(); + + match catch_serde(|| serde_json::from_value::(old_wire.clone())) { + Ok(record) => { + if record.info != legacy_info() { + failures + .push("old flat JSON did not preserve legacy daemon fields".to_string()); + } + if record.identity.is_some() { + failures + .push("old flat JSON should deserialize with absent identity".to_string()); + } + match catch_serde(|| serde_json::to_value(&record)) { + Ok(round_trip) if round_trip == old_wire => {} + Ok(round_trip) => failures.push(format!( + "old record did not reserialize to the exact flat wire: {round_trip}" + )), + Err(error) => failures.push(format!( + "old record could not be reserialized after parsing: {error}" + )), + } + } + Err(error) => failures.push(format!("old flat JSON did not deserialize: {error}")), + } + + let new_record = DaemonRecord { + info: legacy_info(), + identity: Some(identity.clone()), + }; + match catch_serde(|| serde_json::to_value(&new_record)) { + Ok(new_wire) => { + if new_wire != expected_new_wire { + failures.push(format!( + "new record wire was not the exact additive flat shape: {new_wire}" + )); + } + if new_wire.get("info").is_some() { + failures.push("new wire nested legacy fields under `info`".to_string()); + } + + match catch_serde(|| serde_json::from_value::(new_wire.clone())) { + Ok(round_trip) => { + if round_trip.info != legacy_info() + || round_trip.identity.as_ref() != Some(&identity) + { + failures.push( + "new build/executable identity did not round-trip".to_string(), + ); + } + } + Err(error) => failures.push(format!( + "new build/executable identity could not be deserialized: {error}" + )), + } + + match serde_json::from_value::(new_wire) { + Ok(old_reader) if old_reader == legacy_info() => {} + Ok(old_reader) => failures.push(format!( + "old DaemonInfo reader changed legacy fields: {old_reader:?}" + )), + Err(error) => failures.push(format!( + "old DaemonInfo reader rejected additive identity: {error}" + )), + } + } + Err(error) => failures.push(format!("new record could not be serialized: {error}")), + } + + assert!( + failures.is_empty(), + "daemon record wire contract failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn raw_discovery_read_is_non_mutating_and_distinguishes_io() { + const SECRET_SENTINEL: &str = "RAW_DISCOVERY_SECRET_MUST_NOT_LEAK"; + + let tmp = TempDir::new().unwrap(); + let missing_path = tmp.path().join("missing.json"); + let unreadable_path = tmp.path().join("directory-not-file"); + std::fs::create_dir(&unreadable_path).unwrap(); + + let malformed_path = tmp.path().join("malformed.json"); + let malformed_bytes = format!("{{\"secret\":\"{SECRET_SENTINEL}\"").into_bytes(); + std::fs::write(&malformed_path, &malformed_bytes).unwrap(); + + let parsed_path = tmp.path().join("parsed.json"); + let parsed_bytes = serde_json::to_vec(&json!({ + "pid": 4242, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7485, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0" + })) + .unwrap(); + std::fs::write(&parsed_path, &parsed_bytes).unwrap(); + + let entries_before = directory_entries(tmp.path()); + let mut failures = Vec::new(); + + match catch_raw_read(&missing_path) { + Ok(RawDiscoveryRead::Missing { path }) + if path == ReportedPath::from_os_str(missing_path.as_os_str()) => {} + Ok(other) => failures.push(format!( + "missing path was not reported as Missing with its ReportedPath: {other:?}" + )), + Err(error) => failures.push(format!("missing path read failed: {error}")), + } + + match catch_raw_read(&unreadable_path) { + Ok(RawDiscoveryRead::Unreadable { path, kind }) => { + if path != ReportedPath::from_os_str(unreadable_path.as_os_str()) { + failures.push("unreadable path did not use ReportedPath".to_string()); + } + if kind == io::ErrorKind::NotFound { + failures.push("non-NotFound I/O was misclassified as missing".to_string()); + } + } + Ok(other) => failures.push(format!( + "directory read error was not distinguished as Unreadable: {other:?}" + )), + Err(error) => failures.push(format!("unreadable path read failed: {error}")), + } + + match catch_raw_read(&malformed_path) { + Ok(state) => { + if format!("{state:?}").contains(SECRET_SENTINEL) { + failures.push("malformed state leaked discovery contents".to_string()); + } + match state { + RawDiscoveryRead::Malformed { path } + if path == ReportedPath::from_os_str(malformed_path.as_os_str()) => {} + other => failures.push(format!( + "malformed JSON was not reported as Malformed with its ReportedPath: {other:?}" + )), + } + } + Err(error) => failures.push(format!("malformed path read failed: {error}")), + } + + match catch_raw_read(&parsed_path) { + Ok(RawDiscoveryRead::Parsed { path, record }) => { + if path != ReportedPath::from_os_str(parsed_path.as_os_str()) { + failures.push("parsed path did not use ReportedPath".to_string()); + } + if record.info != legacy_info() || record.identity.is_some() { + failures.push( + "old flat discovery JSON did not parse as a legacy record".to_string(), + ); + } + } + Ok(other) => failures.push(format!( + "valid old discovery JSON was not reported as Parsed: {other:?}" + )), + Err(error) => failures.push(format!("parsed path read failed: {error}")), + } + + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + let non_utf8_path = tmp + .path() + .join(OsString::from_vec(b"missing-\xff.json".to_vec())); + match catch_raw_read(&non_utf8_path) { + Ok(RawDiscoveryRead::Missing { path }) if path.encoding == PathEncoding::Lossy => {} + Ok(other) => failures.push(format!( + "non-UTF-8 path was not safely reported as lossy Missing: {other:?}" + )), + Err(error) => failures.push(format!("non-UTF-8 path read failed: {error}")), + } + } + + if missing_path.exists() { + failures.push("raw read created the missing discovery path".to_string()); + } + if !unreadable_path.is_dir() { + failures.push("raw read removed or replaced the unreadable path".to_string()); + } + match std::fs::read(&malformed_path) { + Ok(bytes) if bytes == malformed_bytes => {} + _ => failures.push("raw read changed or deleted malformed discovery bytes".to_string()), + } + match std::fs::read(&parsed_path) { + Ok(bytes) if bytes == parsed_bytes => {} + _ => failures.push("raw read changed or deleted parsed discovery bytes".to_string()), + } + if directory_entries(tmp.path()) != entries_before { + failures.push("raw read changed the discovery directory entries".to_string()); + } + + assert!( + failures.is_empty(), + "raw discovery read contract failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn raw_discovery_rejects_oversized_valid_json() { + const MAX_EXPECTED_DISCOVERY_BYTES: usize = 64 * 1024; + + let tmp = TempDir::new().unwrap(); + let base_bytes = serde_json::to_vec(&json!({ + "pid": 4242, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7485, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0", + "ignored_padding": "" + })) + .unwrap(); + let marker = b"\"ignored_padding\":\"\""; + let marker_start = base_bytes + .windows(marker.len()) + .position(|window| window == marker) + .unwrap(); + let padding_offset = marker_start + marker.len() - 1; + let sized_fixture = |target_len: usize| { + let mut bytes = base_bytes.clone(); + bytes.splice( + padding_offset..padding_offset, + vec![b'x'; target_len - bytes.len()], + ); + bytes + }; + let limit_bytes = sized_fixture(MAX_EXPECTED_DISCOVERY_BYTES); + let oversized_bytes = sized_fixture(MAX_EXPECTED_DISCOVERY_BYTES + 1); + let limit_path = tmp.path().join("limit-daemon.json"); + let oversized_path = tmp.path().join("oversized-daemon.json"); + std::fs::write(&limit_path, &limit_bytes).unwrap(); + std::fs::write(&oversized_path, &oversized_bytes).unwrap(); + + let mut failures = Vec::new(); + if serde_json::from_slice::(&limit_bytes).is_err() + || serde_json::from_slice::(&oversized_bytes).is_err() + { + failures.push("fixed-limit fixtures were not independently valid JSON".to_string()); + } + match read_discovery_file_raw(&limit_path) { + RawDiscoveryRead::Parsed { path: reported, .. } + if reported == ReportedPath::from_os_str(limit_path.as_os_str()) => {} + other => failures.push(format!( + "valid JSON at the exact fixed limit was not accepted: {other:?}" + )), + } + match read_discovery_file_raw(&oversized_path) { + RawDiscoveryRead::Malformed { path: reported } + if reported == ReportedPath::from_os_str(oversized_path.as_os_str()) => {} + other => failures.push(format!( + "oversized valid JSON was not rejected as malformed: {other:?}" + )), + } + for (label, path, expected) in [ + ("limit", &limit_path, &limit_bytes), + ("oversized", &oversized_path, &oversized_bytes), + ] { + match std::fs::read(path) { + Ok(after) if after == *expected => {} + Ok(_) => failures.push(format!("{label} discovery bytes were modified")), + Err(error) => failures.push(format!( + "{label} discovery file disappeared after raw read: {error}" + )), + } + } + + assert!( + failures.is_empty(), + "oversized raw discovery failures:\n{}", + failures.join("\n") + ); + } + + fn run_oversized_discovery_compatibility_scenario() { + const RAW_DOCTOR_LIMIT_BYTES: usize = 64 * 1024; + + assert!( + std::env::var_os("HYPERDB_STATE_DIR").is_some(), + "child scenario requires an isolated state directory" + ); + let health_listener = HealthListener::bind(0).unwrap(); + let health_port = health_listener.port; + let oversized_info = DaemonInfo { + pid: 5_252, + hyperd_endpoint: "127.0.0.1:54321".to_string(), + health_port, + started_at: "2026-08-13T12:34:56Z".to_string(), + version: "v".repeat(RAW_DOCTOR_LIMIT_BYTES + 1), + }; + write_discovery_file(&oversized_info).unwrap(); + let path = discovery_file_path().unwrap(); + let original_bytes = std::fs::read(&path).unwrap(); + let mut failures = Vec::new(); + + if original_bytes.len() <= RAW_DOCTOR_LIMIT_BYTES { + failures.push(format!( + "public writer produced only {} bytes, expected more than {RAW_DOCTOR_LIMIT_BYTES}", + original_bytes.len() + )); + } + match serde_json::from_slice::(&original_bytes) { + Ok(parsed) if parsed == oversized_info => {} + Ok(_) => { + failures.push("oversized public DaemonInfo did not round-trip exactly".to_string()); + } + Err(error) => failures.push(format!( + "public writer did not produce valid oversized DaemonInfo JSON: {error}" + )), + } + match read_discovery_file_raw(&path) { + RawDiscoveryRead::Malformed { path: reported } + if reported == ReportedPath::from_os_str(path.as_os_str()) => {} + RawDiscoveryRead::Missing { .. } => { + failures + .push("doctor raw reader reported the oversized record missing".to_string()); + } + RawDiscoveryRead::Unreadable { kind, .. } => failures.push(format!( + "doctor raw reader reported the oversized record unreadable: {kind:?}" + )), + RawDiscoveryRead::Malformed { .. } => { + failures.push("doctor raw reader reported the wrong oversized path".to_string()); + } + RawDiscoveryRead::Parsed { .. } => { + failures.push("doctor raw reader accepted the oversized record".to_string()); + } + } + match std::fs::read(&path) { + Ok(after) if after == original_bytes => {} + Ok(_) => failures.push("doctor raw reader changed oversized bytes".to_string()), + Err(error) => failures.push(format!( + "doctor raw reader removed the oversized record: {error}" + )), + } + + let health_state = Arc::new(DaemonState::new()); + let health_info = Arc::new(Mutex::new(oversized_info.clone())); + let run_state = Arc::clone(&health_state); + let run_info = Arc::clone(&health_info); + let health_server = std::thread::spawn(move || health_listener.run(run_state, run_info)); + + match discover() { + Some(info) if info == oversized_info => {} + Some(_) => { + failures + .push("normal discover returned different live oversized facts".to_string()); + } + None => failures + .push("normal discover did not accept the live oversized record".to_string()), + } + match std::fs::read(&path) { + Ok(after) if after == original_bytes => {} + Ok(_) => failures.push("live discover changed oversized bytes".to_string()), + Err(error) => failures.push(format!( + "live discover removed the oversized record: {error}" + )), + } + + health_state.request_shutdown(); + health_server.join().unwrap(); + if discover().is_some() { + failures.push("stopped oversized record was incorrectly retained as live".to_string()); + } + if path.exists() { + failures + .push("normal discover did not stale-clean the valid oversized record".to_string()); + } + + assert!( + failures.is_empty(), + "oversized legacy discover failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn discover_preserves_legacy_oversized_stale_cleanup() { + const CHILD_SENTINEL_ENV: &str = "HYPERDB_MCP_OVERSIZED_DISCOVERY_COMPATIBILITY_CHILD"; + const TEST_NAME: &str = + "daemon::discovery::tests::discover_preserves_legacy_oversized_stale_cleanup"; + + let _process_guard = crate::diagnostics::real_network_test_guard(); + if let Some(marker) = std::env::var_os(CHILD_SENTINEL_ENV) { + std::fs::write(std::path::PathBuf::from(marker), b"started").unwrap(); + run_oversized_discovery_compatibility_scenario(); + return; + } + run_discovery_compatibility_child(TEST_NAME, CHILD_SENTINEL_ENV); + } + + #[cfg(unix)] + fn run_symlink_discovery_compatibility_scenario() { + assert!( + std::env::var_os("HYPERDB_STATE_DIR").is_some(), + "child scenario requires an isolated state directory" + ); + let health_listener = HealthListener::bind(0).unwrap(); + let health_port = health_listener.port; + let mut linked_info = legacy_info(); + linked_info.pid = 6_363; + linked_info.health_port = health_port; + write_discovery_file(&linked_info).unwrap(); + let link_path = discovery_file_path().unwrap(); + let target_path = link_path.with_file_name("legacy-daemon-target.json"); + std::fs::rename(&link_path, &target_path).unwrap(); + std::os::unix::fs::symlink(&target_path, &link_path).unwrap(); + let target_bytes = std::fs::read(&target_path).unwrap(); + let mut failures = Vec::new(); + + match read_discovery_file_raw(&link_path) { + RawDiscoveryRead::Unreadable { + path: reported, + kind, + } if reported == ReportedPath::from_os_str(link_path.as_os_str()) + && kind != io::ErrorKind::NotFound => {} + other => failures.push(format!( + "doctor raw reader did not reject the symlink without following it: {other:?}" + )), + } + match std::fs::symlink_metadata(&link_path) { + Ok(metadata) if metadata.file_type().is_symlink() => {} + Ok(metadata) => failures.push(format!( + "doctor raw reader replaced the link with {:?}", + metadata.file_type() + )), + Err(error) => failures.push(format!( + "doctor raw reader removed the discovery symlink: {error}" + )), + } + match std::fs::read(&target_path) { + Ok(after) if after == target_bytes => {} + Ok(_) => failures.push("doctor raw reader changed symlink target bytes".to_string()), + Err(error) => failures.push(format!( + "doctor raw reader removed the symlink target: {error}" + )), + } + + let health_state = Arc::new(DaemonState::new()); + let health_info = Arc::new(Mutex::new(linked_info.clone())); + let run_state = Arc::clone(&health_state); + let run_info = Arc::clone(&health_info); + let health_server = std::thread::spawn(move || health_listener.run(run_state, run_info)); + + match discover() { + Some(info) if info == linked_info => {} + other => failures.push(format!( + "normal discover did not follow the live valid symlink: {other:?}" + )), + } + match std::fs::symlink_metadata(&link_path) { + Ok(metadata) if metadata.file_type().is_symlink() => {} + Ok(_) => failures.push("live discover replaced the discovery symlink".to_string()), + Err(error) => failures.push(format!( + "live discover removed the discovery symlink: {error}" + )), + } + + health_state.request_shutdown(); + health_server.join().unwrap(); + if discover().is_some() { + failures.push("stopped symlinked record was incorrectly retained as live".to_string()); + } + match std::fs::symlink_metadata(&link_path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Ok(_) => failures + .push("normal discover did not remove the stale discovery symlink".to_string()), + Err(error) => failures.push(format!( + "stale discovery symlink cleanup failed unexpectedly: {error}" + )), + } + match std::fs::read(&target_path) { + Ok(after) if after == target_bytes => {} + Ok(_) => failures.push("normal discover changed symlink target bytes".to_string()), + Err(error) => failures.push(format!( + "normal discover removed the symlink target instead of the link: {error}" + )), + } + + assert!( + failures.is_empty(), + "symlink legacy discover failures:\n{}", + failures.join("\n") + ); + } + + #[cfg(unix)] + #[test] + fn discover_preserves_legacy_symlink_stale_cleanup() { + const CHILD_SENTINEL_ENV: &str = "HYPERDB_MCP_SYMLINK_DISCOVERY_COMPATIBILITY_CHILD"; + const TEST_NAME: &str = + "daemon::discovery::tests::discover_preserves_legacy_symlink_stale_cleanup"; + + let _process_guard = crate::diagnostics::real_network_test_guard(); + if let Some(marker) = std::env::var_os(CHILD_SENTINEL_ENV) { + std::fs::write(std::path::PathBuf::from(marker), b"started").unwrap(); + run_symlink_discovery_compatibility_scenario(); + return; + } + run_discovery_compatibility_child(TEST_NAME, CHILD_SENTINEL_ENV); + } + + #[cfg(unix)] + #[test] + fn raw_discovery_rejects_fifo_without_blocking() { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt as _; + use std::os::unix::fs::FileTypeExt as _; + use std::path::PathBuf; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + const CHILD_PATH_ENV: &str = "HYPERDB_MCP_RAW_DISCOVERY_FIFO_CHILD"; + const CHILD_MARKER_ENV: &str = "HYPERDB_MCP_RAW_DISCOVERY_FIFO_MARKER"; + const TEST_NAME: &str = + "daemon::discovery::tests::raw_discovery_rejects_fifo_without_blocking"; + + if let Some(path) = std::env::var_os(CHILD_PATH_ENV) { + let path = PathBuf::from(path); + let marker = PathBuf::from( + std::env::var_os(CHILD_MARKER_ENV) + .expect("FIFO child marker path must accompany child path"), + ); + std::fs::write(marker, b"started").unwrap(); + let mut failures = Vec::new(); + match read_discovery_file_raw(&path) { + RawDiscoveryRead::Unreadable { kind, .. } if kind != io::ErrorKind::NotFound => {} + other => failures.push(format!( + "FIFO was not rejected as a non-NotFound unreadable discovery source: {other:?}" + )), + } + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_fifo() => {} + Ok(_) => { + failures.push("raw read replaced the FIFO with another file type".to_string()); + } + Err(error) => { + failures.push(format!("raw read removed the FIFO: {error}")); + } + } + assert!( + failures.is_empty(), + "FIFO child failures:\n{}", + failures.join("\n") + ); + return; + } + + let tmp = TempDir::new().unwrap(); + let fifo_path = tmp.path().join("daemon.fifo"); + let child_marker = tmp.path().join("child-started"); + let c_path = CString::new(fifo_path.as_os_str().as_bytes()).unwrap(); + // SAFETY: `c_path` is a live, NUL-terminated path and mode contains only + // ordinary permission bits. The return code is checked before use. + let result = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) }; + assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error()); + + let mut child = Command::new(std::env::current_exe().unwrap()) + .arg("--exact") + .arg(TEST_NAME) + .arg("--nocapture") + .env(CHILD_PATH_ENV, &fifo_path) + .env(CHILD_MARKER_ENV, &child_marker) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(2); + let child_status = loop { + match child.try_wait().unwrap() { + Some(status) => break Some(status), + None if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + None => { + child.kill().unwrap(); + child.wait().unwrap(); + break None; + } + } + }; + + let mut failures = Vec::new(); + match child_status { + Some(status) if status.success() => {} + Some(status) => failures.push(format!( + "bounded FIFO child rejected the contract with status {status}" + )), + None => failures.push( + "raw discovery read blocked on a FIFO past the two-second child watchdog" + .to_string(), + ), + } + match std::fs::read(&child_marker) { + Ok(marker) if marker == b"started" => {} + Ok(marker) => failures.push(format!( + "FIFO child wrote an unexpected start marker: {marker:?}" + )), + Err(error) => failures.push(format!( + "FIFO child never reached the raw reader; exact filter may be wrong: {error}" + )), + } + match std::fs::symlink_metadata(&fifo_path) { + Ok(metadata) if metadata.file_type().is_fifo() => {} + Ok(_) => { + failures.push("watchdog run replaced the FIFO with another file type".to_string()); + } + Err(error) => failures.push(format!("watchdog run removed the FIFO: {error}")), + } + + assert!( + failures.is_empty(), + "FIFO raw discovery failures:\n{}", + failures.join("\n") + ); + } +} diff --git a/hyperdb-mcp/src/daemon/health.rs b/hyperdb-mcp/src/daemon/health.rs index 88ad060..c6a3b20 100644 --- a/hyperdb-mcp/src/daemon/health.rs +++ b/hyperdb-mcp/src/daemon/health.rs @@ -17,7 +17,7 @@ //! - `REPORT_HYPERD_ERROR\n` → `OK\n` (sets the restart-requested flag — //! the monitor task picks it up on its next tick). -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -25,7 +25,7 @@ use std::time::{Duration, Instant}; use tracing::{debug, warn}; -use super::discovery::DaemonInfo; +use super::discovery::{DaemonInfo, DaemonRecord}; /// Identifying token included in PONG responses. Used to verify that a bound /// port is owned by a hyperdb-mcp daemon (not a foreign service). @@ -138,6 +138,13 @@ impl HealthListener { match self.listener.accept() { Ok((stream, _addr)) => { + if let Err(error) = stream.set_nonblocking(false) { + warn!( + error = %error, + "could not make accepted health connection blocking" + ); + continue; + } let state = Arc::clone(&state); let info = Arc::clone(&info); std::thread::spawn(move || { @@ -145,7 +152,12 @@ impl HealthListener { }); } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(100)); + // Poll tightly: the doctor network phase budgets only a + // few hundred ms for a STATUS round-trip, and on slow CI + // runners a 100ms idle sleep between accepts can push the + // accept past that window. 5ms keeps the listener + // responsive without meaningfully raising idle CPU. + std::thread::sleep(Duration::from_millis(5)); } Err(e) => { warn!(error = %e, "health listener accept error"); @@ -157,6 +169,17 @@ impl HealthListener { } } +fn status_json(info: &Mutex) -> String { + let snapshot = info.lock().expect("DaemonInfo mutex poisoned").clone(); + match DaemonRecord::with_current_identity(&snapshot) { + Ok(record) => serde_json::to_string(&record).unwrap_or_default(), + Err(error) => { + warn!(%error, "could not collect daemon executable identity for STATUS"); + serde_json::to_string(&snapshot).unwrap_or_default() + } + } +} + #[expect( clippy::needless_pass_by_value, reason = "TcpStream must be owned for BufReader" @@ -183,12 +206,7 @@ fn handle_client(stream: TcpStream, state: &DaemonState, info: &Mutex { - // Brief lock — only to clone the current snapshot. - let snapshot = info.lock().expect("DaemonInfo mutex poisoned").clone(); - let json = serde_json::to_string(&snapshot).unwrap_or_default(); - format!("{json}\n") - } + "STATUS" => format!("{}\n", status_json(info)), "REPORT_HYPERD_ERROR" => { state.request_restart(); "OK\n".to_string() @@ -225,10 +243,9 @@ pub fn send_command(port: u16, command: &str) -> std::io::Result { /// be dead from this client's perspective. Uses short timeouts (200ms each) so /// the calling tool handler isn't stalled if the daemon itself is slow. /// Errors are logged at debug level and otherwise ignored. -pub fn report_hyperd_error_to_daemon() { - let port = super::discovery::resolve_port(); +pub fn report_hyperd_error_to_daemon(health_port: u16) { let timeout = Duration::from_millis(200); - match send_command_with_timeout(port, "REPORT_HYPERD_ERROR", timeout, timeout) { + match send_command_with_timeout(health_port, "REPORT_HYPERD_ERROR", timeout, timeout) { Ok(response) => { debug!(response = %response.trim(), "reported hyperd error to daemon"); } @@ -238,7 +255,10 @@ pub fn report_hyperd_error_to_daemon() { } } -/// Send a command with caller-specified connect/read timeouts. +/// Send a command with caller-specified connect and I/O timeouts. +/// +/// The supplied `read_timeout` also bounds writes so every phase of the +/// request is finite without changing this helper's public signature. /// /// # Errors /// Returns an error if the connection fails or the response cannot be read @@ -251,16 +271,89 @@ pub fn send_command_with_timeout( ) -> std::io::Result { let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); let mut stream = TcpStream::connect_timeout(&addr, connect_timeout)?; - stream.set_read_timeout(Some(read_timeout))?; + let io_deadline = Instant::now().checked_add(read_timeout).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "health command I/O timeout overflows deadline", + ) + })?; let msg = format!("{command}\n"); - stream.write_all(msg.as_bytes())?; - stream.flush()?; + let mut written = 0; + while written < msg.len() { + stream.set_write_timeout(Some(remaining_io_time(io_deadline)?))?; + match stream.write(&msg.as_bytes()[written..]) { + Ok(0) => { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "health command write returned zero bytes", + )); + } + Ok(count) => { + written += count; + remaining_io_time(io_deadline)?; + } + Err(error) => return Err(normalize_expired_io_error(error, io_deadline)), + } + } - let mut reader = BufReader::new(&stream); - let mut response = String::new(); - reader.read_line(&mut response)?; - Ok(response) + const MAX_HEALTH_RESPONSE_BYTES: usize = 64 * 1024; + let mut response = Vec::new(); + loop { + stream.set_read_timeout(Some(remaining_io_time(io_deadline)?))?; + let mut byte = [0]; + match stream.read(&mut byte) { + Ok(0) => break, + Ok(_) if response.len() == MAX_HEALTH_RESPONSE_BYTES => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "health response exceeds the 64 KiB limit", + )); + } + Ok(_) => { + response.push(byte[0]); + remaining_io_time(io_deadline)?; + if byte[0] == b'\n' { + break; + } + } + Err(error) => return Err(normalize_expired_io_error(error, io_deadline)), + } + } + + String::from_utf8(response).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("health response is not valid UTF-8: {error}"), + ) + }) +} + +fn remaining_io_time(io_deadline: Instant) -> std::io::Result { + let remaining = io_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "health command I/O deadline expired", + )) + } else { + Ok(remaining) + } +} + +fn normalize_expired_io_error(error: std::io::Error, io_deadline: Instant) -> std::io::Error { + if matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) || Instant::now() >= io_deadline + { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "health command I/O deadline expired", + ) + } else { + error + } } /// Send PING and verify the response contains the identifying token. Returns @@ -290,3 +383,324 @@ pub fn ping_identified( // version (future-proofing for a token-only reply). Some(tokens.next().unwrap_or("").to_string()) } + +#[cfg(test)] +mod tests { + use std::panic::{catch_unwind, resume_unwind, AssertUnwindSafe}; + use std::sync::mpsc::{self, Receiver, Sender}; + use std::thread::JoinHandle; + + use serde_json::{json, Value}; + + use crate::diagnostics::ReportedPath; + + use super::*; + + struct TestPeer { + port: u16, + stop: Option>, + handle: Option>>, + } + + impl TestPeer { + fn finish(mut self) -> Result { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + self.handle + .take() + .expect("test peer handle must exist") + .join() + .map_err(|payload| format!("test peer panicked: {payload:?}"))? + } + } + + impl Drop for TestPeer { + fn drop(&mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + fn spawn_test_peer(script: F) -> TestPeer + where + T: Send + 'static, + F: FnOnce(TcpStream, Receiver<()>) -> Result + Send + 'static, + { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind test health peer"); + listener + .set_nonblocking(true) + .expect("make test health peer nonblocking"); + let port = listener.local_addr().expect("test peer address").port(); + let (stop_tx, stop_rx) = mpsc::channel(); + let handle = std::thread::spawn(move || { + let accept_deadline = Instant::now() + Duration::from_secs(2); + loop { + if stop_rx.try_recv().is_ok() { + return Err("test peer stopped before accepting a connection".to_string()); + } + match listener.accept() { + Ok((stream, _)) => { + stream.set_nonblocking(false).map_err(|error| { + format!("make accepted test health peer blocking: {error}") + })?; + return script(stream, stop_rx); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + return Err("test peer timed out waiting for a connection".to_string()); + } + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => return Err(format!("test peer accept failed: {error}")), + } + } + }); + + TestPeer { + port, + stop: Some(stop_tx), + handle: Some(handle), + } + } + + fn read_test_command(stream: &TcpStream) -> Result { + let reader_stream = stream + .try_clone() + .map_err(|error| format!("clone test peer stream: {error}"))?; + reader_stream + .set_read_timeout(Some(Duration::from_secs(1))) + .map_err(|error| format!("set test peer read timeout: {error}"))?; + let mut request = String::new(); + BufReader::new(reader_stream) + .read_line(&mut request) + .map_err(|error| format!("read test health command: {error}"))?; + Ok(request) + } + + fn daemon_info(health_port: u16) -> DaemonInfo { + DaemonInfo { + pid: 4242, + hyperd_endpoint: "127.0.0.1:54321".to_string(), + health_port, + started_at: "2026-08-13T12:34:56Z".to_string(), + version: "0.7.0".to_string(), + } + } + + fn expected_status(info: &DaemonInfo) -> Value { + let executable = std::env::current_exe().unwrap(); + let executable_path = ReportedPath::from_os_str(executable.as_os_str()); + + json!({ + "pid": info.pid, + "hyperd_endpoint": info.hyperd_endpoint, + "health_port": info.health_port, + "started_at": info.started_at, + "version": info.version, + "identity": { + "mcp_version": crate::version::mcp_version_string(), + "executable_path": executable_path + } + }) + } + + fn check_status_json( + label: &str, + response: &str, + expected: &Value, + failures: &mut Vec, + ) { + match serde_json::from_str::(response.trim()) { + Ok(actual) => { + if actual != *expected { + failures.push(format!( + "{label} was not the exact flat enriched record: {actual}" + )); + } + if actual.get("info").is_some() { + failures.push(format!("{label} nested legacy fields under `info`")); + } + } + Err(error) => failures.push(format!("{label} was not JSON: {error}")), + } + } + + #[test] + fn health_status_returns_flat_enriched_record() { + let _network_guard = crate::diagnostics::real_network_test_guard(); + let public_run_signature: fn(HealthListener, Arc, Arc>) = + HealthListener::run; + std::hint::black_box(public_run_signature); + + let listener = HealthListener::bind(0).unwrap(); + let port = listener.port; + let state = Arc::new(DaemonState::new()); + let info = Arc::new(Mutex::new(daemon_info(port))); + let initial_expected = expected_status(&info.lock().unwrap()); + let mut failures = Vec::new(); + + match catch_unwind(AssertUnwindSafe(|| status_json(info.as_ref()))) { + Ok(response) => check_status_json( + "private STATUS serializer", + &response, + &initial_expected, + &mut failures, + ), + Err(_) => failures.push("private STATUS serializer is not implemented".to_string()), + } + + let run_state = Arc::clone(&state); + let run_info = Arc::clone(&info); + let handle = std::thread::spawn(move || listener.run(run_state, run_info)); + + let initial_response = send_command(port, "STATUS").unwrap(); + check_status_json( + "initial STATUS response", + &initial_response, + &initial_expected, + &mut failures, + ); + + { + let mut current = info.lock().unwrap(); + current.hyperd_endpoint = "127.0.0.1:60000".to_string(); + } + let updated_expected = expected_status(&info.lock().unwrap()); + let updated_response = send_command(port, "STATUS").unwrap(); + check_status_json( + "STATUS response after shared DaemonInfo update", + &updated_response, + &updated_expected, + &mut failures, + ); + + let _ = send_command(port, "STOP"); + handle.join().unwrap(); + + assert!( + failures.is_empty(), + "health STATUS contract failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn slow_drip_response_honors_absolute_io_deadline() { + const IO_TIMEOUT: Duration = Duration::from_millis(100); + const DRIP_INTERVAL: Duration = Duration::from_millis(20); + const DRIP_BYTES: usize = 40; + const GENEROUS_COMPLETION_BOUND: Duration = Duration::from_millis(500); + + let peer = spawn_test_peer(|mut stream, stop| { + let request = read_test_command(&stream)?; + if request != "PING\n" { + return Err(format!("unexpected health command: {request:?}")); + } + + for _ in 0..DRIP_BYTES { + if stop.try_recv().is_ok() { + return Ok(false); + } + if stream.write_all(b"x").is_err() { + return Ok(false); + } + std::thread::sleep(DRIP_INTERVAL); + } + Ok(true) + }); + + let call = catch_unwind(AssertUnwindSafe(|| { + let started = Instant::now(); + let result = + send_command_with_timeout(peer.port, "PING", Duration::from_secs(1), IO_TIMEOUT); + (result, started.elapsed()) + })); + let completed_full_drip = peer + .finish() + .expect("slow-drip peer must shut down cleanly"); + let (result, elapsed) = match call { + Ok(outcome) => outcome, + Err(payload) => resume_unwind(payload), + }; + + assert_eq!( + result.as_ref().err().map(std::io::Error::kind), + Some(std::io::ErrorKind::TimedOut), + "a peer that makes progress without terminating a line must hit the one I/O deadline; got {result:?} after {elapsed:?}" + ); + assert!( + elapsed < GENEROUS_COMPLETION_BOUND, + "100ms I/O budget was extended to {elapsed:?} by slow-drip progress" + ); + assert!( + !completed_full_drip, + "the client waited for the peer's entire 800ms drip instead of enforcing its absolute deadline" + ); + } + + #[test] + fn oversized_newline_free_response_is_rejected() { + const MAX_HEALTH_RESPONSE_BYTES: usize = 64 * 1024; + const OVERSIZED_RESPONSE_BYTES: usize = MAX_HEALTH_RESPONSE_BYTES + 1; + + let peer = spawn_test_peer(|mut stream, _stop| { + let request = read_test_command(&stream)?; + if request != "PING\n" { + return Err(format!("unexpected health command: {request:?}")); + } + stream + .set_write_timeout(Some(Duration::from_secs(1))) + .map_err(|error| format!("set test peer write timeout: {error}"))?; + let response = vec![b'x'; OVERSIZED_RESPONSE_BYTES]; + let mut emitted = 0; + while emitted < response.len() { + match stream.write(&response[emitted..]) { + Ok(0) => { + return Err(format!( + "oversized health peer wrote zero bytes after {emitted} bytes" + )); + } + Ok(written) => emitted += written, + Err(error) => { + return Err(format!( + "oversized health peer stopped after {emitted} bytes: {error}" + )); + } + } + } + Ok(emitted) + }); + + let call = catch_unwind(AssertUnwindSafe(|| { + send_command_with_timeout( + peer.port, + "PING", + Duration::from_secs(1), + Duration::from_secs(1), + ) + })); + let response_bytes = peer + .finish() + .expect("oversized-response peer must shut down cleanly"); + let result = match call { + Ok(outcome) => outcome, + Err(payload) => resume_unwind(payload), + }; + let outcome = match &result { + Ok(response) => format!("accepted {} bytes", response.len()), + Err(error) => format!("returned {:?}: {error}", error.kind()), + }; + + assert_eq!(response_bytes, OVERSIZED_RESPONSE_BYTES); + assert_eq!( + result.as_ref().err().map(std::io::Error::kind), + Some(std::io::ErrorKind::InvalidData), + "newline-free health responses beyond the 64 KiB protocol limit must be rejected; {outcome}" + ); + } +} diff --git a/hyperdb-mcp/src/daemon/run.rs b/hyperdb-mcp/src/daemon/run.rs index f61518d..360fa74 100644 --- a/hyperdb-mcp/src/daemon/run.rs +++ b/hyperdb-mcp/src/daemon/run.rs @@ -110,7 +110,7 @@ pub async fn run_daemon(config: DaemonConfig) -> Result<(), Box Deserialize<'de> for ReportedPath { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireReportedPath { + display: String, + encoding: PathEncoding, + } + + let mut wire = WireReportedPath::deserialize(deserializer)?; + truncate_utf8(&mut wire.display, MAX_REPORTED_STRING_BYTES); + Ok(Self { + display: wire.display, + encoding: wire.encoding, + }) + } +} + +impl ReportedPath { + /// Build a bounded display representation from an operating-system string. + #[must_use] + pub fn from_os_str(path: &OsStr) -> Self { + let (mut display, encoding) = match path.to_str() { + Some(path) => (path.to_owned(), PathEncoding::Utf8), + None => (path.to_string_lossy().into_owned(), PathEncoding::Lossy), + }; + truncate_utf8(&mut display, MAX_REPORTED_STRING_BYTES); + + Self { display, encoding } + } +} + +/// Launcher-reported identity for one npm package. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LauncherPackageIdentity { + /// Package name. + pub name: String, + /// Package version, absent in source manifests. + pub version: Option, + /// Path to the package manifest. + pub package_path: ReportedPath, +} + +/// Allowlisted identity reported by the npm launcher. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LauncherIdentity { + /// Umbrella npm package. + pub wrapper: LauncherPackageIdentity, + /// Selected platform-specific npm package. + pub platform: LauncherPackageIdentity, + /// Selected native executable. + pub executable_path: ReportedPath, +} + +/// A bounded, typed warning produced while collecting installation identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum IdentityWarning { + /// Launcher metadata was not valid JSON with the expected shape. + MalformedLauncherInfo, + /// The complete launcher value exceeded its fixed input limit. + LauncherInfoTooLarge, + /// One allowlisted string exceeded its fixed input limit. + LauncherFieldTooLarge { + /// Stable dotted field name; never the rejected field value. + field: String, + }, + /// A reported or compiled version could not be parsed. + MalformedVersion { + /// Stable component name; never the malformed value. + component: String, + }, + /// Launcher package bases disagree with the authoritative native base. + VersionMismatch { + /// Native MCP semantic-version base. + native: String, + /// Wrapper npm version, when present and valid. + wrapper: Option, + /// Platform npm version, when present and valid. + platform: Option, + }, +} + +/// Result of pure launcher metadata parsing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ParsedLauncherIdentity { + /// Validated launcher identity, or none when absent/rejected. + pub identity: Option, + /// Bounded warnings explaining rejected metadata. + pub warnings: Vec, +} + +/// A compiled source version split into its semantic base and build suffix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceVersionIdentity { + /// Full compiled source string. + pub source: String, + /// Parsed semantic-version base. + pub version: Option, + /// Build suffix following `.r`, without the `r` marker. + pub build: Option, +} + +/// Authoritative native identity plus optional launcher-reported metadata. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct InstallationIdentity { + /// Actual native executable path. + pub native_executable: ReportedPath, + /// MCP source version and build identity. + pub mcp: SourceVersionIdentity, + /// Rust Hyper API source version and build identity. + pub hyper_rust_api: SourceVersionIdentity, + /// Optional, validated launcher report. + pub launcher: Option, + /// Bounded parse and comparison warnings. + pub warnings: Vec, +} + +/// Global CLI inputs that influence a doctor report. +#[derive(Debug, Clone, Copy)] +pub struct DoctorOptions<'a> { + /// Preferred persistent-database CLI path. + pub persistent_db: Option<&'a str>, + /// Deprecated persistent-database CLI alias. + pub deprecated_workspace: Option<&'a str>, + /// Disable the reserved persistent attachment. + pub ephemeral_only: bool, + /// Effective MCP read-only mode. + pub read_only: bool, + /// Effective private-hyperd mode. + pub no_daemon: bool, +} + +/// Failures that prevent serializable doctor facts from being assembled. +#[derive(Debug, thiserror::Error)] +pub enum DoctorReportError { + /// The operating system could not identify the running native executable. + #[error("could not identify the current hyperdb-mcp executable: {0}")] + CurrentExecutable(#[source] io::Error), + /// The generated MCP tool catalog could not be serialized canonically. + #[error("could not serialize the generated MCP tool catalog: {0}")] + Catalog(#[from] serde_json::Error), +} + +/// Collect the installation identity shared by `doctor` and MCP `status`. +/// +/// This only inspects process metadata and the bounded launcher environment +/// value. It deliberately performs no daemon, filesystem, or database probe. +/// +/// # Errors +/// +/// Returns an error if the operating system cannot resolve the current executable path. +pub fn current_installation_identity() -> Result { + let current_executable = std::env::current_exe()?; + let launcher_info = std::env::var_os("HYPERDB_MCP_LAUNCHER_INFO"); + Ok(installation_identity_from_parts( + current_executable.as_os_str(), + &crate::version::mcp_version_string(), + &crate::version::hyper_api_version_string(), + launcher_info.as_deref(), + )) +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +enum DoctorStatus { + Ok, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +enum PersistentMode { + PersistentAttached, + EphemeralOnly, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorPathFacts { + path: ReportedPath, + exists: bool, + is_file: bool, + is_directory: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorInstallationReport { + native_executable: ReportedPath, + mcp_version: SourceVersionIdentity, + hyper_rust_api_version: SourceVersionIdentity, + launcher: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorConfigurationReport { + persistent_mode: PersistentMode, + persistent_path_source: crate::paths::PersistentDbPathSource, + observed_persistent_path: Option, + resolved_persistent_path: Option, + resolved_persistent_parent: Option, + daemon_state_directory: Option, + daemon_discovery_file: Option, + client_log: DoctorPathFacts, + observed_hyperd_path: Option, + effective_hyperd_path: Option, + upward_hyperd_candidate: Option, + read_only: bool, + no_daemon: bool, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorDaemonSection { + state: DoctorDaemonState, + pid: Option, + hyperd_endpoint: Option, + health_port: Option, + started_at: Option, + version: Option, + mcp_version: Option, + executable_path: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorToolCatalogReport { + tool_count: usize, + canonical_tool_bytes: usize, + initialization_instructions_bytes: usize, + get_readme_bytes: usize, +} + +#[derive(Debug, Clone, Serialize)] +struct DoctorWarning { + code: String, + message: String, +} + +/// Stable, typed native doctor report. +#[derive(Debug, Clone, Serialize)] +pub struct DoctorReport { + status: DoctorStatus, + installation: DoctorInstallationReport, + configuration: DoctorConfigurationReport, + daemon: DoctorDaemonSection, + tool_catalog: DoctorToolCatalogReport, + warnings: Vec, +} + +/// Monotonic instant supplied to the pure doctor collector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DoctorMoment(pub(crate) u64); + +/// Finite monotonic deadline shared by doctor scans and probes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DoctorDeadline(pub(crate) u64); + +/// Bounded candidate-scan request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DoctorScanRequest { + pub(crate) ports: PortScan, + pub(crate) deadline: DoctorDeadline, +} + +/// A candidate location returned by the bounded scanner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DoctorScanCandidate { + pub(crate) responding_port: u16, +} + +/// Raw outcome from fetching enriched `STATUS` at one candidate port. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DoctorStatusProbe { + Unreachable, + Response(String), +} + +/// Finite collection policy supplied independently of process globals. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct DoctorCollectRequest { + pub(crate) ports: PortScan, + pub(crate) timeout: Duration, +} + +/// The stable daemon discovery state exposed by doctor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum DoctorDaemonState { + Missing, + Unreadable, + Malformed, + ParsedUnreachable, + LiveFromDiscovery, + LiveFromScan, +} + +/// One recorded discovery fact that disagrees with fresh enriched `STATUS`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DiscoveryFactMismatch { + Pid { + recorded: u32, + fresh: u32, + }, + McpVersion { + recorded: String, + fresh: String, + }, + ExecutablePath { + recorded: ReportedPath, + fresh: ReportedPath, + }, +} + +/// Typed warnings produced while verifying daemon candidates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum DoctorDaemonWarning { + DiscoveryUnreadable { + kind: io::ErrorKind, + }, + MalformedDiscovery, + DiscoveryCandidateUnreachable { + responding_port: u16, + }, + StaleOrReplacedDiscovery { + mismatches: Vec, + }, + StatusHealthPortMismatch { + responding_port: u16, + reported_port: u16, + }, + MalformedStatus { + responding_port: u16, + }, +} + +/// Fresh daemon facts accepted only after candidate-port verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct VerifiedDoctorDaemon { + pub(crate) responding_port: u16, + pub(crate) record: DaemonRecord, +} + +/// Pure daemon portion of the doctor report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DoctorDaemonReport { + pub(crate) state: DoctorDaemonState, + pub(crate) verified: Option, + pub(crate) warnings: Vec, +} + +/// The complete external capability set reachable by daemon collection. +/// +/// Deliberately absent: discovery writers, cleanup, process control, filesystem +/// mutation, and unbounded network operations. +pub(crate) struct DoctorCollectorDependencies<'a> { + pub(crate) read_raw_discovery: &'a dyn Fn() -> RawDiscoveryRead, + pub(crate) probe_enriched_status: &'a dyn Fn(u16, DoctorDeadline) -> DoctorStatusProbe, + pub(crate) scan_candidates: &'a dyn Fn(DoctorScanRequest) -> Vec, + pub(crate) now: &'a dyn Fn() -> DoctorMoment, + pub(crate) deadline_after: &'a dyn Fn(DoctorMoment, Duration) -> DoctorDeadline, +} + +/// Collect daemon doctor facts without granting mutation capabilities. +pub(crate) fn collect_doctor_daemon( + dependencies: &DoctorCollectorDependencies<'_>, + request: DoctorCollectRequest, +) -> DoctorDaemonReport { + let deadline = (dependencies.deadline_after)((dependencies.now)(), request.timeout); + let raw = (dependencies.read_raw_discovery)(); + let mut warnings = Vec::new(); + + let (fallback_state, discovery_record) = match raw { + RawDiscoveryRead::Missing { .. } => (DoctorDaemonState::Missing, None), + RawDiscoveryRead::Unreadable { kind, .. } => { + warnings.push(DoctorDaemonWarning::DiscoveryUnreadable { kind }); + (DoctorDaemonState::Unreadable, None) + } + RawDiscoveryRead::Malformed { .. } => { + warnings.push(DoctorDaemonWarning::MalformedDiscovery); + (DoctorDaemonState::Malformed, None) + } + RawDiscoveryRead::Parsed { record, .. } => { + (DoctorDaemonState::ParsedUnreachable, Some(record)) + } + }; + + if let Some(recorded) = discovery_record.as_ref() { + let responding_port = recorded.info().health_port; + match verify_status_candidate(dependencies, responding_port, deadline, &mut warnings) { + Some(fresh) => { + let mismatches = discovery_fact_mismatches(recorded, &fresh); + if !mismatches.is_empty() { + warnings.push(DoctorDaemonWarning::StaleOrReplacedDiscovery { mismatches }); + } + return DoctorDaemonReport { + state: DoctorDaemonState::LiveFromDiscovery, + verified: Some(VerifiedDoctorDaemon { + responding_port, + record: fresh, + }), + warnings, + }; + } + None if warnings.is_empty() => { + warnings + .push(DoctorDaemonWarning::DiscoveryCandidateUnreachable { responding_port }); + } + None => {} + } + } + + for candidate in (dependencies.scan_candidates)(DoctorScanRequest { + ports: request.ports, + deadline, + }) { + if let Some(record) = verify_status_candidate( + dependencies, + candidate.responding_port, + deadline, + &mut warnings, + ) { + return DoctorDaemonReport { + state: DoctorDaemonState::LiveFromScan, + verified: Some(VerifiedDoctorDaemon { + responding_port: candidate.responding_port, + record, + }), + warnings, + }; + } + } + + DoctorDaemonReport { + state: fallback_state, + verified: None, + warnings, + } +} + +fn verify_status_candidate( + dependencies: &DoctorCollectorDependencies<'_>, + responding_port: u16, + deadline: DoctorDeadline, + warnings: &mut Vec, +) -> Option { + let DoctorStatusProbe::Response(response) = + (dependencies.probe_enriched_status)(responding_port, deadline) + else { + return None; + }; + let Ok(record) = serde_json::from_str::(&response) else { + warnings.push(DoctorDaemonWarning::MalformedStatus { responding_port }); + return None; + }; + if record.identity().is_none() { + warnings.push(DoctorDaemonWarning::MalformedStatus { responding_port }); + return None; + } + if record.info().health_port != responding_port { + warnings.push(DoctorDaemonWarning::StatusHealthPortMismatch { + responding_port, + reported_port: record.info().health_port, + }); + return None; + } + Some(record) +} + +fn discovery_fact_mismatches( + recorded: &DaemonRecord, + fresh: &DaemonRecord, +) -> Vec { + let mut mismatches = Vec::new(); + if recorded.info().pid != fresh.info().pid { + mismatches.push(DiscoveryFactMismatch::Pid { + recorded: recorded.info().pid, + fresh: fresh.info().pid, + }); + } + if let (Some(recorded_identity), Some(fresh_identity)) = (recorded.identity(), fresh.identity()) + { + if recorded_identity.mcp_version() != fresh_identity.mcp_version() { + mismatches.push(DiscoveryFactMismatch::McpVersion { + recorded: recorded_identity.mcp_version().to_owned(), + fresh: fresh_identity.mcp_version().to_owned(), + }); + } + if recorded_identity.executable_path() != fresh_identity.executable_path() { + mismatches.push(DiscoveryFactMismatch::ExecutablePath { + recorded: recorded_identity.executable_path().clone(), + fresh: fresh_identity.executable_path().clone(), + }); + } + } + mismatches +} + +/// Collect the complete side-effect-free native doctor report. +/// +/// This reads process configuration, filesystem metadata, the non-mutating raw +/// discovery record, and bounded loopback health responses. It never creates a +/// directory or file, starts a daemon or `hyperd`, or opens a database. +/// +/// # Errors +/// +/// Returns [`DoctorReportError::CurrentExecutable`] when the operating system +/// cannot identify this process's executable, or [`DoctorReportError::Catalog`] +/// when the generated typed tool catalog cannot be serialized. +pub fn collect_doctor_report( + options: DoctorOptions<'_>, +) -> Result { + let installation = + current_installation_identity().map_err(DoctorReportError::CurrentExecutable)?; + + let resolved = crate::paths::resolve_persistent_db_path_with_source( + options.persistent_db, + options.deprecated_workspace, + options.ephemeral_only, + ); + let persistent_mode = if resolved.path.is_some() { + PersistentMode::PersistentAttached + } else { + PersistentMode::EphemeralOnly + }; + let observed_persistent_path = resolved + .observed_path + .as_deref() + .map(|path| ReportedPath::from_os_str(path.as_os_str())); + let resolved_persistent_path = resolved.path.as_deref().map(doctor_path_facts); + let resolved_persistent_parent = resolved + .path + .as_deref() + .map(normalized_persistent_parent) + .map(|path| doctor_path_facts(&path)); + + let state_dir_result = crate::daemon::discovery::state_dir(); + let state_error_kind = state_dir_result.as_ref().err().map(io::Error::kind); + let state_dir = state_dir_result.ok(); + let discovery_path = state_dir.as_ref().map(|path| path.join("daemon.json")); + let daemon_state_directory = state_dir.as_deref().map(doctor_path_facts); + let daemon_discovery_file = discovery_path.as_deref().map(doctor_path_facts); + + let client_log_path = doctor_client_log_path(resolved.path.as_deref()); + let hyperd_resolution = resolve_doctor_hyperd(); + + let daemon_report = collect_real_doctor_daemon( + discovery_path.as_deref(), + state_error_kind, + crate::daemon::discovery::resolve_port_scan(), + ); + let daemon = doctor_daemon_section(&daemon_report); + let catalog = crate::server::HyperMcpServer::doctor_catalog_snapshot(options.read_only)?; + + let mut warnings = installation + .warnings + .iter() + .map(identity_doctor_warning) + .collect::>(); + warnings.extend(daemon_report.warnings.iter().map(daemon_doctor_warning)); + if let Some(kind) = state_error_kind { + warnings.push(doctor_warning( + "daemon_state_path_unavailable", + format!("The daemon state path could not be resolved ({kind:?})."), + )); + } + if resolved.source == crate::paths::PersistentDbPathSource::DeprecatedAlias { + warnings.push(doctor_warning( + "deprecated_persistent_alias", + "The persistent path came from deprecated --workspace; use --persistent-db.", + )); + } + if resolved.path.is_none() { + // No persistent path => `doctor_client_log_path(None)` falls back to + // `resolve_log_dir(None)`, which keys the log directory to *this* + // doctor process's PID. A separate running MCP server logs under its + // own per-process directory, so the reported path cannot correspond + // to any real session — surface that instead of presenting it as fact. + warnings.push(doctor_warning( + "ephemeral_client_log_path_illustrative", + "No persistent database is configured (ephemeral-only mode), so the reported client log path is derived from this doctor invocation's own temporary directory and process id. It is illustrative only: a running MCP server logs under its own per-process directory, which a separate doctor run cannot identify.", + )); + } + if let Some(warning) = hyperd_resolution.warning { + warnings.push(warning); + } + if let Some(verified) = daemon_report.verified.as_ref() { + if let Some(identity) = verified.record.identity() { + if identity.mcp_version() != installation.mcp.source { + warnings.push(doctor_warning( + "daemon_client_build_mismatch", + format!( + "The live daemon MCP build '{}' differs from this client build '{}'.", + identity.mcp_version(), + installation.mcp.source + ), + )); + } + if identity.executable_path() != &installation.native_executable { + warnings.push(doctor_warning( + "daemon_client_executable_mismatch", + format!( + "The live daemon executable '{}' differs from this client executable '{}'.", + identity.executable_path().display, + installation.native_executable.display + ), + )); + } + } + } + warnings.push(doctor_warning( + "local_paths_review", + "This report contains local paths; review it before sharing.", + )); + + Ok(DoctorReport { + status: DoctorStatus::Ok, + installation: DoctorInstallationReport { + native_executable: installation.native_executable, + mcp_version: bounded_source_version(installation.mcp), + hyper_rust_api_version: bounded_source_version(installation.hyper_rust_api), + launcher: installation.launcher, + }, + configuration: DoctorConfigurationReport { + persistent_mode, + persistent_path_source: resolved.source, + observed_persistent_path, + resolved_persistent_path, + resolved_persistent_parent, + daemon_state_directory, + daemon_discovery_file, + client_log: doctor_path_facts(&client_log_path), + observed_hyperd_path: hyperd_resolution.observed.as_deref().map(doctor_path_facts), + effective_hyperd_path: hyperd_resolution + .effective + .as_deref() + .map(doctor_path_facts), + upward_hyperd_candidate: hyperd_resolution + .upward_candidate + .as_deref() + .map(doctor_path_facts), + read_only: options.read_only, + no_daemon: options.no_daemon, + }, + daemon, + tool_catalog: DoctorToolCatalogReport { + tool_count: catalog.tool_count, + canonical_tool_bytes: catalog.canonical_tool_bytes, + initialization_instructions_bytes: catalog.initialization_instructions_bytes, + get_readme_bytes: catalog.get_readme_bytes, + }, + warnings, + }) +} + +/// Render the typed doctor report as terminal-safe human-readable text. +#[must_use] +pub fn render_doctor_human(report: &DoctorReport) -> String { + let mut output = String::new(); + let _ = writeln!(output, "Status"); + let _ = writeln!(output, " Overall: ok"); + + let _ = writeln!(output, "\nInstallation"); + push_human_path( + &mut output, + "Native executable", + &report.installation.native_executable, + None, + ); + let _ = writeln!( + output, + " MCP version: {}", + escape_human(&report.installation.mcp_version.source) + ); + let _ = writeln!( + output, + " Hyper Rust API version: {}", + escape_human(&report.installation.hyper_rust_api_version.source) + ); + match report.installation.launcher.as_ref() { + Some(launcher) => { + let _ = writeln!(output, " Launcher-reported wrapper:"); + let _ = writeln!(output, " Name: {}", escape_human(&launcher.wrapper.name)); + let _ = writeln!( + output, + " Version: {}", + escape_human(launcher.wrapper.version.as_deref().unwrap_or("unavailable")) + ); + push_human_path( + &mut output, + " Package path", + &launcher.wrapper.package_path, + None, + ); + let _ = writeln!(output, " Launcher-reported platform:"); + let _ = writeln!( + output, + " Name: {}", + escape_human(&launcher.platform.name) + ); + let _ = writeln!( + output, + " Version: {}", + escape_human( + launcher + .platform + .version + .as_deref() + .unwrap_or("unavailable") + ) + ); + push_human_path( + &mut output, + " Package path", + &launcher.platform.package_path, + None, + ); + push_human_path( + &mut output, + " Launcher executable", + &launcher.executable_path, + None, + ); + } + None => { + let _ = writeln!(output, " Launcher-reported metadata: absent"); + } + } + + let _ = writeln!(output, "\nConfiguration"); + let _ = writeln!( + output, + " Persistent mode: {}", + persistent_mode_label(report.configuration.persistent_mode) + ); + let _ = writeln!( + output, + " Persistent path source: {}", + persistent_source_label(report.configuration.persistent_path_source) + ); + match report.configuration.observed_persistent_path.as_ref() { + Some(path) => push_human_path(&mut output, "Observed persistent path", path, None), + None => { + let _ = writeln!(output, " Observed persistent path: unavailable"); + } + } + push_optional_human_path_facts( + &mut output, + "Resolved persistent path", + report.configuration.resolved_persistent_path.as_ref(), + ); + push_optional_human_path_facts( + &mut output, + "Resolved persistent parent", + report.configuration.resolved_persistent_parent.as_ref(), + ); + push_optional_human_path_facts( + &mut output, + "Daemon state directory", + report.configuration.daemon_state_directory.as_ref(), + ); + push_optional_human_path_facts( + &mut output, + "Daemon discovery file", + report.configuration.daemon_discovery_file.as_ref(), + ); + push_human_path_facts(&mut output, "Client log", &report.configuration.client_log); + push_optional_human_path_facts( + &mut output, + "Observed HYPERD_PATH", + report.configuration.observed_hyperd_path.as_ref(), + ); + push_optional_human_path_facts( + &mut output, + "Effective hyperd path", + report.configuration.effective_hyperd_path.as_ref(), + ); + push_optional_human_path_facts( + &mut output, + "Upward .hyperd/current candidate", + report.configuration.upward_hyperd_candidate.as_ref(), + ); + let _ = writeln!(output, " Read only: {}", report.configuration.read_only); + let _ = writeln!(output, " No daemon: {}", report.configuration.no_daemon); + + let _ = writeln!(output, "\nDaemon"); + let _ = writeln!( + output, + " State: {}", + daemon_state_label(report.daemon.state) + ); + if let Some(pid) = report.daemon.pid { + let _ = writeln!(output, " PID: {pid}"); + } + if let Some(endpoint) = report.daemon.hyperd_endpoint.as_deref() { + let _ = writeln!(output, " Hyperd endpoint: {}", escape_human(endpoint)); + } + if let Some(port) = report.daemon.health_port { + let _ = writeln!(output, " Health port: {port}"); + } + if let Some(started_at) = report.daemon.started_at.as_deref() { + let _ = writeln!(output, " Started: {}", escape_human(started_at)); + } + if let Some(version) = report.daemon.version.as_deref() { + let _ = writeln!(output, " Takeover version: {}", escape_human(version)); + } + if let Some(version) = report.daemon.mcp_version.as_deref() { + let _ = writeln!(output, " MCP build: {}", escape_human(version)); + } + if let Some(path) = report.daemon.executable_path.as_ref() { + push_human_path(&mut output, "Daemon executable", path, None); + } + + let _ = writeln!(output, "\nTool catalog"); + let _ = writeln!(output, " Tools: {}", report.tool_catalog.tool_count); + let _ = writeln!( + output, + " Canonical generated tools bytes: {}", + report.tool_catalog.canonical_tool_bytes + ); + let _ = writeln!( + output, + " Initialization instructions bytes: {}", + report.tool_catalog.initialization_instructions_bytes + ); + let _ = writeln!( + output, + " get_readme bytes: {}", + report.tool_catalog.get_readme_bytes + ); + + let _ = writeln!(output, "\nWarnings"); + if report.warnings.is_empty() { + let _ = writeln!(output, " None"); + } else { + for warning in &report.warnings { + let _ = writeln!( + output, + " [{}] {}", + escape_human(&warning.code), + escape_human(&warning.message) + ); + } + } + output +} + +fn doctor_path_facts(path: &Path) -> DoctorPathFacts { + let metadata = std::fs::metadata(path).ok(); + DoctorPathFacts { + path: ReportedPath::from_os_str(path.as_os_str()), + exists: metadata.is_some(), + is_file: metadata.as_ref().is_some_and(std::fs::Metadata::is_file), + is_directory: metadata.as_ref().is_some_and(std::fs::Metadata::is_dir), + } +} + +fn normalized_persistent_parent(path: &Path) -> PathBuf { + match path.parent() { + Some(parent) if parent.as_os_str().is_empty() => PathBuf::from("."), + Some(parent) => parent.to_path_buf(), + None => PathBuf::from("."), + } +} + +fn doctor_client_log_path(persistent_path: Option<&Path>) -> PathBuf { + let log_dir = match persistent_path { + // `persistent_path` is already the effective runtime path. Derive the + // sibling log directly so a literal `~/` in HOME is not expanded a + // second time. + Some(path) => path + .parent() + .map_or_else(|| PathBuf::from("."), Path::to_path_buf), + None => crate::engine::resolve_log_dir(None), + }; + log_dir.join(crate::engine::CLIENT_LOG_FILE_NAME) +} + +fn find_upward_hyperd_candidate() -> Option { + #[cfg(windows)] + const HYPERD_EXE: &str = "hyperd.exe"; + #[cfg(not(windows))] + const HYPERD_EXE: &str = "hyperd"; + + let current_dir = std::env::current_dir().ok()?; + current_dir + .ancestors() + .map(|directory| directory.join(".hyperd").join("current").join(HYPERD_EXE)) + .find(|candidate| candidate.exists()) +} + +struct DoctorHyperdResolution { + observed: Option, + effective: Option, + upward_candidate: Option, + warning: Option, +} + +fn resolve_doctor_hyperd() -> DoctorHyperdResolution { + match std::env::var("HYPERD_PATH") { + Ok(configured) => { + let observed = PathBuf::from(&configured); + let (effective, warning) = resolve_configured_hyperd(&observed, &configured); + DoctorHyperdResolution { + observed: Some(observed), + effective, + upward_candidate: None, + warning, + } + } + Err(std::env::VarError::NotUnicode(configured)) => { + let upward_candidate = find_upward_hyperd_candidate(); + DoctorHyperdResolution { + observed: Some(PathBuf::from(configured)), + effective: upward_candidate.clone(), + upward_candidate, + warning: Some(doctor_warning( + "non_utf8_hyperd_path_ignored", + "HYPERD_PATH is non-UTF-8; runtime ignores that override and uses upward .hyperd/current resolution when available.", + )), + } + } + Err(std::env::VarError::NotPresent) => { + let upward_candidate = find_upward_hyperd_candidate(); + DoctorHyperdResolution { + observed: None, + effective: upward_candidate.clone(), + upward_candidate, + warning: None, + } + } + } +} + +fn resolve_configured_hyperd( + configured: &Path, + _configured_text: &str, +) -> (Option, Option) { + #[cfg(windows)] + const HYPERD_EXE: &str = "hyperd.exe"; + #[cfg(not(windows))] + const HYPERD_EXE: &str = "hyperd"; + + if configured.is_dir() { + let executable = configured.join(HYPERD_EXE); + if executable.exists() { + return (Some(executable), None); + } + #[cfg(windows)] + { + let executable_without_extension = configured.join("hyperd"); + if executable_without_extension.exists() { + return (Some(executable_without_extension), None); + } + } + return ( + None, + Some(doctor_warning( + "observed_hyperd_directory_missing_executable", + format!( + "HYPERD_PATH is a directory, but {HYPERD_EXE} was not found in that directory." + ), + )), + ); + } + if configured.exists() { + return (Some(configured.to_path_buf()), None); + } + #[cfg(windows)] + { + let executable = PathBuf::from(format!("{_configured_text}.exe")); + if executable.exists() { + return (Some(executable), None); + } + } + ( + None, + Some(doctor_warning( + "observed_hyperd_path_missing", + "HYPERD_PATH was observed, but the configured hyperd executable was not found.", + )), + ) +} + +fn collect_real_doctor_daemon( + discovery_path: Option<&Path>, + state_error_kind: Option, + ports: PortScan, +) -> DoctorDaemonReport { + let origin = Instant::now(); + let probing_scan = std::cell::Cell::new(false); + let read_raw_discovery = || match discovery_path { + Some(path) => crate::daemon::discovery::read_discovery_file_raw(path), + None => RawDiscoveryRead::Unreadable { + path: ReportedPath::from_os_str(OsStr::new("")), + kind: state_error_kind.unwrap_or(io::ErrorKind::NotFound), + }, + }; + let probe_enriched_status = |port: u16, deadline: DoctorDeadline| { + // Preserve the established scan handshake, but verify each identified + // port immediately instead of gathering PONGs across the whole range. + // Discovery candidates already have a recorded identity and go + // straight to the stronger fresh STATUS verification. + if probing_scan.get() { + match send_doctor_command(port, "PING", origin, deadline) { + Ok(response) if is_identified_doctor_pong(&response) => {} + _ => return DoctorStatusProbe::Unreachable, + } + } + + match send_doctor_command(port, "STATUS", origin, deadline) { + Ok(response) => DoctorStatusProbe::Response(response), + Err(error) if error.kind() == io::ErrorKind::InvalidData => { + DoctorStatusProbe::Response(String::new()) + } + Err(_) => DoctorStatusProbe::Unreachable, + } + }; + let scan_candidates = |request: DoctorScanRequest| { + probing_scan.set(true); + let mut candidates = Vec::new(); + for offset in 0..request.ports.span { + let Some(port) = request.ports.base.checked_add(offset) else { + break; + }; + candidates.push(DoctorScanCandidate { + responding_port: port, + }); + } + candidates + }; + let now = || DoctorMoment(elapsed_millis(origin)); + let deadline_after = |now: DoctorMoment, timeout: Duration| { + DoctorDeadline(now.0.saturating_add(duration_millis(timeout))) + }; + let dependencies = DoctorCollectorDependencies { + read_raw_discovery: &read_raw_discovery, + probe_enriched_status: &probe_enriched_status, + scan_candidates: &scan_candidates, + now: &now, + deadline_after: &deadline_after, + }; + collect_doctor_daemon( + &dependencies, + DoctorCollectRequest { + ports, + timeout: DOCTOR_DAEMON_TIMEOUT, + }, + ) +} + +fn doctor_network_timeout(origin: Instant, deadline: DoctorDeadline) -> io::Result { + let Some(remaining_millis) = deadline.0.checked_sub(elapsed_millis(origin)) else { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "doctor daemon deadline elapsed", + )); + }; + if remaining_millis == 0 { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "doctor daemon deadline elapsed", + )); + } + Ok(Duration::from_millis(remaining_millis).min(DOCTOR_NETWORK_PHASE_TIMEOUT)) +} + +fn elapsed_millis(origin: Instant) -> u64 { + duration_millis(origin.elapsed()) +} + +fn duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn send_doctor_command( + port: u16, + command: &str, + origin: Instant, + deadline: DoctorDeadline, +) -> io::Result { + use std::io::{Read, Write}; + + let address = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let connect_timeout = doctor_network_timeout(origin, deadline)?; + let mut stream = std::net::TcpStream::connect_timeout(&address, connect_timeout)?; + let message = format!("{command}\n"); + let mut written = 0; + while written < message.len() { + let timeout = doctor_network_timeout(origin, deadline)?; + stream.set_write_timeout(Some(timeout))?; + match stream.write(&message.as_bytes()[written..]) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "doctor health peer stopped accepting the request", + )); + } + Ok(count) => written += count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + + let mut response = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let timeout = doctor_network_timeout(origin, deadline)?; + stream.set_read_timeout(Some(timeout))?; + let remaining_capacity = MAX_STATUS_RESPONSE_BYTES + .saturating_add(1) + .saturating_sub(response.len()); + if remaining_capacity == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "doctor health response exceeded its fixed limit", + )); + } + let read_capacity = remaining_capacity.min(chunk.len()); + match stream.read(&mut chunk[..read_capacity]) { + Ok(0) => break, + Ok(count) => { + let line_end = chunk[..count] + .iter() + .position(|byte| *byte == b'\n') + .map_or(count, |index| index + 1); + response.extend_from_slice(&chunk[..line_end]); + if response.len() > MAX_STATUS_RESPONSE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "doctor health response exceeded its fixed limit", + )); + } + if response.last() == Some(&b'\n') { + break; + } + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) => return Err(error), + } + } + String::from_utf8(response) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string())) +} + +fn is_identified_doctor_pong(response: &str) -> bool { + let mut tokens = response.split_whitespace(); + tokens.next() == Some("PONG") && tokens.next() == Some(crate::daemon::health::PONG_TOKEN) +} + +fn doctor_daemon_section(report: &DoctorDaemonReport) -> DoctorDaemonSection { + let Some(verified) = report.verified.as_ref() else { + return DoctorDaemonSection { + state: report.state, + pid: None, + hyperd_endpoint: None, + health_port: None, + started_at: None, + version: None, + mcp_version: None, + executable_path: None, + }; + }; + let identity = verified.record.identity(); + DoctorDaemonSection { + state: report.state, + pid: Some(verified.record.info().pid), + hyperd_endpoint: Some(bounded_string(&verified.record.info().hyperd_endpoint)), + health_port: Some(verified.record.info().health_port), + started_at: Some(bounded_string(&verified.record.info().started_at)), + version: Some(bounded_string(&verified.record.info().version)), + mcp_version: identity.map(|identity| bounded_string(identity.mcp_version())), + executable_path: identity.map(|identity| identity.executable_path().clone()), + } +} + +fn bounded_source_version(mut version: SourceVersionIdentity) -> SourceVersionIdentity { + truncate_utf8(&mut version.source, MAX_REPORTED_STRING_BYTES); + if let Some(value) = version.version.as_mut() { + truncate_utf8(value, MAX_REPORTED_STRING_BYTES); + } + if let Some(value) = version.build.as_mut() { + truncate_utf8(value, MAX_REPORTED_STRING_BYTES); + } + version +} + +fn bounded_string(value: &str) -> String { + let mut bounded = value.to_owned(); + truncate_utf8(&mut bounded, MAX_REPORTED_STRING_BYTES); + bounded +} + +fn doctor_warning(code: impl Into, message: impl Into) -> DoctorWarning { + let mut code = code.into(); + let mut message = message.into(); + truncate_utf8(&mut code, MAX_REPORTED_STRING_BYTES); + truncate_utf8(&mut message, MAX_REPORTED_STRING_BYTES); + DoctorWarning { code, message } +} + +fn identity_doctor_warning(warning: &IdentityWarning) -> DoctorWarning { + match warning { + IdentityWarning::MalformedLauncherInfo => doctor_warning( + "malformed_launcher_info", + "HYPERDB_MCP_LAUNCHER_INFO was malformed and was ignored.", + ), + IdentityWarning::LauncherInfoTooLarge => doctor_warning( + "launcher_info_too_large", + "HYPERDB_MCP_LAUNCHER_INFO exceeded 16 KiB and was ignored.", + ), + IdentityWarning::LauncherFieldTooLarge { field } => doctor_warning( + "launcher_field_too_large", + format!( + "Launcher field '{field}' exceeded 4 KiB and all launcher metadata was ignored." + ), + ), + IdentityWarning::MalformedVersion { component } => doctor_warning( + "malformed_version", + format!("The {component} value was not valid semantic version identity."), + ), + IdentityWarning::VersionMismatch { + native, + wrapper, + platform, + } => doctor_warning( + "launcher_native_version_mismatch", + format!( + "Launcher package versions differ from native {native}: wrapper={}, platform={}.", + wrapper.as_deref().unwrap_or("unavailable"), + platform.as_deref().unwrap_or("unavailable") + ), + ), + } +} + +fn daemon_doctor_warning(warning: &DoctorDaemonWarning) -> DoctorWarning { + match warning { + DoctorDaemonWarning::DiscoveryUnreadable { kind } => doctor_warning( + "daemon_discovery_unreadable", + format!("The daemon discovery file was unreadable ({kind:?})."), + ), + DoctorDaemonWarning::MalformedDiscovery => doctor_warning( + "daemon_discovery_malformed", + "The daemon discovery file was malformed; it was left unchanged.", + ), + DoctorDaemonWarning::DiscoveryCandidateUnreachable { responding_port } => doctor_warning( + "daemon_discovery_candidate_unreachable", + format!("The recorded daemon candidate on port {responding_port} did not return fresh enriched STATUS."), + ), + DoctorDaemonWarning::StaleOrReplacedDiscovery { mismatches } => { + let facts = mismatches + .iter() + .map(discovery_mismatch_message) + .collect::>() + .join("; "); + doctor_warning( + "daemon_discovery_stale_or_replaced", + format!("Fresh daemon STATUS disagreed with the discovery record: {facts}."), + ) + } + DoctorDaemonWarning::StatusHealthPortMismatch { + responding_port, + reported_port, + } => doctor_warning( + "daemon_status_health_port_mismatch", + format!("STATUS from port {responding_port} reported health port {reported_port}; the candidate was rejected."), + ), + DoctorDaemonWarning::MalformedStatus { responding_port } => doctor_warning( + "daemon_status_malformed", + format!("Port {responding_port} returned malformed or unenriched STATUS; the candidate was rejected."), + ), + } +} + +fn discovery_mismatch_message(mismatch: &DiscoveryFactMismatch) -> String { + match mismatch { + DiscoveryFactMismatch::Pid { recorded, fresh } => { + format!("PID recorded={recorded} fresh={fresh}") + } + DiscoveryFactMismatch::McpVersion { recorded, fresh } => { + format!("MCP build recorded='{recorded}' fresh='{fresh}'") + } + DiscoveryFactMismatch::ExecutablePath { recorded, fresh } => format!( + "executable recorded='{}' fresh='{}'", + recorded.display, fresh.display + ), + } +} + +fn escape_human(value: &str) -> String { + let mut escaped = String::new(); + for character in value.chars() { + if character <= '\u{1f}' || character == '\u{7f}' { + let _ = write!(escaped, "\\u{{{:x}}}", u32::from(character)); + } else { + escaped.push(character); + } + } + truncate_utf8(&mut escaped, MAX_REPORTED_STRING_BYTES); + escaped +} + +fn push_optional_human_path_facts( + output: &mut String, + label: &str, + facts: Option<&DoctorPathFacts>, +) { + match facts { + Some(facts) => push_human_path_facts(output, label, facts), + None => { + let _ = writeln!(output, " {label}: unavailable"); + } + } +} + +fn push_human_path_facts(output: &mut String, label: &str, facts: &DoctorPathFacts) { + push_human_path( + output, + label, + &facts.path, + Some((facts.exists, facts.is_file, facts.is_directory)), + ); +} + +fn push_human_path( + output: &mut String, + label: &str, + path: &ReportedPath, + facts: Option<(bool, bool, bool)>, +) { + let encoding = match path.encoding { + PathEncoding::Utf8 => "utf8", + PathEncoding::Lossy => "lossy", + }; + match facts { + Some((exists, is_file, is_directory)) => { + let _ = writeln!( + output, + " {label}: {} (encoding: {encoding}; exists: {exists}; file: {is_file}; directory: {is_directory})", + escape_human(&path.display) + ); + } + None => { + let _ = writeln!( + output, + " {label}: {} (encoding: {encoding})", + escape_human(&path.display) + ); + } + } +} + +const fn persistent_mode_label(mode: PersistentMode) -> &'static str { + match mode { + PersistentMode::PersistentAttached => "persistent_attached", + PersistentMode::EphemeralOnly => "ephemeral_only", + } +} + +const fn persistent_source_label(source: crate::paths::PersistentDbPathSource) -> &'static str { + match source { + crate::paths::PersistentDbPathSource::Cli => "cli", + crate::paths::PersistentDbPathSource::DeprecatedAlias => "deprecated_alias", + crate::paths::PersistentDbPathSource::Environment => "environment", + crate::paths::PersistentDbPathSource::PlatformDefault => "platform_default", + crate::paths::PersistentDbPathSource::Disabled => "disabled", + } +} + +const fn daemon_state_label(state: DoctorDaemonState) -> &'static str { + match state { + DoctorDaemonState::Missing => "missing", + DoctorDaemonState::Unreadable => "unreadable", + DoctorDaemonState::Malformed => "malformed", + DoctorDaemonState::ParsedUnreachable => "parsed_unreachable", + DoctorDaemonState::LiveFromDiscovery => "live_from_discovery", + DoctorDaemonState::LiveFromScan => "live_from_scan", + } +} + +#[derive(Deserialize)] +struct RawLauncherPackageIdentity { + name: String, + version: Option, + package_path: String, +} + +#[derive(Deserialize)] +struct RawLauncherIdentity { + wrapper: RawLauncherPackageIdentity, + platform: RawLauncherPackageIdentity, + executable_path: String, +} + +/// Parse launcher metadata without reading or mutating process environment. +#[must_use] +pub fn parse_launcher_identity(value: Option<&OsStr>) -> ParsedLauncherIdentity { + let Some(value) = value else { + return ParsedLauncherIdentity { + identity: None, + warnings: Vec::new(), + }; + }; + + if value.as_encoded_bytes().len() > MAX_LAUNCHER_INFO_BYTES { + return rejected_launcher(IdentityWarning::LauncherInfoTooLarge); + } + + let Some(value) = value.to_str() else { + return rejected_launcher(IdentityWarning::MalformedLauncherInfo); + }; + let Ok(raw) = serde_json::from_str::(value) else { + return rejected_launcher(IdentityWarning::MalformedLauncherInfo); + }; + + for (field, value) in raw_launcher_fields(&raw) { + if value.len() > MAX_REPORTED_STRING_BYTES { + return rejected_launcher(IdentityWarning::LauncherFieldTooLarge { + field: field.to_owned(), + }); + } + } + + ParsedLauncherIdentity { + identity: Some(LauncherIdentity { + wrapper: launcher_package_identity(raw.wrapper), + platform: launcher_package_identity(raw.platform), + executable_path: ReportedPath::from_os_str(OsStr::new(&raw.executable_path)), + }), + warnings: Vec::new(), + } +} + +/// Build installation identity from injected authoritative facts. +#[must_use] +pub fn installation_identity_from_parts( + native_executable: &OsStr, + mcp_version: &str, + hyper_rust_api_version: &str, + launcher_info: Option<&OsStr>, +) -> InstallationIdentity { + let parsed_launcher = parse_launcher_identity(launcher_info); + let mut warnings = parsed_launcher.warnings; + + let (mcp, native_version) = parse_source_version(mcp_version); + if native_version.is_none() { + warnings.push(IdentityWarning::MalformedVersion { + component: "mcp.version".to_owned(), + }); + } + + let (hyper_rust_api, hyper_version) = parse_source_version(hyper_rust_api_version); + if hyper_version.is_none() { + warnings.push(IdentityWarning::MalformedVersion { + component: "hyper_rust_api.version".to_owned(), + }); + } + + if let Some(launcher) = parsed_launcher.identity.as_ref() { + let wrapper_version = parse_launcher_version( + launcher.wrapper.version.as_deref(), + "wrapper.version", + &mut warnings, + ); + let platform_version = parse_launcher_version( + launcher.platform.version.as_deref(), + "platform.version", + &mut warnings, + ); + + if let Some(native_version) = native_version.as_ref() { + let wrapper_mismatch = wrapper_version + .as_ref() + .is_some_and(|version| version != native_version); + let platform_mismatch = platform_version + .as_ref() + .is_some_and(|version| version != native_version); + if wrapper_mismatch || platform_mismatch { + warnings.push(IdentityWarning::VersionMismatch { + native: native_version.to_string(), + wrapper: wrapper_version.map(|version| version.to_string()), + platform: platform_version.map(|version| version.to_string()), + }); + } + } + } + + InstallationIdentity { + native_executable: ReportedPath::from_os_str(native_executable), + mcp, + hyper_rust_api, + launcher: parsed_launcher.identity, + warnings, + } +} + +fn truncate_utf8(value: &mut String, max_bytes: usize) { + if value.len() <= max_bytes { + return; + } + + let mut boundary = max_bytes; + while !value.is_char_boundary(boundary) { + boundary -= 1; + } + value.truncate(boundary); +} + +fn rejected_launcher(warning: IdentityWarning) -> ParsedLauncherIdentity { + ParsedLauncherIdentity { + identity: None, + warnings: vec![warning], + } +} + +fn raw_launcher_fields(raw: &RawLauncherIdentity) -> [(&'static str, &str); 7] { + [ + ("wrapper.name", raw.wrapper.name.as_str()), + ( + "wrapper.version", + raw.wrapper.version.as_deref().unwrap_or_default(), + ), + ("wrapper.package_path", raw.wrapper.package_path.as_str()), + ("platform.name", raw.platform.name.as_str()), + ( + "platform.version", + raw.platform.version.as_deref().unwrap_or_default(), + ), + ("platform.package_path", raw.platform.package_path.as_str()), + ("executable_path", raw.executable_path.as_str()), + ] +} + +fn launcher_package_identity(raw: RawLauncherPackageIdentity) -> LauncherPackageIdentity { + LauncherPackageIdentity { + name: raw.name, + version: raw.version, + package_path: ReportedPath::from_os_str(OsStr::new(&raw.package_path)), + } +} + +fn parse_source_version(source: &str) -> (SourceVersionIdentity, Option) { + let (version, build, suffix_is_valid) = match source.rsplit_once(".r") { + Some((version, build)) => ( + version, + (!build.is_empty()).then(|| build.to_owned()), + !build.is_empty(), + ), + None => (source, None, true), + }; + let parsed = suffix_is_valid + .then(|| Version::parse(version).ok()) + .flatten(); + + ( + SourceVersionIdentity { + source: source.to_owned(), + version: parsed.as_ref().map(ToString::to_string), + build, + }, + parsed, + ) +} + +fn parse_launcher_version( + version: Option<&str>, + component: &'static str, + warnings: &mut Vec, +) -> Option { + let version = version?; + if let Ok(version) = Version::parse(version) { + Some(version) + } else { + warnings.push(IdentityWarning::MalformedVersion { + component: component.to_owned(), + }); + None + } +} + +#[cfg(test)] +pub(crate) fn real_network_test_guard() -> std::sync::MutexGuard<'static, ()> { + static REAL_NETWORK_TEST_LOCK: std::sync::OnceLock> = + std::sync::OnceLock::new(); + REAL_NETWORK_TEST_LOCK + .get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::ffi::OsStr; + use std::io; + use std::panic::{catch_unwind, AssertUnwindSafe}; + use std::path::Path; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + use serde_json::{json, Value}; + use tempfile::TempDir; + + use crate::daemon::discovery::{ + read_discovery_file_raw, DaemonBuildIdentity, DaemonInfo, DaemonRecord, PortScan, + RawDiscoveryRead, + }; + use crate::daemon::health::{DaemonState, HealthListener}; + + use super::{ + collect_doctor_daemon, collect_real_doctor_daemon, real_network_test_guard, + DiscoveryFactMismatch, DoctorCollectRequest, DoctorCollectorDependencies, + DoctorDaemonState, DoctorDaemonWarning, DoctorDeadline, DoctorMoment, DoctorScanCandidate, + DoctorScanRequest, DoctorStatusProbe, ReportedPath, + }; + + #[derive(Debug, Clone, PartialEq, Eq)] + enum RawFixture { + Missing, + Unreadable(io::ErrorKind), + Malformed, + Parsed(Value), + } + + impl RawFixture { + fn read(&self) -> RawDiscoveryRead { + let path = ReportedPath::from_os_str(OsStr::new("/virtual/state/daemon.json")); + match self { + Self::Missing => RawDiscoveryRead::Missing { path }, + Self::Unreadable(kind) => RawDiscoveryRead::Unreadable { path, kind: *kind }, + Self::Malformed => RawDiscoveryRead::Malformed { path }, + Self::Parsed(value) => RawDiscoveryRead::Parsed { + path, + record: serde_json::from_value(value.clone()).unwrap(), + }, + } + } + } + + fn enriched_status(pid: u32, health_port: u16, build: &str, executable: &str) -> Value { + json!({ + "pid": pid, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": health_port, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0", + "identity": { + "mcp_version": build, + "executable_path": ReportedPath::from_os_str(OsStr::new(executable)) + } + }) + } + + fn listener_daemon_info(pid: u32, health_port: u16) -> DaemonInfo { + DaemonInfo { + pid, + hyperd_endpoint: "127.0.0.1:54321".to_string(), + health_port, + started_at: "2026-08-13T12:34:56Z".to_string(), + version: "0.7.0".to_string(), + } + } + + fn adjacent_fake_and_health_listeners() -> (std::net::TcpListener, HealthListener) { + for _ in 0..128 { + let fake = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let fake_port = fake.local_addr().unwrap().port(); + let Some(health_port) = fake_port.checked_add(1) else { + continue; + }; + if let Ok(health) = HealthListener::bind(health_port) { + return (fake, health); + } + } + panic!("could not reserve adjacent OS-selected loopback listeners after 128 attempts"); + } + + fn health_listener_with_adjacent_followers( + follower_count: u16, + ) -> (HealthListener, Vec) { + for _ in 0..128 { + let health = HealthListener::bind(0).unwrap(); + let Some(last_port) = health.port.checked_add(follower_count) else { + continue; + }; + let mut followers = Vec::with_capacity(usize::from(follower_count)); + for port in health.port + 1..=last_port { + let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", port)) else { + break; + }; + followers.push(listener); + } + if followers.len() == usize::from(follower_count) { + return (health, followers); + } + } + panic!("could not reserve a contiguous OS-selected loopback range"); + } + + fn run_invalid_status_then_later_daemon_attempt() -> Vec { + use std::io::{BufRead as _, BufReader, Write as _}; + + let tmp = TempDir::new().unwrap(); + let (fake_listener, health_listener) = adjacent_fake_and_health_listeners(); + let fake_port = fake_listener.local_addr().unwrap().port(); + let health_port = health_listener.port; + fake_listener.set_nonblocking(true).unwrap(); + + let stop_fake = Arc::new(AtomicBool::new(false)); + let fake_stop = Arc::clone(&stop_fake); + let served_ping = Arc::new(AtomicUsize::new(0)); + let fake_served_ping = Arc::clone(&served_ping); + let served_status = Arc::new(AtomicUsize::new(0)); + let fake_served_status = Arc::clone(&served_status); + let (fake_ready_sender, fake_ready_receiver) = mpsc::sync_channel(1); + let wrong_port_status = enriched_status( + 9_090, + health_port, + "0.7.0.rwrong-port", + "/opt/hyperdb/wrong-port-daemon", + ) + .to_string(); + let fake_server = std::thread::spawn(move || -> Result<(), String> { + fake_ready_sender + .send(()) + .map_err(|_| "fake candidate readiness receiver closed".to_string())?; + let deadline = Instant::now() + Duration::from_secs(2); + while !fake_stop.load(Ordering::Acquire) && Instant::now() < deadline { + let mut stream = match fake_listener.accept() { + Ok((stream, _)) => stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => return Err(format!("fake candidate accept failed: {error}")), + }; + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|error| error.to_string())?; + let mut command = String::new(); + let read_result = + BufReader::new(stream.try_clone().map_err(|error| error.to_string())?) + .read_line(&mut command); + match read_result { + Ok(0) if command.is_empty() => continue, + Err(error) + if command.is_empty() + && matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + continue; + } + Ok(_) => {} + Err(error) => { + return Err(format!( + "fake candidate command read failed after {} bytes: {error}", + command.len() + )); + } + } + let (response, served) = match command.trim() { + "PING" => ("PONG hyperdb-mcp 0.7.0\n".to_string(), &fake_served_ping), + "STATUS" => (format!("{wrong_port_status}\n"), &fake_served_status), + other => return Err(format!("fake candidate received {other:?}")), + }; + stream + .write_all(response.as_bytes()) + .map_err(|error| error.to_string())?; + served.fetch_add(1, Ordering::AcqRel); + } + Ok(()) + }); + + let health_state = Arc::new(DaemonState::new()); + let health_info = Arc::new(Mutex::new(listener_daemon_info(9_191, health_port))); + let run_state = Arc::clone(&health_state); + let run_info = Arc::clone(&health_info); + let (health_ready_sender, health_ready_receiver) = mpsc::sync_channel(1); + let health_server = std::thread::spawn(move || { + let _ = health_ready_sender.send(()); + health_listener.run(run_state, run_info); + }); + + let mut failures = Vec::new(); + let fake_ready = fake_ready_receiver + .recv_timeout(Duration::from_millis(500)) + .is_ok(); + if !fake_ready { + failures.push("fake candidate did not signal readiness within 500ms".to_string()); + } + let health_ready = health_ready_receiver + .recv_timeout(Duration::from_millis(500)) + .is_ok(); + if !health_ready { + failures.push("later HealthListener did not signal readiness within 500ms".to_string()); + } + + let report = if fake_ready && health_ready { + if let Ok(report) = catch_unwind(AssertUnwindSafe(|| { + collect_real_doctor_daemon( + Some(&tmp.path().join("missing-daemon.json")), + None, + PortScan { + base: fake_port, + span: 2, + }, + ) + })) { + Some(report) + } else { + failures.push("doctor collector panicked during adjacent scan".to_string()); + None + } + } else { + None + }; + + stop_fake.store(true, Ordering::Release); + health_state.request_shutdown(); + match fake_server.join() { + Ok(Ok(())) => {} + Ok(Err(error)) => failures.push(error), + Err(_) => failures.push("fake candidate server panicked".to_string()), + } + if health_server.join().is_err() { + failures.push("later HealthListener server panicked".to_string()); + } + + let ping_count = served_ping.load(Ordering::Acquire); + let status_count = served_status.load(Ordering::Acquire); + if ping_count == 0 || status_count == 0 { + failures.push(format!( + "fake candidate served PING {ping_count} time(s) and STATUS {status_count} time(s); both must be demonstrated" + )); + } + if let Some(report) = report { + if report.state != DoctorDaemonState::LiveFromScan { + failures.push(format!( + "adjacent scan state was {:?}, expected LiveFromScan", + report.state + )); + } + match report.verified.as_ref() { + Some(verified) + if verified.responding_port == health_port + && verified.record.info().health_port == health_port + && verified.record.info().pid == 9_191 => {} + other => failures.push(format!( + "later real HealthListener did not supply exact fresh facts: {other:?}" + )), + } + if !report.warnings.iter().any(|warning| { + matches!( + warning, + DoctorDaemonWarning::StatusHealthPortMismatch { + responding_port, + reported_port, + } if *responding_port == fake_port && *reported_port == health_port + ) + }) { + failures.push(format!( + "first candidate's wrong-port STATUS was not retained as a warning: {:?}", + report.warnings + )); + } + } + + failures + } + + #[test] + fn real_scan_skips_invalid_status_candidate_and_finds_later_daemon() { + const MAX_SCENARIO_ATTEMPTS: usize = 3; + + let _network_guard = real_network_test_guard(); + let mut attempt_failures = Vec::new(); + for attempt in 1..=MAX_SCENARIO_ATTEMPTS { + let failures = run_invalid_status_then_later_daemon_attempt(); + if failures.is_empty() { + return; + } + attempt_failures.push(format!("attempt {attempt}:\n{}", failures.join("\n"))); + } + + panic!( + "adjacent candidate scan failed all {MAX_SCENARIO_ATTEMPTS} bounded attempts:\n{}", + attempt_failures.join("\n") + ); + } + + #[test] + fn real_scan_verifies_early_daemon_before_slow_later_ports() { + use std::io::{BufRead as _, BufReader, Write as _}; + + const FOLLOWER_COUNT: u16 = 4; + + let _network_guard = real_network_test_guard(); + let (health_listener, slow_listeners) = + health_listener_with_adjacent_followers(FOLLOWER_COUNT); + let health_port = health_listener.port; + + let health_state = Arc::new(DaemonState::new()); + let health_info = Arc::new(Mutex::new(listener_daemon_info(7_171, health_port))); + let run_state = Arc::clone(&health_state); + let run_info = Arc::clone(&health_info); + let health_server = std::thread::spawn(move || health_listener.run(run_state, run_info)); + + let stop_slow_peers = Arc::new(AtomicBool::new(false)); + let slow_peer_commands = Arc::new(AtomicUsize::new(0)); + let mut slow_servers = Vec::new(); + for listener in slow_listeners { + listener.set_nonblocking(true).unwrap(); + let stop = Arc::clone(&stop_slow_peers); + let command_count = Arc::clone(&slow_peer_commands); + slow_servers.push(std::thread::spawn(move || -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(2); + while !stop.load(Ordering::Acquire) && Instant::now() < deadline { + let mut stream = match listener.accept() { + Ok((stream, _)) => stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => { + return Err(format!("slow follower accept failed: {error}")); + } + }; + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|error| error.to_string())?; + let mut command = String::new(); + let read_result = + BufReader::new(stream.try_clone().map_err(|error| error.to_string())?) + .read_line(&mut command); + match read_result { + Ok(0) if command.is_empty() => continue, + Err(error) + if command.is_empty() + && matches!( + error.kind(), + io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock + ) => + { + continue; + } + Ok(_) => {} + Err(error) => { + return Err(format!( + "slow follower command read failed after {} bytes: {error}", + command.len() + )); + } + } + if command.trim() != "PING" { + return Err(format!( + "slow follower received unexpected command {command:?}" + )); + } + command_count.fetch_add(1, Ordering::AcqRel); + + let response_at = Instant::now() + Duration::from_millis(110); + while !stop.load(Ordering::Acquire) && Instant::now() < response_at { + std::thread::sleep(Duration::from_millis(2)); + } + if stop.load(Ordering::Acquire) { + break; + } + match stream.write_all(b"PONG hyperdb-mcp 0.7.0\n") { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::NotConnected + ) => {} + Err(error) => { + return Err(format!("slow follower PONG failed: {error}")); + } + } + } + Ok(()) + })); + } + + let tmp = TempDir::new().unwrap(); + let missing_discovery = tmp.path().join("missing-daemon.json"); + let (result_sender, result_receiver) = mpsc::channel(); + let collector = std::thread::spawn(move || { + let started = Instant::now(); + let report = collect_real_doctor_daemon( + Some(&missing_discovery), + None, + PortScan { + base: health_port, + span: FOLLOWER_COUNT + 1, + }, + ); + let _ = result_sender.send((report, started.elapsed())); + }); + + let bounded_result = result_receiver.recv_timeout(Duration::from_millis(650)); + stop_slow_peers.store(true, Ordering::Release); + health_state.request_shutdown(); + let slow_results = slow_servers + .into_iter() + .map(|server| server.join().unwrap()) + .collect::>(); + health_server.join().unwrap(); + collector.join().unwrap(); + + let mut failures = slow_results + .into_iter() + .filter_map(Result::err) + .collect::>(); + match bounded_result { + Ok((report, elapsed)) => { + if elapsed > Duration::from_millis(650) { + failures.push(format!( + "early-daemon scan completed after its 650ms watchdog: {elapsed:?}" + )); + } + if report.state != DoctorDaemonState::LiveFromScan { + failures.push(format!( + "early-daemon scan state was {:?}, expected LiveFromScan", + report.state + )); + } + match report.verified { + Some(verified) + if verified.responding_port == health_port + && verified.record.info().health_port == health_port + && verified.record.info().pid == 7_171 => {} + other => failures.push(format!( + "healthy first port did not supply exact fresh daemon facts: {other:?}" + )), + } + } + Err(mpsc::RecvTimeoutError::Timeout) => failures.push( + "later identified peers starved the healthy first port past 650ms".to_string(), + ), + Err(mpsc::RecvTimeoutError::Disconnected) => { + failures.push("early-daemon collector disconnected without a report".to_string()); + } + } + let later_commands = slow_peer_commands.load(Ordering::Acquire); + if later_commands != 0 { + failures.push(format!( + "{later_commands} later peer command(s) ran after the healthy first port" + )); + } + + assert!( + failures.is_empty(), + "early daemon scan failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn real_health_listener_accept_cadence_fits_doctor_budget() { + let _network_guard = real_network_test_guard(); + let listener = HealthListener::bind(0).unwrap(); + let port = listener.port; + let state = Arc::new(DaemonState::new()); + let info = Arc::new(Mutex::new(listener_daemon_info(8_181, port))); + let run_state = Arc::clone(&state); + let run_info = Arc::clone(&info); + let listener_thread = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(75)); + listener.run(run_state, run_info); + }); + + let tmp = TempDir::new().unwrap(); + let missing_discovery = tmp.path().join("missing-daemon.json"); + let started = Instant::now(); + let report = collect_real_doctor_daemon( + Some(&missing_discovery), + None, + PortScan { + base: port, + span: 1, + }, + ); + let elapsed = started.elapsed(); + + state.request_shutdown(); + listener_thread.join().unwrap(); + + let mut failures = Vec::new(); + if report.state != DoctorDaemonState::LiveFromScan { + failures.push(format!( + "real HealthListener state was {:?}, expected LiveFromScan", + report.state + )); + } + match report.verified { + Some(verified) + if verified.responding_port == port + && verified.record.info().health_port == port + && verified.record.info().pid == 8_181 => {} + other => failures.push(format!( + "real HealthListener did not yield exact fresh daemon facts: {other:?}" + )), + } + if elapsed > Duration::from_millis(650) { + failures.push(format!( + "real HealthListener collection exceeded the 650ms watchdog: {elapsed:?}" + )); + } + + assert!( + failures.is_empty(), + "real HealthListener budget failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn real_doctor_collector_enforces_global_deadline_against_slow_drip() { + use std::io::{BufRead as _, BufReader, Write as _}; + use std::net::{Shutdown, TcpListener}; + + let _network_guard = real_network_test_guard(); + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + listener.set_nonblocking(true).unwrap(); + let stop_writer = Arc::new(AtomicBool::new(false)); + let server_stop = Arc::clone(&stop_writer); + let server = std::thread::spawn(move || -> Result<(), String> { + let accept_deadline = Instant::now() + Duration::from_secs(2); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if server_stop.load(Ordering::Acquire) || Instant::now() >= accept_deadline + { + return Err("slow-drip peer never received a connection".to_string()); + } + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => return Err(format!("slow-drip accept failed: {error}")), + } + }; + stream + .set_nodelay(true) + .map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(Duration::from_millis(200))) + .map_err(|error| error.to_string())?; + let mut command = String::new(); + BufReader::new(stream.try_clone().map_err(|error| error.to_string())?) + .read_line(&mut command) + .map_err(|error| error.to_string())?; + if command.trim() != "PING" { + return Err(format!( + "slow-drip peer received unexpected command {command:?}" + )); + } + + let write_deadline = Instant::now() + Duration::from_secs(2); + while !server_stop.load(Ordering::Acquire) && Instant::now() < write_deadline { + if stream.write_all(b"x").is_err() || stream.flush().is_err() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + let _ = stream.shutdown(Shutdown::Both); + Ok(()) + }); + + let tmp = TempDir::new().unwrap(); + let missing_discovery = tmp.path().join("missing-daemon.json"); + let (result_sender, result_receiver) = mpsc::channel(); + let collector = std::thread::spawn(move || { + let started = Instant::now(); + let report = collect_real_doctor_daemon( + Some(&missing_discovery), + None, + PortScan { + base: port, + span: 1, + }, + ); + let _ = result_sender.send((report, started.elapsed())); + }); + + let bounded_result = result_receiver.recv_timeout(Duration::from_millis(650)); + stop_writer.store(true, Ordering::Release); + let server_result = server.join().unwrap(); + collector.join().unwrap(); + + let mut failures = Vec::new(); + match bounded_result { + Ok((report, elapsed)) => { + if elapsed > Duration::from_millis(650) { + failures.push(format!( + "collector reported completion after the 650ms watchdog: {elapsed:?}" + )); + } + if report.verified.is_some() { + failures.push("slow-drip foreign peer was accepted as a daemon".to_string()); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => failures.push( + "real collector exceeded 650ms because each drip reset its socket read timeout" + .to_string(), + ), + Err(mpsc::RecvTimeoutError::Disconnected) => { + failures.push("real collector worker disconnected without a report".to_string()); + } + } + if let Err(error) = server_result { + failures.push(error); + } + + assert!( + failures.is_empty(), + "slow-drip deadline failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn collect_doctor_state_matrix_is_pure() { + struct Case { + name: &'static str, + raw: RawFixture, + scan_ports: Vec, + status_responses: Vec<(u16, Value)>, + expected_state: DoctorDaemonState, + expected_live: Option<(u32, u16, &'static str)>, + expected_warnings: Vec, + } + + let discovery_live = enriched_status( + 4_242, + 7_486, + "0.7.0.rdiscovery", + "/opt/hyperdb/discovery-daemon", + ); + let scan_live = enriched_status(5_151, 7_487, "0.7.0.rscan", "/opt/hyperdb/scanned-daemon"); + let cases = vec![ + Case { + name: "missing", + raw: RawFixture::Missing, + scan_ports: vec![], + status_responses: vec![], + expected_state: DoctorDaemonState::Missing, + expected_live: None, + expected_warnings: vec![], + }, + Case { + name: "unreadable", + raw: RawFixture::Unreadable(io::ErrorKind::PermissionDenied), + scan_ports: vec![], + status_responses: vec![], + expected_state: DoctorDaemonState::Unreadable, + expected_live: None, + expected_warnings: vec![DoctorDaemonWarning::DiscoveryUnreadable { + kind: io::ErrorKind::PermissionDenied, + }], + }, + Case { + name: "malformed", + raw: RawFixture::Malformed, + scan_ports: vec![], + status_responses: vec![], + expected_state: DoctorDaemonState::Malformed, + expected_live: None, + expected_warnings: vec![DoctorDaemonWarning::MalformedDiscovery], + }, + Case { + name: "parsed-unreachable", + raw: RawFixture::Parsed(enriched_status( + 4_040, + 7_485, + "0.7.0.rstale", + "/opt/hyperdb/stale-daemon", + )), + scan_ports: vec![], + status_responses: vec![], + expected_state: DoctorDaemonState::ParsedUnreachable, + expected_live: None, + expected_warnings: vec![DoctorDaemonWarning::DiscoveryCandidateUnreachable { + responding_port: 7_485, + }], + }, + Case { + name: "live-from-discovery", + raw: RawFixture::Parsed(discovery_live.clone()), + scan_ports: vec![], + status_responses: vec![(7_486, discovery_live)], + expected_state: DoctorDaemonState::LiveFromDiscovery, + expected_live: Some((4_242, 7_486, "0.7.0.rdiscovery")), + expected_warnings: vec![], + }, + Case { + name: "live-from-scan", + raw: RawFixture::Missing, + scan_ports: vec![7_487], + status_responses: vec![(7_487, scan_live)], + expected_state: DoctorDaemonState::LiveFromScan, + expected_live: Some((5_151, 7_487, "0.7.0.rscan")), + expected_warnings: vec![], + }, + ]; + + let mut failures = Vec::new(); + for case in cases { + let raw_before = case.raw.clone(); + let operations = RefCell::new(Vec::new()); + let read_raw_discovery = || { + operations.borrow_mut().push("raw-reader".to_string()); + case.raw.read() + }; + let probe_enriched_status = |port: u16, deadline: DoctorDeadline| { + operations + .borrow_mut() + .push(format!("status-prober:{port}:{}", deadline.0)); + case.status_responses + .iter() + .find(|(candidate, _)| *candidate == port) + .map_or(DoctorStatusProbe::Unreachable, |(_, response)| { + DoctorStatusProbe::Response(response.to_string()) + }) + }; + let scan_candidates = |request: DoctorScanRequest| { + operations.borrow_mut().push(format!( + "bounded-scanner:{}:{}:{}", + request.ports.base, request.ports.span, request.deadline.0 + )); + case.scan_ports + .iter() + .copied() + .map(|responding_port| DoctorScanCandidate { responding_port }) + .collect() + }; + let now = || { + operations.borrow_mut().push("clock".to_string()); + DoctorMoment(10_000) + }; + let deadline_after = |now: DoctorMoment, timeout: Duration| { + operations + .borrow_mut() + .push(format!("deadline:{}:{}", now.0, timeout.as_millis())); + DoctorDeadline( + now.0 + + u64::try_from(timeout.as_millis()) + .expect("the test timeout fits in u64 milliseconds"), + ) + }; + let dependencies = DoctorCollectorDependencies { + read_raw_discovery: &read_raw_discovery, + probe_enriched_status: &probe_enriched_status, + scan_candidates: &scan_candidates, + now: &now, + deadline_after: &deadline_after, + }; + let request = DoctorCollectRequest { + ports: PortScan { + base: 7_485, + span: 4, + }, + timeout: Duration::from_millis(275), + }; + + match catch_unwind(AssertUnwindSafe(|| { + collect_doctor_daemon(&dependencies, request) + })) { + Ok(report) => { + if report.state != case.expected_state { + failures.push(format!( + "{}: state was {:?}, expected {:?}", + case.name, report.state, case.expected_state + )); + } + match (report.verified.as_ref(), case.expected_live) { + (None, None) => {} + (Some(verified), Some((pid, port, build))) => { + if verified.responding_port != port + || verified.record.info().pid != pid + || verified.record.info().health_port != port + || verified + .record + .identity() + .map(DaemonBuildIdentity::mcp_version) + != Some(build) + { + failures.push(format!( + "{}: collector did not report the fresh verified STATUS facts", + case.name + )); + } + } + (actual, expected) => failures.push(format!( + "{}: verified daemon was {actual:?}, expected {expected:?}", + case.name + )), + } + if report.warnings != case.expected_warnings { + failures.push(format!( + "{}: warnings were {:?}, expected {:?}", + case.name, report.warnings, case.expected_warnings + )); + } + } + Err(_) => failures.push(format!( + "{}: pure doctor collector remains unimplemented", + case.name + )), + } + + if case.raw != raw_before { + failures.push(format!( + "{}: raw discovery fixture was conceptually mutated", + case.name + )); + } + let unexpected = operations + .borrow() + .iter() + .filter(|operation| { + !operation.starts_with("raw-reader") + && !operation.starts_with("status-prober") + && !operation.starts_with("bounded-scanner") + && !operation.starts_with("clock") + && !operation.starts_with("deadline") + }) + .cloned() + .collect::>(); + if !unexpected.is_empty() { + failures.push(format!( + "{}: collector reached non-read dependencies: {unexpected:?}", + case.name + )); + } + } + + assert!( + failures.is_empty(), + "doctor state matrix failures:\n{}", + failures.join("\n") + ); + } + + #[test] + fn candidates_refetch_and_verify_enriched_status() { + #[derive(Clone)] + enum ProbeFixture { + Malformed, + Response(Value), + } + + struct Case { + name: &'static str, + raw: RawFixture, + scan_ports: Vec, + probes: Vec<(u16, ProbeFixture)>, + expected_state: DoctorDaemonState, + expected_fresh_pid: Option, + expected_probed_ports: Vec, + expected_scan: bool, + expected_warnings: Vec, + } + + let recorded_executable = + ReportedPath::from_os_str(OsStr::new("/opt/hyperdb/recorded-daemon")); + let fresh_executable = ReportedPath::from_os_str(OsStr::new("/opt/hyperdb/fresh-daemon")); + let recorded = enriched_status(101, 8_000, "0.7.0.rrecorded", &recorded_executable.display); + let fresh = enriched_status(202, 8_000, "0.7.0.rfresh", &fresh_executable.display); + let cases = vec![ + Case { + name: "discovery-is-refetched-and-fresh-facts-win", + raw: RawFixture::Parsed(recorded), + scan_ports: vec![], + probes: vec![(8_000, ProbeFixture::Response(fresh))], + expected_state: DoctorDaemonState::LiveFromDiscovery, + expected_fresh_pid: Some(202), + expected_probed_ports: vec![8_000], + expected_scan: false, + expected_warnings: vec![DoctorDaemonWarning::StaleOrReplacedDiscovery { + mismatches: vec![ + DiscoveryFactMismatch::Pid { + recorded: 101, + fresh: 202, + }, + DiscoveryFactMismatch::McpVersion { + recorded: "0.7.0.rrecorded".to_string(), + fresh: "0.7.0.rfresh".to_string(), + }, + DiscoveryFactMismatch::ExecutablePath { + recorded: recorded_executable, + fresh: fresh_executable, + }, + ], + }], + }, + Case { + name: "discovery-status-health-port-must-match-responder", + raw: RawFixture::Parsed(enriched_status( + 303, + 8_001, + "0.7.0.rrecorded", + "/opt/hyperdb/discovery-candidate", + )), + scan_ports: vec![], + probes: vec![( + 8_001, + ProbeFixture::Response(enriched_status( + 404, + 9_001, + "0.7.0.rfresh", + "/opt/hyperdb/other-daemon", + )), + )], + expected_state: DoctorDaemonState::ParsedUnreachable, + expected_fresh_pid: None, + expected_probed_ports: vec![8_001], + expected_scan: true, + expected_warnings: vec![DoctorDaemonWarning::StatusHealthPortMismatch { + responding_port: 8_001, + reported_port: 9_001, + }], + }, + Case { + name: "malformed-discovery-status-is-not-live-evidence", + raw: RawFixture::Parsed(enriched_status( + 505, + 8_002, + "0.7.0.rrecorded", + "/opt/hyperdb/discovery-candidate", + )), + scan_ports: vec![], + probes: vec![(8_002, ProbeFixture::Malformed)], + expected_state: DoctorDaemonState::ParsedUnreachable, + expected_fresh_pid: None, + expected_probed_ports: vec![8_002], + expected_scan: true, + expected_warnings: vec![DoctorDaemonWarning::MalformedStatus { + responding_port: 8_002, + }], + }, + Case { + name: "scan-hit-is-refetched-before-becoming-live", + raw: RawFixture::Missing, + scan_ports: vec![8_003], + probes: vec![( + 8_003, + ProbeFixture::Response(enriched_status( + 606, + 8_003, + "0.7.0.rscan-fresh", + "/opt/hyperdb/scan-fresh-daemon", + )), + )], + expected_state: DoctorDaemonState::LiveFromScan, + expected_fresh_pid: Some(606), + expected_probed_ports: vec![8_003], + expected_scan: true, + expected_warnings: vec![], + }, + Case { + name: "scan-status-health-port-must-match-responder", + raw: RawFixture::Missing, + scan_ports: vec![8_004], + probes: vec![( + 8_004, + ProbeFixture::Response(enriched_status( + 707, + 9_004, + "0.7.0.rwrong-port", + "/opt/hyperdb/wrong-port-daemon", + )), + )], + expected_state: DoctorDaemonState::Missing, + expected_fresh_pid: None, + expected_probed_ports: vec![8_004], + expected_scan: true, + expected_warnings: vec![DoctorDaemonWarning::StatusHealthPortMismatch { + responding_port: 8_004, + reported_port: 9_004, + }], + }, + ]; + + let mut failures = Vec::new(); + for case in cases { + let raw_before = case.raw.clone(); + let probed_ports = RefCell::new(Vec::new()); + let scan_requests = RefCell::new(Vec::new()); + let read_raw_discovery = || case.raw.read(); + let probe_enriched_status = |port: u16, deadline: DoctorDeadline| { + probed_ports.borrow_mut().push((port, deadline)); + match case + .probes + .iter() + .find(|(candidate, _)| *candidate == port) + .map(|(_, response)| response) + { + Some(ProbeFixture::Response(response)) => { + DoctorStatusProbe::Response(response.to_string()) + } + Some(ProbeFixture::Malformed) => { + DoctorStatusProbe::Response("{not-valid-json".to_string()) + } + None => DoctorStatusProbe::Unreachable, + } + }; + let scan_candidates = |request: DoctorScanRequest| { + scan_requests.borrow_mut().push(request); + case.scan_ports + .iter() + .copied() + .map(|responding_port| DoctorScanCandidate { responding_port }) + .collect() + }; + let now = || DoctorMoment(60_000); + let deadline_after = |now: DoctorMoment, timeout: Duration| { + DoctorDeadline( + now.0 + + u64::try_from(timeout.as_millis()) + .expect("the test timeout fits in u64 milliseconds"), + ) + }; + let dependencies = DoctorCollectorDependencies { + read_raw_discovery: &read_raw_discovery, + probe_enriched_status: &probe_enriched_status, + scan_candidates: &scan_candidates, + now: &now, + deadline_after: &deadline_after, + }; + let request = DoctorCollectRequest { + ports: PortScan { + base: 8_000, + span: 5, + }, + timeout: Duration::from_millis(125), + }; + + match catch_unwind(AssertUnwindSafe(|| { + collect_doctor_daemon(&dependencies, request) + })) { + Ok(report) => { + if report.state != case.expected_state { + failures.push(format!( + "{}: state was {:?}, expected {:?}", + case.name, report.state, case.expected_state + )); + } + let fresh_pid = report + .verified + .as_ref() + .map(|verified| verified.record.info().pid); + if fresh_pid != case.expected_fresh_pid { + failures.push(format!( + "{}: fresh verified PID was {fresh_pid:?}, expected {:?}", + case.name, case.expected_fresh_pid + )); + } + if report.warnings != case.expected_warnings { + failures.push(format!( + "{}: warnings were {:?}, expected {:?}", + case.name, report.warnings, case.expected_warnings + )); + } + } + Err(_) => failures.push(format!( + "{}: candidate verification collector remains unimplemented", + case.name + )), + } + + let actual_ports = probed_ports + .borrow() + .iter() + .map(|(port, _)| *port) + .collect::>(); + if actual_ports != case.expected_probed_ports { + failures.push(format!( + "{}: STATUS probes were {actual_ports:?}, expected {:?}", + case.name, case.expected_probed_ports + )); + } + if probed_ports + .borrow() + .iter() + .any(|(_, deadline)| *deadline != DoctorDeadline(60_125)) + { + failures.push(format!( + "{}: STATUS probe did not receive the finite shared deadline", + case.name + )); + } + + let expected_scan_requests = usize::from(case.expected_scan); + if scan_requests.borrow().len() != expected_scan_requests { + failures.push(format!( + "{}: bounded scanner call count was {}, expected {expected_scan_requests}", + case.name, + scan_requests.borrow().len() + )); + } + for scan_request in scan_requests.borrow().iter() { + if scan_request.ports + != (PortScan { + base: 8_000, + span: 5, + }) + || scan_request.deadline != DoctorDeadline(60_125) + { + failures.push(format!( + "{}: scan request was not bounded to five ports and 125ms: {scan_request:?}", + case.name + )); + } + } + if case.raw != raw_before { + failures.push(format!( + "{}: candidate verification mutated the raw record", + case.name + )); + } + } + + assert!( + failures.is_empty(), + "candidate verification failures:\n{}", + failures.join("\n") + ); + } + + fn assert_identity_accessors( + identity: &DaemonBuildIdentity, + expected_version: &str, + expected_path: &ReportedPath, + ) { + assert_eq!(identity.mcp_version(), expected_version); + assert_eq!(identity.executable_path(), expected_path); + } + + fn assert_record_accessors(record: &DaemonRecord) { + let _ = record.info(); + let _ = record.identity(); + } + + fn assert_record_contract( + path: &Path, + expected_wire: &Value, + expected_identity: Option<(&str, &ReportedPath)>, + ) { + std::fs::write(path, serde_json::to_vec(expected_wire).unwrap()).unwrap(); + + let record = match read_discovery_file_raw(path) { + RawDiscoveryRead::Parsed { record, .. } => record, + other => panic!("expected parsed raw daemon record, got {other:?}"), + }; + assert_record_accessors(&record); + + let round_trip = serde_json::to_value(&record).unwrap(); + assert_eq!(round_trip, *expected_wire); + assert!( + round_trip.get("info").is_none(), + "legacy daemon fields must remain at the top level" + ); + + let info = record.info(); + assert_eq!(info.pid, 4242); + assert_eq!(info.hyperd_endpoint, "127.0.0.1:54321"); + assert_eq!(info.health_port, 7485); + assert_eq!(info.started_at, "2026-08-13T12:34:56Z"); + assert_eq!(info.version, "0.7.0"); + + match (record.identity(), expected_identity) { + (None, None) => {} + (Some(identity), Some((expected_version, expected_path))) => { + assert_identity_accessors(identity, expected_version, expected_path); + } + (actual, expected) => { + panic!("identity mismatch: actual={actual:?}, expected={expected:?}") + } + } + } + + #[test] + fn doctor_can_inspect_raw_daemon_record() { + let tmp = TempDir::new().unwrap(); + let old_wire = json!({ + "pid": 4242, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7485, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0" + }); + assert_record_contract(&tmp.path().join("old.json"), &old_wire, None); + + let executable_path = + ReportedPath::from_os_str(std::ffi::OsStr::new("/opt/hyperdb/bin/hyperdb-mcp")); + let enriched_wire = json!({ + "pid": 4242, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7485, + "started_at": "2026-08-13T12:34:56Z", + "version": "0.7.0", + "identity": { + "mcp_version": "0.7.0.rabc123", + "executable_path": executable_path + } + }); + assert_record_contract( + &tmp.path().join("enriched.json"), + &enriched_wire, + Some(("0.7.0.rabc123", &executable_path)), + ); + } +} diff --git a/hyperdb-mcp/src/engine.rs b/hyperdb-mcp/src/engine.rs index a6cd0e4..ad4499e 100644 --- a/hyperdb-mcp/src/engine.rs +++ b/hyperdb-mcp/src/engine.rs @@ -77,6 +77,26 @@ pub struct PersistentAttachOutcome { pub file_was_created: bool, } +/// Typed view of the selected chart measure before JSON materialization can +/// erase SQL nullability, non-finite floating-point state, or exact decimal +/// display text. +#[derive(Debug, Clone)] +pub(crate) enum ChartMeasureValue { + Finite { coordinate: f64, display: String }, + NonFinite, + Null, + NonNumeric, +} + +/// JSON rows plus a row-aligned typed sidecar for the chart's measure column. +/// This remains crate-private so ordinary query results keep their established +/// JSON shapes. +#[derive(Debug)] +pub(crate) struct ChartQueryRows { + pub(crate) rows: Vec, + pub(crate) measures: Vec, +} + /// Attach the persistent database under the reserved `"persistent"` /// alias on `connection`, creating the underlying `.hyper` file if it /// doesn't yet exist. Also pins `schema_search_path` to `primary_db_name` @@ -93,24 +113,18 @@ fn attach_default_persistent( "CREATE DATABASE IF NOT EXISTS {}", escape_sql_path(&path_str) ); - connection.execute_command(&create_sql).map_err(|e| { - McpError::new( - ErrorCode::InternalError, - format!("Failed to create persistent database: {e}"), - ) - })?; + connection + .execute_command(&create_sql) + .map_err(|e| persistent_attach_error(e, persistent_path))?; } let attach_sql = format!( "ATTACH DATABASE {path} AS \"{alias}\"", path = escape_sql_path(&path_str), alias = PERSISTENT_ALIAS, ); - connection.execute_command(&attach_sql).map_err(|e| { - McpError::new( - ErrorCode::InternalError, - format!("Failed to attach persistent database: {e}"), - ) - })?; + connection + .execute_command(&attach_sql) + .map_err(|e| persistent_attach_error(e, persistent_path))?; // Pin search_path to the primary so unqualified SQL keeps routing // there even with the persistent attachment present. Mirrors the // logic AttachRegistry uses for user-attached databases. @@ -127,6 +141,58 @@ fn attach_default_persistent( Ok(PersistentAttachOutcome { file_was_created }) } +/// True when a Hyper error from an `ATTACH DATABASE`-class statement means +/// the target `.hyper` file is locked or already owned by another process. +/// +/// SQLSTATE `55006` carries this meaning only in the attach context — the +/// generic `From` conversion in [`crate::error`] +/// deliberately leaves it as `SqlError` elsewhere. Older hyperd versions +/// omit the structured code and emit only a human-readable lock phrase, so +/// both spellings are checked. +fn is_attach_lock_conflict(err: &hyperdb_api::Error) -> bool { + err.sqlstate() == Some("55006") || crate::error::is_resource_busy(&err.to_string()) +} + +/// Converts a Hyper error from the reserved persistent-attachment path. +/// +/// SQLSTATE `55006` has enough meaning to be a lock conflict only here: the +/// reserved persistent database is being created or attached. Older hyperd +/// versions omit the structured SQLSTATE, so retain the established wording +/// fallback for that boundary too. +fn persistent_attach_error(err: hyperdb_api::Error, persistent_path: &Path) -> McpError { + if is_attach_lock_conflict(&err) { + let raw_error = err.to_string(); + return McpError::new( + ErrorCode::ResourceBusy, + format!( + "Failed to attach persistent database {}: {raw_error}", + persistent_path.display() + ), + ) + .with_suggestion( + "The persistent database may be held by another process. Run `hyperdb-mcp doctor` to inspect the configuration and possible owner, then close the possible owner or copy the file before retrying.", + ); + } + + McpError::from(err) +} + +/// Converts a Hyper error from a user-facing `attach_database` on a +/// caller-supplied `.hyper` file. Mirrors [`persistent_attach_error`] for +/// the user attach path so a lock conflict surfaces as +/// [`ErrorCode::ResourceBusy`] — with the default doctor-oriented recovery +/// suggestion from [`crate::error`] — instead of a generic `SqlError`. +fn attach_lock_error(err: hyperdb_api::Error, path: &Path) -> McpError { + if is_attach_lock_conflict(&err) { + return McpError::new( + ErrorCode::ResourceBusy, + format!("Failed to attach database {}: {err}", path.display()), + ); + } + + McpError::from(err) +} + /// File-stem of a `.hyper` path as the unqualified database name Hyper /// uses internally. Falls back to `"scratch"` if the stem can't be read. fn path_stem(path: &Path) -> String { @@ -256,7 +322,7 @@ impl Engine { // Resolve persistent path (if requested) and pre-create its parent dir. let persistent_path = match persistent_db_path.as_deref() { Some(p) => { - let path = PathBuf::from(shellexpand_tilde(p)); + let path = crate::paths::effective_persistent_db_path(std::ffi::OsStr::new(p)); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).map_err(|e| { McpError::new( @@ -394,7 +460,7 @@ impl Engine { // The daemon's discovery file points at this endpoint but we can't // reach it — hyperd is likely dead. Tell the daemon so it can // restart it on its next monitor tick. - daemon::health::report_hyperd_error_to_daemon(); + daemon::health::report_hyperd_error_to_daemon(info.health_port); McpError::new( ErrorCode::InternalError, format!("Failed to connect to daemon hyperd at {endpoint}: {e}"), @@ -697,6 +763,27 @@ impl Engine { self.connection.execute_command(sql).map_err(McpError::from) } + /// Execute an `ATTACH DATABASE` statement for a user-supplied `.hyper` + /// file, mapping a lock conflict (SQLSTATE `55006`, or a legacy + /// "already attached"/"file is locked" phrase from older hyperd) to + /// [`ErrorCode::ResourceBusy`] with actionable recovery guidance. + /// + /// The generic [`Engine::execute_command`] conversion deliberately + /// leaves `55006` as [`ErrorCode::SqlError`] because the code only means + /// "held by another owner" inside the attach context; this method is the + /// attach-context counterpart to `persistent_attach_error` for the + /// user-facing `attach_database` tool. + /// + /// # Errors + /// + /// Returns [`ErrorCode::ResourceBusy`] on a lock conflict, otherwise the + /// same error [`Engine::execute_command`] would produce. + pub fn execute_attach_command(&self, sql: &str, path: &Path) -> Result { + self.connection + .execute_command(sql) + .map_err(|err| attach_lock_error(err, path)) + } + /// Run the given closure inside a database transaction. /// /// Issues `BEGIN TRANSACTION` before calling `f`. If `f` returns `Ok`, @@ -845,6 +932,49 @@ impl Engine { Ok(rows_json) } + /// Execute a chart query while retaining the selected measure's typed + /// state alongside the ordinary JSON rows. The sidecar is row-aligned and + /// is consumed only by the MCP chart renderer. + pub(crate) fn execute_chart_query_to_json( + &self, + sql: &str, + measure_column: Option<&str>, + ) -> Result { + let mut result = self.connection.execute_query(sql).map_err(McpError::from)?; + + let mut rows_json = Vec::new(); + let mut measures = Vec::new(); + let mut schema_opt = None; + while let Some(chunk) = result.next_chunk().map_err(McpError::from)? { + if schema_opt.is_none() { + schema_opt = result.schema(); + } + if let Some(ref schema) = schema_opt { + let columns = schema.columns(); + // JSON object insertion keeps the last duplicate column name, + // so select the same occurrence for the typed sidecar. + let measure = measure_column + .and_then(|name| columns.iter().rev().find(|column| column.name() == name)); + for row in &chunk { + let measure_value = measure.map_or(ChartMeasureValue::NonNumeric, |column| { + chart_measure_value(row, column.index(), &column.sql_type()) + }); + let mut obj = serde_json::Map::new(); + for col in columns { + let val = row_value_to_json(row, col.index(), &col.sql_type()); + obj.insert(col.name().to_string(), val); + } + rows_json.push(Value::Object(obj)); + measures.push(measure_value); + } + } + } + Ok(ChartQueryRows { + rows: rows_json, + measures, + }) + } + /// Create a table from a schema definition. /// /// - `replace = true`: drops the existing table (if any) and recreates it. @@ -1509,7 +1639,7 @@ impl Engine { /// | `BOOL` | `true`/`false` | /// | `SMALL_INT` / `INT` / `BIG_INT` | number | /// | `DOUBLE` / `FLOAT` | number | -/// | `NUMERIC` | number when losslessly representable as `f64`, else string | +/// | `NUMERIC` | number when representable as a finite `f64`, else string | /// | `DATE` | ISO 8601 date string (`YYYY-MM-DD`) | /// | `TIMESTAMP` / `TIMESTAMP_TZ` | ISO 8601 timestamp string | /// | `TEXT` / `VARCHAR` | string | @@ -1565,12 +1695,12 @@ fn row_value_to_json(row: &hyperdb_api::Row, idx: usize, sql_type: &SqlType) -> // handled inside `hyperdb-api`; this function only needs to pick // the JSON shape. // - // `Numeric::to_string()` uses the decoded scale, so round-trip - // through `f64` is only used for JSON compactness — if the - // value doesn't fit in `f64` losslessly (`serde_json::Number:: - // from_f64` returns `None` for NaN/Infinity, and we can't - // always represent large i128 exactly as `f64`), fall back to - // the string form so the caller sees the exact value. + // `Numeric::to_string()` uses the decoded scale. Ordinary query + // results retain their established compact JSON shape: any value + // parseable as a finite `f64` becomes a JSON number, even when that + // conversion rounds, while values outside that domain remain exact + // strings. The chart-only materializer below retains the exact text + // separately for display labels. return row.get::(idx).map_or(Value::Null, |n| { let s = n.to_string(); s.parse::() @@ -1606,6 +1736,66 @@ fn row_value_to_json(row: &hyperdb_api::Row, idx: usize, sql_type: &SqlType) -> row.get::(idx).map_or(Value::Null, Value::String) } +fn chart_measure_value( + row: &hyperdb_api::Row, + idx: usize, + sql_type: &SqlType, +) -> ChartMeasureValue { + use hyperdb_api::oids; + use hyperdb_api::Numeric; + + if row.is_null(idx) { + return ChartMeasureValue::Null; + } + + let oid = sql_type.internal_oid(); + if oid == oids::DOUBLE.0 || oid == oids::FLOAT.0 { + return match row.get::(idx) { + Some(value) if value.is_finite() => ChartMeasureValue::Finite { + coordinate: value, + display: serde_json::Number::from_f64(value) + .map_or_else(|| value.to_string(), |number| number.to_string()), + }, + Some(_) => ChartMeasureValue::NonFinite, + None => ChartMeasureValue::NonNumeric, + }; + } + if oid == oids::NUMERIC.0 { + return row + .get::(idx) + .map_or(ChartMeasureValue::NonNumeric, |numeric| { + let coordinate = numeric.to_f64(); + if coordinate.is_finite() { + ChartMeasureValue::Finite { + coordinate, + display: numeric.to_string(), + } + } else { + ChartMeasureValue::NonFinite + } + }); + } + + let json_value = row_value_to_json(row, idx, sql_type); + match json_value { + Value::Bool(value) => ChartMeasureValue::Finite { + coordinate: if value { 1.0 } else { 0.0 }, + display: value.to_string(), + }, + Value::Number(number) => number + .as_f64() + .map_or(ChartMeasureValue::NonNumeric, |value| { + ChartMeasureValue::Finite { + coordinate: value, + display: number.to_string(), + } + }), + Value::Null | Value::String(_) | Value::Array(_) | Value::Object(_) => { + ChartMeasureValue::NonNumeric + } + } +} + /// Name of the client-side log file written in [`resolve_log_dir`]. /// The MCP binary's `main` opens this file and sets it as a `tracing` /// subscriber target so both startup errors and runtime events land here. @@ -1650,7 +1840,7 @@ pub fn is_internal_table(name: &str) -> bool { pub fn resolve_log_dir(persistent_db_path: Option<&str>) -> PathBuf { match persistent_db_path { Some(p) => { - let expanded = PathBuf::from(shellexpand_tilde(p)); + let expanded = crate::paths::effective_persistent_db_path(std::ffi::OsStr::new(p)); expanded .parent() .map_or_else(|| PathBuf::from("."), std::path::Path::to_path_buf) @@ -1916,47 +2106,6 @@ fn bootstrap_public_schema(connection: &Connection) -> Result<(), McpError> { }) } -/// Minimal `~/` (and `~\` on Windows) expansion. Resolves the home -/// directory via `$HOME` on Unix and `%USERPROFILE%` (falling back to -/// `%HOMEDRIVE%%HOMEPATH%`) on Windows. `~username/` is not supported — -/// callers who need that should expand their paths themselves. -fn shellexpand_tilde(path: &str) -> String { - let rest = if let Some(r) = path.strip_prefix("~/") { - Some(r) - } else if cfg!(windows) { - path.strip_prefix("~\\") - } else { - None - }; - let Some(rest) = rest else { - return path.to_string(); - }; - let Some(home) = home_dir() else { - return path.to_string(); - }; - let sep = std::path::MAIN_SEPARATOR; - format!("{}{sep}{rest}", home.to_string_lossy()) -} - -/// Resolve the user's home directory across platforms. Unix uses `$HOME`; -/// Windows prefers `%USERPROFILE%` and falls back to `%HOMEDRIVE%%HOMEPATH%`. -fn home_dir() -> Option { - if cfg!(windows) { - if let Some(profile) = std::env::var_os("USERPROFILE") { - if !profile.is_empty() { - return Some(PathBuf::from(profile)); - } - } - let drive = std::env::var_os("HOMEDRIVE")?; - let rel = std::env::var_os("HOMEPATH")?; - let mut combined = PathBuf::from(drive); - combined.push(PathBuf::from(rel)); - Some(combined) - } else { - std::env::var_os("HOME").map(PathBuf::from) - } -} - #[cfg(test)] mod statement_helper_tests { use super::*; @@ -2017,3 +2166,113 @@ mod statement_helper_tests { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A lock conflict on a user-facing `attach_database` call must surface + /// as `RESOURCE_BUSY` (with doctor-oriented guidance), mirroring the + /// reserved persistent-attach path — the generic `From` conversion leaves + /// such a `55006` as `SqlError`, so the attach-context mapper is the only + /// place that reclassifies it for the user attach path. + #[test] + fn user_attach_55006_maps_resource_busy() { + let attach_path = PathBuf::from("/tmp/task-user-attach.hyper"); + let upstream = hyperdb_api::Error::server( + Some("55006".to_string()), + "database is already attached by another client connection", + None, + None, + ); + + let mapped = attach_lock_error(upstream, &attach_path); + + assert_eq!(mapped.code, ErrorCode::ResourceBusy); + assert!( + mapped.message.contains("55006"), + "must retain SQLSTATE evidence: {}", + mapped.message + ); + assert!( + mapped.message.contains("already attached"), + "must retain Hyper's raw diagnostic: {}", + mapped.message + ); + assert!( + mapped.message.contains(attach_path.to_str().unwrap()), + "must name the attach path: {}", + mapped.message + ); + let guidance = mapped + .suggestion + .expect("RESOURCE_BUSY needs recovery guidance"); + assert!( + guidance.to_lowercase().contains("doctor"), + "guidance must direct callers to doctor: {guidance}" + ); + } + + /// An unrelated `55006` from ordinary SQL (not an attach) must keep its + /// generic mapping even through the attach-context mapper — only genuine + /// attach-lock phrasing / the attach call site should reclassify. + #[test] + fn user_attach_maps_non_lock_error_generically() { + let upstream = hyperdb_api::Error::server( + Some("42601".to_string()), + "syntax error at or near \"ATTACH\"", + None, + None, + ); + + let mapped = attach_lock_error(upstream, &PathBuf::from("/tmp/task-user-attach.hyper")); + + assert_eq!(mapped.code, ErrorCode::SqlError); + assert_ne!(mapped.code, ErrorCode::ResourceBusy); + } + + /// SQLSTATE 55006 is only a lock conflict in the reserved persistent + /// attachment path. The conversion must retain Hyper's diagnostics while + /// adding actionable, non-accusatory recovery guidance for that path. + #[test] + fn persistent_attach_55006_maps_resource_busy() { + let persistent_path = PathBuf::from("/tmp/task7-persistent-workspace.hyper"); + let upstream = hyperdb_api::Error::server( + Some("55006".to_string()), + "database is already attached by another client connection", + None, + None, + ); + + let mapped = persistent_attach_error(upstream, &persistent_path); + + assert_eq!(mapped.code, ErrorCode::ResourceBusy); + assert!( + mapped.message.contains("55006"), + "must retain SQLSTATE evidence: {}", + mapped.message + ); + assert!( + mapped.message.contains("already attached"), + "must retain Hyper's raw diagnostic: {}", + mapped.message + ); + assert!( + mapped.message.contains(persistent_path.to_str().unwrap()), + "must name the exact effective persistent path: {}", + mapped.message + ); + let guidance = mapped + .suggestion + .expect("RESOURCE_BUSY needs recovery guidance"); + let lower = guidance.to_lowercase(); + assert!( + lower.contains("doctor"), + "guidance must direct callers to doctor: {guidance}" + ); + assert!( + lower.contains("possible") && (lower.contains("owner") || lower.contains("process")), + "guidance must describe a possible owner without accusing one: {guidance}" + ); + } +} diff --git a/hyperdb-mcp/src/error.rs b/hyperdb-mcp/src/error.rs index 84ee148..b556d4e 100644 --- a/hyperdb-mcp/src/error.rs +++ b/hyperdb-mcp/src/error.rs @@ -16,7 +16,7 @@ use serde::Serialize; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum ErrorCode { - /// The `hyperd` binary was not found at `HYPERD_PATH` or on `PATH`. + /// The `hyperd` binary was not found at the configured `HYPERD_PATH`. HyperdNotFound, /// A file path argument points to a nonexistent or unreadable file. FileNotFound, @@ -115,7 +115,7 @@ impl std::error::Error for McpError {} /// phrased as instructions so an LLM can act on them directly. fn default_suggestion(code: ErrorCode, _message: &str) -> Option { match code { - ErrorCode::HyperdNotFound => Some("Set HYPERD_PATH environment variable or ensure hyperd is on PATH".into()), + ErrorCode::HyperdNotFound => Some("Set HYPERD_PATH to the hyperd executable (or its containing directory).".into()), ErrorCode::FileNotFound => Some("Verify the file path exists and is accessible".into()), ErrorCode::UnsupportedFormat => Some("Specify format explicitly: json, csv, parquet, or arrow_ipc".into()), ErrorCode::SchemaMismatch => Some("Retry with an explicit schema override".into()), @@ -127,7 +127,7 @@ fn default_suggestion(code: ErrorCode, _message: &str) -> Option { ErrorCode::ReadOnlyViolation => Some("Server is in read-only mode. Use query_data or query_file for one-shot analysis, or restart without --read-only.".into()), ErrorCode::ConnectionLost => Some("The hyperd connection was lost or fell out of wire-protocol sync. Retry the request — the server will tear down the engine and reconnect automatically.".into()), ErrorCode::InvalidArgument => Some("Check the tool argument shape and allowed values. The message identifies the offending field.".into()), - ErrorCode::ResourceBusy => Some("The .hyper file is held by another process. Close the other MCP server (or hyperd instance) that owns it, or copy the file first and attach the copy.".into()), + ErrorCode::ResourceBusy => Some("The .hyper file may be held by another process. Run `hyperdb-mcp doctor` to inspect the configuration and possible owner; then close that possible owner or copy the file before attaching the copy.".into()), ErrorCode::InternalError => None, } } @@ -192,9 +192,12 @@ impl From for McpError { return McpError::new(ErrorCode::ConnectionLost, msg); } - // Resource-busy is a hyperd attach-time error; same multi-source - // problem as connection-lost. - if is_resource_busy(&msg) { + // Older hyperd versions can report attach contention only as a + // human-readable phrase. Do not let that fallback override an + // explicit SQLSTATE: structured server errors need the reserved + // persistent-attach boundary to determine whether `55006` is a + // database lock rather than an unrelated object-in-use conflict. + if err.sqlstate().is_none() && is_resource_busy(&msg) { return McpError::new(ErrorCode::ResourceBusy, msg); } @@ -311,7 +314,7 @@ pub fn is_connection_lost(msg: &str) -> bool { /// [`ErrorCode::ResourceBusy`] instead of a generic internal error. /// Matches the wording hyperd uses when a `.hyper` file is locked or /// already attached by another process. -fn is_resource_busy(msg: &str) -> bool { +pub(crate) fn is_resource_busy(msg: &str) -> bool { let lower = msg.to_lowercase(); lower.contains("already attached") || lower.contains("database is in use") diff --git a/hyperdb-mcp/src/lib.rs b/hyperdb-mcp/src/lib.rs index a7179a9..56958a5 100644 --- a/hyperdb-mcp/src/lib.rs +++ b/hyperdb-mcp/src/lib.rs @@ -21,7 +21,7 @@ //! - [`schema`] — Three-tier schema inference: exact (Arrow/Parquet), structural (JSON), //! heuristic (CSV). Also handles user-provided schema overrides. //! - [`engine`] — Manages the `HyperProcess` lifecycle, connection, table CRUD, and -//! query execution. Supports ephemeral and persistent workspace modes. +//! query execution across the local database and optional persistent database. //! - [`ingest`] — Loads inline JSON (row-by-row INSERT) and CSV (`COPY FROM`) into Hyper. //! - [`ingest_arrow`] — Loads Parquet and Arrow IPC files via the Arrow crate. //! - [`inspect`] — Dry-run file inspection powering the `inspect_file` MCP tool. @@ -39,6 +39,7 @@ pub mod attach; pub mod chart; pub mod daemon; +pub mod diagnostics; pub mod engine; pub mod error; pub mod export; diff --git a/hyperdb-mcp/src/main.rs b/hyperdb-mcp/src/main.rs index b2e9426..607a4bd 100644 --- a/hyperdb-mcp/src/main.rs +++ b/hyperdb-mcp/src/main.rs @@ -3,7 +3,8 @@ //! Binary entry point for the `hyperdb-mcp` MCP server. //! -//! Starts an MCP server on stdio, optionally backed by a persistent workspace. +//! Starts an MCP server on stdio with a local database and optional persistent +//! attachment. //! Can also run in daemon mode to manage a shared `hyperd` process. //! //! # Logging @@ -25,6 +26,7 @@ use hyperdb_mcp::daemon; use hyperdb_mcp::daemon::discovery; use hyperdb_mcp::daemon::health; use hyperdb_mcp::daemon::run::DaemonConfig; +use hyperdb_mcp::diagnostics::{self, DoctorOptions}; use hyperdb_mcp::engine::{resolve_log_dir, CLIENT_LOG_FILE_NAME}; use hyperdb_mcp::paths; use hyperdb_mcp::server::HyperMcpServer; @@ -40,7 +42,8 @@ const VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), ".r", env!("HYPERDB_GIT #[command( name = "hyperdb-mcp", version = VERSION, - about = "MCP server for Hyper database analytics" + about = "MCP server for Hyper database analytics", + long_about = "MCP server for Hyper database analytics. HYPERD_PATH accepts either the hyperd executable or its containing directory. When HYPERD_PATH is absent or non-UTF-8, runtime resolution searches upward through current-directory ancestors for .hyperd/current/hyperd; no general PATH lookup is performed." )] struct Cli { #[command(subcommand)] @@ -58,12 +61,17 @@ struct Cli { workspace: Option, /// Skip opening any persistent database. The session has only the - /// ephemeral primary plus any user-attached databases. Disables + /// local database plus any user-attached databases. Disables /// `save_query` persistence (queries fall back to session storage). #[arg(long, global = true)] ephemeral_only: bool, - /// Run in read-only mode: disables execute, `load_data`, `load_file`, and export to hyper format + /// Run in read-only mode. Guards `execute`, `load_data`, `load_file`, + /// `load_files`, `load_iceberg`, `watch_directory`, `save_query`, + /// `delete_query`, `set_table_metadata`, `copy_query`, `kv_set`, + /// `kv_set_many`, `kv_delete`, `kv_pop`, `kv_clear`, and writable/create + /// `attach_database`. Read-only `attach_database` remains available; + /// `unwatch_directory` and `export` (including Hyper format) stay available. #[arg(long, global = true)] read_only: bool, @@ -73,6 +81,17 @@ struct Cli { } impl Cli { + fn validate_persistent_options(&self) -> Result<(), &'static str> { + if self.ephemeral_only && (self.persistent_db.is_some() || self.workspace.is_some()) { + return Err("--ephemeral-only is incompatible with --persistent-db / --workspace"); + } + if self.persistent_db.is_some() && self.workspace.is_some() { + return Err("Both --persistent-db and --workspace were supplied. \ + --workspace is a deprecated alias; pass only --persistent-db."); + } + Ok(()) + } + /// Translate the deprecated `--workspace` flag to `--persistent-db`, /// emitting a one-time deprecation warning, and resolve the final /// persistent path according to the precedence rules in @@ -82,38 +101,44 @@ impl Cli { /// Errors out if both `--persistent-db` and `--workspace` are /// supplied — there's no sensible "winner", so be loud about it. fn resolve_persistent_path(&self) -> Result, &'static str> { - if self.ephemeral_only { - if self.persistent_db.is_some() || self.workspace.is_some() { - return Err("--ephemeral-only is incompatible with --persistent-db / --workspace"); - } - return Ok(None); - } - if self.persistent_db.is_some() && self.workspace.is_some() { - return Err("Both --persistent-db and --workspace were supplied. \ - --workspace is a deprecated alias; pass only --persistent-db."); - } + self.validate_persistent_options()?; if self.workspace.is_some() { eprintln!( "warning: --workspace is deprecated; use --persistent-db instead. \ The old flag will be removed in a future release." ); } - let cli_value = self.persistent_db.as_deref().or(self.workspace.as_deref()); - Ok(paths::resolve_persistent_db_path(cli_value)) + Ok(paths::resolve_persistent_db_path_with_source( + self.persistent_db.as_deref(), + self.workspace.as_deref(), + self.ephemeral_only, + ) + .observed_path) } } #[derive(Subcommand)] enum Commands { - /// Run as a background daemon managing a shared hyperd process + /// Side-effect-free installation/configuration/identity diagnostics; starts no Hyper or database + Doctor { + /// Emit the typed report as JSON + #[arg(long)] + json: bool, + }, + + /// Run a foreground daemon managing shared hyperd. Auto-spawn scans before + /// launching; foreground startup binds its configured/base port exactly. Daemon { #[command(subcommand)] action: Option, - /// TCP port for health listener and single-instance lock. When omitted, - /// the daemon scans from the base port to find a free port. For stop/status - /// commands, omitting the port uses discovery + scanning to find the running daemon. - #[arg(long)] + /// Exact TCP health/lock port for foreground startup. Without `--port`, + /// the foreground daemon binds the configured/base port exactly + /// (`HYPERDB_DAEMON_PORT` when valid, otherwise 7485) and does not scan. + /// Auto-spawn performs bounded discovery from its configured base before + /// launching. For stop/status, omitting the port uses discovery plus + /// scanning. + #[arg(long, global = true)] port: Option, /// Idle timeout in seconds before the daemon shuts down @@ -132,9 +157,11 @@ enum DaemonAction { #[tokio::main] async fn main() -> Result<(), Box> { - let cli = Cli::parse(); + let mut cli = Cli::parse(); + let command = cli.command.take(); - match cli.command { + match command { + Some(Commands::Doctor { json }) => run_doctor_mode(&cli, json), Some(Commands::Daemon { action: Some(DaemonAction::Stop), port, @@ -145,9 +172,10 @@ async fn main() -> Result<(), Box> { } Some(Commands::Daemon { action: Some(DaemonAction::Status), + port, .. }) => { - daemon_status(); + daemon_status(port); Ok(()) } Some(Commands::Daemon { @@ -163,6 +191,26 @@ async fn main() -> Result<(), Box> { } } +fn run_doctor_mode(cli: &Cli, json: bool) -> Result<(), Box> { + if let Err(message) = cli.validate_persistent_options() { + eprintln!("error: {message}"); + std::process::exit(2); + } + let report = diagnostics::collect_doctor_report(DoctorOptions { + persistent_db: cli.persistent_db.as_deref(), + deprecated_workspace: cli.workspace.as_deref(), + ephemeral_only: cli.ephemeral_only, + read_only: cli.read_only, + no_daemon: cli.no_daemon, + })?; + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print!("{}", diagnostics::render_doctor_human(&report)); + } + Ok(()) +} + async fn run_daemon_mode( port: u16, idle_timeout: Option, @@ -273,16 +321,32 @@ fn daemon_stop(port: Option) { } } -fn daemon_status() { - if let Some(info) = discovery::find_running_daemon() { - println!("Daemon is running:"); - println!(" PID: {}", info.pid); - println!(" Hyperd endpoint: {}", info.hyperd_endpoint); - println!(" Health port: {}", info.health_port); - println!(" Started: {}", info.started_at); - println!(" Version: {}", info.version); +fn daemon_status(port: Option) { + let info = if let Some(port) = port { + match health::send_command(port, "STATUS") { + Ok(response) => match serde_json::from_str::(response.trim()) { + Ok(info) => info, + Err(e) => { + eprintln!("Daemon on port {port} returned invalid status: {e}"); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("No daemon running on port {port} (or cannot connect): {e}"); + std::process::exit(1); + } + } + } else if let Some(info) = discovery::find_running_daemon() { + info } else { eprintln!("No daemon is currently running."); std::process::exit(1); - } + }; + + println!("Daemon is running:"); + println!(" PID: {}", info.pid); + println!(" Hyperd endpoint: {}", info.hyperd_endpoint); + println!(" Health port: {}", info.health_port); + println!(" Started: {}", info.started_at); + println!(" Version: {}", info.version); } diff --git a/hyperdb-mcp/src/paths.rs b/hyperdb-mcp/src/paths.rs index b36b789..5f0ddbd 100644 --- a/hyperdb-mcp/src/paths.rs +++ b/hyperdb-mcp/src/paths.rs @@ -21,8 +21,11 @@ //! 2. `HYPERDB_PERSISTENT_DB` environment variable. //! 3. Platform default via [`dirs::data_dir`]. +use std::ffi::OsStr; use std::path::PathBuf; +use serde::Serialize; + /// Application directory name used inside the platform data dir. const APP_DIR_NAME: &str = "hyperdb"; @@ -32,6 +35,36 @@ const PERSISTENT_DB_FILENAME: &str = "workspace.hyper"; /// Environment variable that overrides the platform-default path. pub const ENV_PERSISTENT_DB: &str = "HYPERDB_PERSISTENT_DB"; +/// The winning input in persistent-database path resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PersistentDbPathSource { + /// The preferred `--persistent-db` CLI flag. + Cli, + /// The deprecated `--workspace` CLI alias. + DeprecatedAlias, + /// The `HYPERDB_PERSISTENT_DB` environment variable. + Environment, + /// The platform data-directory default. + PlatformDefault, + /// Persistent storage was explicitly disabled with `--ephemeral-only`. + Disabled, +} + +/// A persistent-database resolution result that retains its provenance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPersistentDbPath { + /// Effective UTF-8-compatible runtime path after lossy conversion and + /// literal home-prefix expansion, absent only when persistence is disabled + /// or no platform data directory is available. + pub path: Option, + /// Exact operating-system path supplied by the winning source, before the + /// runtime's lossy string conversion and literal home-prefix expansion. + pub observed_path: Option, + /// The source that won precedence resolution. + pub source: PersistentDbPathSource, +} + /// Returns the platform-default path for the persistent database. Returns /// `None` if the home / data directory cannot be determined (rare; usually /// indicates a misconfigured environment). @@ -54,15 +87,105 @@ pub fn default_persistent_db_path() -> Option { /// the env var and the platform default. #[must_use] pub fn resolve_persistent_db_path(cli_value: Option<&str>) -> Option { - if let Some(p) = cli_value { - return Some(PathBuf::from(p)); + if let Some(path) = cli_value { + return Some(PathBuf::from(path)); } - if let Some(p) = std::env::var_os(ENV_PERSISTENT_DB) { - return Some(PathBuf::from(p)); + if let Some(path) = std::env::var_os(ENV_PERSISTENT_DB) { + return Some(PathBuf::from(path)); } default_persistent_db_path() } +/// Resolve the persistent database while retaining the winning source. +/// +/// Callers validate mutually exclusive CLI flags before invoking this helper. +/// Selection then preserves the existing CLI > environment > platform-default +/// precedence, while distinguishing the preferred and deprecated CLI spellings. +#[must_use] +pub fn resolve_persistent_db_path_with_source( + cli_value: Option<&str>, + deprecated_alias: Option<&str>, + disabled: bool, +) -> ResolvedPersistentDbPath { + if disabled { + return ResolvedPersistentDbPath { + path: None, + observed_path: None, + source: PersistentDbPathSource::Disabled, + }; + } + if let Some(path) = cli_value { + return resolved_persistent_path(PathBuf::from(path), PersistentDbPathSource::Cli); + } + if let Some(path) = deprecated_alias { + return resolved_persistent_path( + PathBuf::from(path), + PersistentDbPathSource::DeprecatedAlias, + ); + } + if let Some(path) = std::env::var_os(ENV_PERSISTENT_DB) { + return resolved_persistent_path(PathBuf::from(path), PersistentDbPathSource::Environment); + } + match default_persistent_db_path() { + Some(path) => resolved_persistent_path(path, PersistentDbPathSource::PlatformDefault), + None => ResolvedPersistentDbPath { + path: None, + observed_path: None, + source: PersistentDbPathSource::PlatformDefault, + }, + } +} + +fn resolved_persistent_path( + observed_path: PathBuf, + source: PersistentDbPathSource, +) -> ResolvedPersistentDbPath { + let path = Some(effective_persistent_db_path(observed_path.as_os_str())); + ResolvedPersistentDbPath { + path, + observed_path: Some(observed_path), + source, + } +} + +/// Apply the same string conversion and literal `~/` expansion used before an +/// [`crate::engine::Engine`] opens a persistent database. +pub(crate) fn effective_persistent_db_path(path: &OsStr) -> PathBuf { + let path = path.to_string_lossy(); + let rest = if let Some(rest) = path.strip_prefix("~/") { + Some(rest) + } else if cfg!(windows) { + path.strip_prefix("~\\") + } else { + None + }; + let Some(rest) = rest else { + return PathBuf::from(path.as_ref()); + }; + let Some(home) = persistent_home_dir() else { + return PathBuf::from(path.as_ref()); + }; + let separator = std::path::MAIN_SEPARATOR; + PathBuf::from(format!("{}{separator}{rest}", home.to_string_lossy())) +} + +fn persistent_home_dir() -> Option { + if cfg!(windows) { + if let Some(profile) = std::env::var_os("USERPROFILE") { + if !profile.is_empty() { + return Some(PathBuf::from(profile)); + } + } + let drive = std::env::var_os("HOMEDRIVE")?; + let relative = std::env::var_os("HOMEPATH")?; + let mut combined = PathBuf::from(drive); + combined.push(PathBuf::from(relative)); + Some(combined) + } else { + std::env::var_os("HOME").map(PathBuf::from) + } +} + #[cfg(test)] mod tests { use super::*; @@ -171,4 +294,115 @@ mod tests { assert!(p.to_string_lossy().contains("hyperdb")); }); } + + #[test] + fn nested_tilde_source_resolution_preserves_raw_input_for_one_runtime_expansion() { + use std::io::Read as _; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + const CHILD_SENTINEL: &str = "HYPERDB_MCP_PATHS_NESTED_TILDE_CHILD"; + const CHILD_SENTINEL_VALUE: &str = "nested-tilde-source-child-v1"; + const TEST_NAME: &str = + "paths::tests::nested_tilde_source_resolution_preserves_raw_input_for_one_runtime_expansion"; + + if std::env::var(CHILD_SENTINEL).as_deref() == Ok(CHILD_SENTINEL_VALUE) { + let legacy = resolve_persistent_db_path(Some("~/data.hyper")); + assert_eq!( + legacy, + Some(PathBuf::from("~/data.hyper")), + "legacy callers must continue receiving the raw winning path" + ); + + let source_aware = + resolve_persistent_db_path_with_source(Some("~/data.hyper"), None, false); + assert_eq!(source_aware.source, PersistentDbPathSource::Cli); + assert_eq!( + source_aware.observed_path, + Some(PathBuf::from("~/data.hyper")), + "source-aware resolution must retain the raw value main can pass to Engine" + ); + assert_eq!( + source_aware.path, + Some(PathBuf::from("~/outer").join("data.hyper")), + "diagnostics may report exactly one literal home-prefix expansion" + ); + return; + } + + let mut child = + Command::new(std::env::current_exe().expect("locate current libtest binary")) + .arg("--exact") + .arg(TEST_NAME) + .arg("--nocapture") + .env(CHILD_SENTINEL, CHILD_SENTINEL_VALUE) + .env("HOME", "~/outer") + .env("USERPROFILE", "~/outer") + .env_remove(ENV_PERSISTENT_DB) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn isolated nested-tilde libtest child"); + let mut child_stdout = child.stdout.take().expect("capture child stdout"); + let mut child_stderr = child.stderr.take().expect("capture child stderr"); + let stdout_reader = std::thread::spawn(move || { + let mut output = Vec::new(); + child_stdout + .read_to_end(&mut output) + .expect("read nested-tilde child stdout"); + output + }); + let stderr_reader = std::thread::spawn(move || { + let mut output = Vec::new(); + child_stderr + .read_to_end(&mut output) + .expect("read nested-tilde child stderr"); + output + }); + + let deadline = Instant::now() + Duration::from_secs(10); + let completion = loop { + match child.try_wait() { + Ok(Some(status)) => break Ok(status), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + let kill_result = child.kill(); + let wait_result = child.wait(); + break Err(format!( + "nested-tilde child exceeded ten-second watchdog; kill={kill_result:?}; wait={wait_result:?}" + )); + } + Err(error) => { + let kill_result = child.kill(); + let wait_result = child.wait(); + break Err(format!( + "could not poll nested-tilde child: {error}; kill={kill_result:?}; wait={wait_result:?}" + )); + } + } + }; + let stdout = stdout_reader + .join() + .expect("nested-tilde stdout reader must not panic"); + let stderr = stderr_reader + .join() + .expect("nested-tilde stderr reader must not panic"); + let stdout = String::from_utf8_lossy(&stdout); + let stderr = String::from_utf8_lossy(&stderr); + + let status = completion.unwrap_or_else(|error| { + panic!("{error}\nchild stdout:\n{stdout}\nchild stderr:\n{stderr}") + }); + assert!( + status.success(), + "nested-tilde child failed with {status}\nchild stdout:\n{stdout}\nchild stderr:\n{stderr}" + ); + assert!( + stdout.contains("running 1 test") && stdout.contains(TEST_NAME), + "nested-tilde child exact filter did not execute one named test:\n{stdout}" + ); + } } diff --git a/hyperdb-mcp/src/readme.rs b/hyperdb-mcp/src/readme.rs index 01873c8..c2c9d07 100644 --- a/hyperdb-mcp/src/readme.rs +++ b/hyperdb-mcp/src/readme.rs @@ -24,66 +24,44 @@ Iceberg, Arrow IPC, CSV, or .hyper. Whenever the user asks to analyze tabular data, run SQL, transform a file, or build a chart from a query. Prefer this MCP over ad-hoc Python or shell pipelines: it parses files faster, runs SQL natively, and keeps -intermediate state in a workspace database the LLM can re-query without +intermediate state in a local database the LLM can re-query without re-loading. -## Workspace model — queryable memory +## Database model — queryable memory Every session has TWO databases, plus optional user-attached ones: -- **Ephemeral primary** (default destination). Created fresh per - session, deleted on exit. Unqualified SQL routes here. Use as - scratch space for exploratory work, intermediate transformations, - and one-off analysis the user doesn't need to keep. +- **Local database** (ephemeral and the default destination). Created + fresh per session, deleted on exit, and addressed as `\"local\"`. + Unqualified SQL routes here; use it for scratch data. - **Persistent database** (alias `\"persistent\"`). Survives across - sessions — this is your **long-term structured memory**. Store - reference tables, accumulated results, user preferences, learned - facts, or any data you want to recall in future conversations. - Unlike flat-text memory, persistent data is **queryable**: you can - JOIN, filter, aggregate, and reason over it with SQL. Disabled - when the server runs with `--ephemeral-only`. -- **User-attached writable databases** via `attach_database` with - `writable: true`. Each lives in its own `.hyper` file under a - user-chosen alias. - -### Persistent as memory — when and how to use it - -Store data in persistent whenever: -- The user says \"remember this\", \"save this\", \"keep this\" -- You produce a useful reference table (lookups, configs, mappings) -- You accumulate results across multiple conversations -- You want to recall context in future sessions - -Retrieve from persistent whenever: -- You need context from a prior session -- The user asks \"what do we have?\" or \"show me what's saved\" -- You want to JOIN current scratch work against historical data + sessions as queryable long-term structured memory. Disabled with + `--ephemeral-only`. +- **Attached databases** via `attach_database`. Each lives in its own + `.hyper` file under a user-chosen canonical lowercase alias. Attachments + are read-only by default; pass `writable: true` when writes are needed. + +### Persistent as memory + +Use persistent for reference data, preferences, accumulated results, or +anything the user asks to remember or query in a future session. ``` -// Save something for later load_data({ table: \"project_decisions\", data: \"[...]\", persist: true }) - -// Recall it next session query({ sql: \"SELECT * FROM project_decisions\", database: \"persistent\" }) - -// Cross-reference: join session scratch with persistent memory -query({ sql: \"SELECT s.*, p.decision FROM scratch_analysis s \ - JOIN \\\"persistent\\\".\\\"public\\\".\\\"project_decisions\\\" p \ - ON s.topic = p.topic\" }) ``` ### KV store vs. a custom table — which to remember with When you need to remember something, pick the lighter tool: -- **A few scraps** — a variable, a flag, a summary, a JSON blob, a - work-queue entry — use the key-value store (`kv_set` / `kv_get`). No - schema, no DDL, no `load_data`. See `Tool index → Key-value store`. +- **A few scraps** — variables, flags, summaries, JSON, queue entries — + use the key-value store (`kv_set` / `kv_get`); no schema or DDL. - **Structured rows** you'll filter, JOIN, or aggregate — use a real table (`load_data` / `execute CREATE TABLE`), so SQL can reason over typed columns. -Both persist the same way: default is the EPHEMERAL database (lost on +Both persist the same way: default is the local database (lost on restart); pass `database: \"persistent\"` to keep either one across sessions. The KV and `load_*` tools also accept the `persist: true` shorthand; `execute` takes `database` only. @@ -94,19 +72,50 @@ shorthand; `execute` takes `database` only. SQL): `query`, `execute`, `load_data`, `load_file`, `load_files`, `watch_directory`, `describe`, `sample`, `chart`, `export`, and `set_table_metadata` accept `database: \"persistent\"`, - `database: \"local\"` (= primary), or any user-attached writable - alias. Case-insensitive. Defaults to primary. + `database: \"local\"`, or any user-attached alias (write tools require + a writable attachment). Case-insensitive. Defaults to local. - **`persist: true` shorthand** on `load_data`, `load_file`, `load_files`, `watch_directory` — equivalent to `database: \"persistent\"`. - **Fully-qualified SQL** for power users: `INSERT INTO \"persistent\".\"public\".\"customers\" SELECT ...` -Each writable database carries its own `_table_catalog` table that -tracks load tool, params, timestamps, and any prose metadata set via -`set_table_metadata` — lazily seeded on first ingest into that DB. -`detach_database` rejects with `InvalidArgument` if any active -watcher targets the alias; call `unwatch_directory` first. +### Chart delivery and presentation + +Long and Unicode labels are not truncated or auto-sized; if they clip, +increase `width` or `height`. +Log bars start at the positive lower bound, not zero. + +`chart` is a bounded quick diagnostic, not a dashboard system. With no +`output_path`, its PNG (default) or SVG is returned `inline` and no file +is written. Supplying `output_path` writes the file and still returns it +inline; set `inline=false` for disk-only output (an omitted path then gets +an auto-generated temp file). Set `overwrite=false` to refuse an existing +destination. The path extension and explicit `format`, when both supplied, +must agree. Use `database` to route the SQL to local, persistent, or an +attached database. + +Bars are vertical by default; set `bar_orientation` to `horizontal` for +rankings. `label_values=true` writes each original y scalar beside its +bar. `show_legend` defaults to true; set it false to suppress the legend. +With a `series` column, `color_map` maps series names to hex colors; +`label_points=true` labels line/scatter points and suppresses their legend. +`y_scale` defaults to `linear`; `log` +applies to the data-role y measure, including the physical x axis of +horizontal bars. Log values and ranges must be finite and strictly +positive (> 0): zero and negative values are invalid, logarithmic +histograms are unsupported, and an explicit range must contain every +plotted value. Explicit `x_range` and `y_range` bounds must also be finite, +strictly increasing, and representable. + +Line/scatter DATE, TIMESTAMP, and TIMESTAMPTZ x values use a proportional +time axis automatically. TEXT is categorical; set `x_as_category=true` +to deliberately give temporal observations even spacing. Bars always +treat x as categorical. + +Every successful database-routed tool response carries canonical +`resolved_database`: `\"local\"`, `\"persistent\"`, or the lowercase attached +alias. `copy_query` also retains `target_database`. ## Tool index @@ -114,10 +123,7 @@ watcher targets the alias; call `unwatch_directory` first. - `query` — run a read-only SELECT / WITH / EXPLAIN / SHOW / VALUES. - `execute` — run one or more DDL/DML statements as an atomic batch. `sql` is an array; multi-element batches run inside a transaction - (all commit or all roll back). Response shape: - `{ statements, affected_rows, per_statement: [{sql, affected_rows, - elapsed_ms}], stats: {operation, elapsed_ms} }`. Disabled in - read-only mode. + (all commit or all roll back). Disabled in read-only mode. - `query_data` — ingest inline JSON or CSV and run one SQL query in a single call (table is temporary). - `query_file` — same as `query_data` but reads from a file path. The @@ -125,22 +131,22 @@ watcher targets the alias; call `unwatch_directory` first. ### Load - `load_file` — load one CSV / JSON / JSONL / Parquet / Arrow IPC file - into a named workspace table. `mode`: `replace` (default) / + into a named database table. `mode`: `replace` (default) / `append` / `merge`. Use `merge` to upsert by `merge_key` (column name or list); new columns in the incoming file are auto-added via `ALTER TABLE`. - `load_files` — load many files in parallel. Files must share a schema (or be unioned). `merge` mode is not supported here — call `load_file` per-file if you need merge. -- `load_data` — load inline JSON / CSV into a named workspace table. +- `load_data` — load inline JSON / CSV into a named database table. - `load_iceberg` — load an Apache Iceberg table by absolute path to its root directory; supports snapshot pinning via `metadata_filename` or `version_as_of`. ### Inspect -- `describe` — list workspace tables (no args) or describe one table +- `describe` — list local tables (no args) or describe one table (`table` arg) with columns, types, row count, and prose metadata. - **Defaults to the ephemeral primary** — pass `database: \"persistent\"` + **Defaults to the local database** — pass `database: \"persistent\"` (or an attached alias) to list/inspect durable tables. `status` reports table *counts* only, never names, so check the right database here before assuming a persistent table is missing. @@ -148,20 +154,23 @@ watcher targets the alias; call `unwatch_directory` first. writing a non-trivial query. - `inspect_file` — dry-run schema inference on a CSV / Parquet / Arrow IPC file without loading it. -- `status` — plugin health, workspace path, table count, total rows, - disk usage, watchers, attached databases, read-only flag. +- `status` — plugin and native/API identity; daemon/Hyper connection facts; + local/persistent paths; table count; disk usage; watchers; attachments; + read-only flag. + Both full and degraded responses report `default_database: \"local\"`. + When + `engine_busy: true`, the response is partial and non-definitive: + `hyperd_running: false` is inconclusive; retry `status` for full + statistics after the in-progress operation completes. ### Export - `export` — write a table or query result to a file (Parquet, Iceberg, - Arrow IPC, CSV, .hyper). -- `chart` — render a bar / line / scatter / histogram PNG from a SQL - query. Data must be long-format (one numeric y column; use a `series` - column for grouping). On line/scatter charts, DATE / TIMESTAMP / - TIMESTAMPTZ x columns auto-detect to a **proportional time axis** - (real-world gaps reflected in spacing); TEXT x falls back to evenly - spaced categorical mode. Pass `x_as_category: true` to force - categorical even on temporal data. Wide-format data must be reshaped - with UNION ALL. + Arrow IPC, CSV, .hyper). Hyper export leaves the source database + unchanged, but creates or replaces the destination `.hyper` file and + materializes all user tables into it. +- `chart` — render a bar / line / scatter / histogram PNG or SVG from a + SQL query as a quick diagnostic. Use long-format data (numeric y; + optional `series` grouping). See `Chart delivery and presentation`. - `copy_query` — run a SELECT across local + attached databases and insert the result into a target table (`mode`: `create`, `append`, `replace`). Cross-database analytics in one tool call. @@ -170,7 +179,9 @@ watcher targets the alias; call `unwatch_directory` first. - `save_query` — save a named read-only SQL query for later reuse. - `delete_query` — delete a named saved query. - `set_table_metadata` — update prose metadata (source_url, purpose, - notes, license, source_description) on a table catalog entry. + notes, license, source_description) on an existing table catalog entry. + Local and persistent tables share one name-keyed persistent catalog; + writable user-attached databases have per-database catalogs. ### Multi-database - `attach_database` — attach an additional .hyper database under an @@ -196,11 +207,8 @@ watcher targets the alias; call `unwatch_directory` first. - `kv_set_many` — atomic batch write. Pass an `entries` array of `{key, value}` objects. All keys validated up front; an invalid key aborts the whole batch without writing anything. `overwrite: false` - skips existing keys within the batch. Returns - `{stored, created, overwritten, total_bytes}` (or `skipped` instead - of `overwritten` under `overwrite: false`). `total_bytes` counts all - submitted values — an upper bound on bytes actually persisted when - keys are skipped or duplicated. + skips existing keys. Returns counts plus `total_bytes`, which counts + submitted values and may exceed bytes persisted when entries skip. - `kv_get` — read a value by store + key. - `kv_delete` — delete a key. - `kv_list` — list keys in a store. Pass `values: true` to return @@ -214,10 +222,13 @@ watcher targets the alias; call `unwatch_directory` first. - `kv_clear` — delete all keys in a store. Every kv_* tool takes the same optional `database` parameter as the data -tools. Omit it and the store lives in the EPHEMERAL database (lost on +tools. Omit it and the store lives in the local database (lost on restart); pass `\"persistent\"` (or `persist: true`) to persist across -restarts, or any attached alias to target that database. Each database -has its own isolated set of stores. Enrich analytical tables with KV +restarts, or any attached alias to target that database. Every user-attached +target must be writable, even for readers, because the backing table may +need initialization. The global `--read-only` guard blocks the five KV +mutators but not the four readers. Each database has its own isolated set +of stores. Enrich analytical tables with KV metadata via LEFT JOIN — always filter `kv.store_name = ''` to avoid row multiplication, and keep the KV table in the same database as the joined table. See the `hyper://schema/kv` resource for the join @@ -246,13 +257,20 @@ scale 0 and truncates decimal places. Example: `41.54178215::numeric` Sales` reads `sales`. Use `\"Sales\"` to preserve case. - **`query` is read-only.** SELECT / WITH / EXPLAIN / SHOW / VALUES only. For DDL / DML use `execute`. -- **Read-only mode** (`--read-only` flag on the server) disables: - `execute`, all `load_*`, writable `attach_database`, `save_query`, - `delete_query`, `set_table_metadata`, `copy_query`, `watch_directory`, - `unwatch_directory`, and the mutating KV tools (`kv_set`, `kv_delete`, - `kv_pop`, `kv_clear`). `query`, `describe`, `sample`, `inspect_file`, - `export`, `chart`, `status`, `list_attached_databases`, and - `get_readme` always work. +- **Read-only mode** (`--read-only` flag on the server) guards exactly: + `execute`, `load_data`, `load_file`, `load_files`, `load_iceberg`, + `watch_directory`, `save_query`, `delete_query`, `set_table_metadata`, + `copy_query`, `kv_set`, `kv_set_many`, `kv_delete`, `kv_pop`, and + `kv_clear`. A writable `attach_database` or `on_missing: \"create\"` is + also guarded, while a read-only attachment remains available. + Queries and inspection, `chart`, and detach/list operations remain + available. unwatch_directory remains allowed; export formats, including + Hyper, remain allowed. Hyper export does not mutate its source database, + but it does create or replace its materialized destination file. +- **Persistent-file contention:** only a reserved persistent attachment + lock is reported as `RESOURCE_BUSY`. Run `hyperdb-mcp doctor`, compare + client/daemon identities, close the possible owner (Hyper, Tableau, or + another process), or copy/select another `.hyper` file, then retry. - **Table names** in `load_*` and `query_data` / `query_file` accept unquoted identifiers; the server lowercases them. - **`copy_query` modes:** `create` requires the target not exist; @@ -329,10 +347,10 @@ sample({ \"table\": \"sales\" }) query({ \"sql\": \"SELECT region, SUM(amount) FROM sales GROUP BY region\" }) // Cross-database join via attachment -attach_database({ \"alias\": \"lookup\", \"path\": \"/data/dim.hyper\" }) +attach_database({ \"alias\": \"lookup\", \"kind\": \"local_file\", \"path\": \"/data/dim.hyper\" }) query({ \"sql\": \"SELECT s.region, d.country_name, SUM(s.amount) \ - FROM sales s JOIN lookup.dim_region d ON s.region = d.code \ + FROM sales s JOIN lookup.public.dim_region d ON s.region = d.code \ GROUP BY s.region, d.country_name\" }) @@ -376,8 +394,9 @@ execute({ // Chart chart({ \"sql\": \"SELECT region, SUM(amount) AS total FROM sales GROUP BY region\", - \"path\": \"/tmp/sales_by_region.png\", - \"chart_type\": \"bar\" + \"chart_type\": \"bar\", + \"x\": \"region\", + \"y\": \"total\" }) ``` diff --git a/hyperdb-mcp/src/server.rs b/hyperdb-mcp/src/server.rs index e4f2d02..d629f01 100644 --- a/hyperdb-mcp/src/server.rs +++ b/hyperdb-mcp/src/server.rs @@ -12,7 +12,9 @@ //! includes full JSON Schema descriptions for each tool's inputs. use crate::attach::{self, AttachRegistry, AttachRequest, AttachSource, LOCAL_ALIAS}; -use crate::chart::{render_chart, ChartFormat, ChartOptions, ChartType}; +use crate::chart::{ + render_chart_with_measure_metadata, ChartFormat, ChartOptions, ChartPresentation, ChartType, +}; use crate::engine::{classify_statement, is_read_only_sql, Engine, StatementKind}; use crate::error::{ErrorCode, McpError}; use crate::export::{export_to_file, ExportOptions}; @@ -67,9 +69,17 @@ const TABLE_SAMPLE_ROWS: u64 = 5; /// more compact wire format and the extra rows help LLMs see patterns. const TABLE_CSV_SAMPLE_ROWS: u64 = 20; +/// Canonical generated-catalog metrics used by the native doctor command. +pub(crate) struct DoctorCatalogSnapshot { + pub(crate) tool_count: usize, + pub(crate) canonical_tool_bytes: usize, + pub(crate) initialization_instructions_bytes: usize, + pub(crate) get_readme_bytes: usize, +} + /// Body of the `hyper://schema/kv` resource: describes the `_hyperdb_kv_store` /// backing table behind the `kv_*` tools, its (indexless) shape, the -/// ephemeral-vs-persistent durability rule, per-database isolation, and the +/// local-vs-persistent durability rule, per-database isolation, and the /// LEFT JOIN enrichment pattern. Served verbatim as `text/plain`. const KV_SCHEMA_RESOURCE: &str = "\ KV store backing table (managed by the kv_* tools): @@ -89,10 +99,13 @@ the kv_* tools, which guarantee uniqueness within a session. DATABASE / DURABILITY: each database has its own _hyperdb_kv_store table. Every kv_* tool takes the same optional `database` parameter as the other tools. Omit it -and the store lives in the EPHEMERAL database — convenient, but LOST when the +and the store lives in the local database — convenient, but LOST when the server restarts. Pass \"persistent\" (or persist=true) to survive restarts, or any attached alias to target that database. A store in one database is invisible from -another. +another. Every user-attached KV target must be writable, even for readers, +because opening a store may initialize this backing table. The global +`--read-only` mode blocks KV mutators; KV readers remain allowed, subject to +that attached-target writability requirement. Enrich an analytical table with KV metadata without ALTER TABLE. The KV table must be in the SAME database as the joined table (or fully qualify both) — a LEFT JOIN @@ -179,7 +192,7 @@ pub struct QueryFileParams { pub json_extract_path: Option, } -/// Parameters for the `load_data` workspace tool. +/// Parameters for the `load_data` database tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct LoadDataParams { /// Target table name. @@ -195,7 +208,7 @@ pub struct LoadDataParams { /// See the docs on `QueryDataParams` for the full spec. pub schema: Option, /// Target database alias. Omit (or pass `"local"`) to write to the - /// ephemeral primary. Pass `"persistent"` to write to the durable + /// local database. Pass `"persistent"` to write to the durable /// database that survives across sessions. Other values target a /// user-attached database (must be writable). pub database: Option, @@ -205,7 +218,7 @@ pub struct LoadDataParams { pub persist: Option, } -/// Parameters for the `load_file` workspace tool. +/// Parameters for the `load_file` database tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct LoadFileParams { /// Target table name. @@ -236,7 +249,7 @@ pub struct LoadFileParams { /// error if set for `replace` or `append`. pub merge_key: Option, /// Target database alias. Omit (or pass `"local"`) to write to the - /// ephemeral primary. Pass `"persistent"` to write to the durable + /// local database. Pass `"persistent"` to write to the durable /// database. Other values target a user-attached writable database. pub database: Option, /// Shorthand for `database: "persistent"`. If both `database` and @@ -334,7 +347,7 @@ pub struct LoadFilesEntry { pub merge_key: Option, } -/// Parameters for the `load_files` workspace tool. +/// Parameters for the `load_files` database tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct LoadFilesParams { /// Batch of files to ingest in parallel. Each entry targets its own @@ -348,7 +361,7 @@ pub struct LoadFilesParams { /// and can starve the primary connection. pub concurrency: Option, /// Target database alias. Omit (or pass `"local"`) to write to the - /// ephemeral primary. Pass `"persistent"` to write to the durable + /// local database. Pass `"persistent"` to write to the durable /// database. Other values target a user-attached writable database. /// Applies to every entry in the batch — multi-target batches are /// not supported. @@ -390,7 +403,7 @@ fn validate_merge_args( } } -/// Parameters for the `load_iceberg` workspace tool. +/// Parameters for the local-only `load_iceberg` tool. /// /// An Iceberg table on disk is a *directory* containing a `metadata/` /// subdir and one or more `data/` parquet files — hyperd reads the @@ -411,18 +424,18 @@ pub struct LoadIcebergParams { pub version_as_of: Option, } -/// Parameters for the read-only `query` workspace tool. +/// Parameters for the read-only `query` database tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct QueryParams { /// SQL SELECT / WITH / EXPLAIN / SHOW / VALUES statement (read-only) pub sql: String, /// Target database alias for unqualified name resolution. Omit to - /// query the ephemeral primary. Pass `"persistent"` to route to the + /// query the local database. Pass `"persistent"` to route to the /// durable database, or any user-attached alias. pub database: Option, } -/// Parameters for the mutating `execute` workspace tool. +/// Parameters for the mutating `execute` database tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct ExecuteParams { /// One or more DDL/DML SQL statements (CREATE, INSERT, UPDATE, DELETE, @@ -444,7 +457,7 @@ pub struct ExecuteParams { /// issue each DDL in its own `execute` call. pub sql: Vec, /// Target database alias for unqualified name resolution. Omit to - /// run against the ephemeral primary. Pass `"persistent"` to write + /// run against the local database. Pass `"persistent"` to write /// to the durable database (or a writable user-attached alias). pub database: Option, } @@ -456,25 +469,38 @@ pub struct SampleParams { pub table: String, /// Number of rows to return (default: 5, max: 100) pub n: Option, - /// Target database alias. Omit to sample from the ephemeral primary; + /// Target database alias. Omit to sample from the local database; /// pass `"persistent"` or a user-attached alias to sample from there. pub database: Option, } /// Parameters for the `describe` tool. Both fields are optional to preserve /// backward compatibility with callers that invoke `describe` with no args -/// to get the full workspace listing. +/// to get the full local-database listing. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct DescribeParams { /// If set, return the schema and row count for just this table. Omit to - /// list every public table in the workspace. + /// list every public table in the selected database. pub table: Option, - /// Target database alias. Omit to describe tables in the ephemeral - /// primary; pass `"persistent"` or a user-attached alias to describe - /// tables in another database. + /// Target database alias. Omit to describe tables in the local database; + /// pass `"persistent"` or a user-attached alias for another database. pub database: Option, } +fn chart_bar_orientation_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "enum": ["vertical", "horizontal"] + }) +} + +fn chart_y_scale_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "enum": ["linear", "log"] + }) +} + /// Parameters for the `chart` tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct ChartParams { @@ -498,20 +524,31 @@ pub struct ChartParams { pub height: Option, /// Number of bins for histograms (default 20) pub bins: Option, - /// Treat the x column as categorical rather than numeric. Auto-detected - /// from the first row's x value for line/scatter charts: DATE, TIMESTAMP, - /// TEXT, and other non-numeric types flip to categorical automatically. - /// Set explicitly to override auto-detection. Bar charts are always - /// categorical regardless of this flag. + /// Force the x column to evenly spaced categorical positions. By default, + /// line/scatter DATE, TIMESTAMP, and TIMESTAMPTZ values use proportional + /// temporal spacing; numeric values use a numeric axis and TEXT is + /// categorical. Bar charts are always categorical regardless of this flag. pub x_as_category: Option, + /// Bar layout: "vertical" (default) or "horizontal". Invalid for + /// line, scatter, and histogram charts. + #[serde(default)] + #[schemars(schema_with = "chart_bar_orientation_schema")] + pub bar_orientation: Option, /// Fix the x-axis range as [min, max]. Omit to auto-scale. Useful when /// comparing multiple charts at a consistent scale (e.g. [0, 1500] for /// population in millions) or when an outlier would distort auto-scaling. /// Ignored for bar charts (which use categorical x positions). pub x_range: Option<[f64; 2]>, - /// Fix the y-axis range as [min, max]. Omit to auto-scale. + /// Fix the data-role y measure range as [min, max]. This applies to bars; + /// horizontal bars render it on the physical x axis. Omit to auto-scale. /// Example: [0.0, 1.0] to pin a 0–1 index axis regardless of the data. pub y_range: Option<[f64; 2]>, + /// Data-role y measure scale: "linear" (default) or positive-only "log". + /// For horizontal bars this controls the physical x axis. Log histograms + /// are unsupported. + #[serde(default)] + #[schemars(schema_with = "chart_y_scale_schema")] + pub y_scale: Option, /// Map series names to hex colors ("#rrggbb"). Series not listed here /// fall back to the default color palette. Example: /// {"India": "#e41a1c", "China": "#ff7f0e"}. Only meaningful when a @@ -522,11 +559,15 @@ pub struct ChartParams { /// each series has exactly one point (e.g. one country per dot). /// Defaults to false (legend shown). pub label_points: Option, - /// Where to write the rendered image. Parent directory is created - /// automatically. If omitted, a file is auto-generated under the - /// system temp dir (`/hyperdb-charts/chart---.`). - /// Combine with `inline=true` to receive the bytes inline AND write - /// a file; otherwise the file is the sole output. + /// For bars, draw the original y scalar beside each mark. Defaults false. + pub label_values: Option, + /// Show the series legend. Defaults true; `label_points=true` still + /// suppresses line/scatter legends. + pub show_legend: Option, + /// Where to write the rendered image. Parent directories are created. + /// Omit for inline-only delivery (the default). With `inline=false`, an + /// omitted path is auto-generated under the system temp directory. + /// Supplying a path writes the file and, by default, also returns it inline. pub output_path: Option, /// When true, include the PNG/SVG bytes inline in the tool result. /// Without `output_path` this also skips the disk write entirely @@ -539,7 +580,7 @@ pub struct ChartParams { /// true (overwrite silently), matching the `export` tool. pub overwrite: Option, /// Target database alias for unqualified name resolution in the - /// chart's SQL. Omit to query the ephemeral primary. Pass + /// chart's SQL. Omit to query the local database. Pass /// `"persistent"` or a user-attached alias to chart from there. pub database: Option, } @@ -555,8 +596,8 @@ pub struct WatchDirectoryParams { /// Each in-flight ingest holds one connection to hyperd plus a transaction. #[serde(default)] pub max_concurrent: Option, - /// Target database alias. Omit (or pass `"local"`) for the ephemeral - /// primary. Pass `"persistent"` for the durable database, or any + /// Target database alias. Omit (or pass `"local"`) for the local + /// database. Pass `"persistent"` for the durable database, or any /// user-attached writable alias. The watcher's connection pool is /// built against the resolved target, so subsequent ingests land /// in the right database without per-file routing. @@ -636,7 +677,7 @@ pub struct ExportParams { /// /// Ignored for `format = "hyper"` (which isn't a `COPY`). pub format_options: Option, - /// Source database alias. Omit to read from the ephemeral primary. + /// Source database alias. Omit to read from the local database. /// Pass `"persistent"` or a user-attached alias to export from there. /// In `table` mode, the table name is fully qualified against this /// database. In `sql` mode, unqualified names in the SQL resolve @@ -654,9 +695,9 @@ pub struct ExportParams { /// * `hyper://queries/{name}/result` — re-runs the SQL on every read and /// returns the rows + query stats. /// -/// In ephemeral workspaces (no `--workspace`) saved queries live only for -/// the life of the server process; in persistent workspaces they are -/// stored in the `_hyperdb_saved_queries` meta-table and survive restarts. +/// With the normal persistent attachment, saved queries are stored in +/// `_hyperdb_saved_queries` and survive restarts. Under `--ephemeral-only` +/// they live only for the server process lifetime. #[derive(Debug, Deserialize, JsonSchema)] pub struct SaveQueryParams { /// Unique name identifying the query. Becomes the path component of @@ -714,13 +755,14 @@ pub struct AttachSpec { pub struct AttachDatabaseParams { /// Alias to register the attachment under. Must be a SQL identifier /// (`[A-Za-z_][A-Za-z0-9_]{0,62}`) and cannot be `local` (reserved - /// for the primary workspace). + /// for the local database). pub alias: String, /// Attachment kind. Only `"local_file"` is supported today. pub kind: String, /// Absolute path to a `.hyper` file. Required when `kind == - /// "local_file"`. The file must be idle — another MCP server or - /// `hyperd` instance holding it will cause a `RESOURCE_BUSY` error. + /// "local_file"`. Attachment failures preserve Hyper diagnostics. The + /// specialized contention classification is reserved for startup of the + /// configured persistent attachment, not user attachments. pub path: Option, /// If `true`, `copy_query` (and raw `execute`) may target this /// attachment. Defaults to `false` so sources stay safe from @@ -749,10 +791,10 @@ pub struct DetachDatabaseParams { /// Parameters for the `copy_query` tool. Runs a read-only SELECT / WITH /// / VALUES statement and lands the result into a target table. /// -/// The inner `sql` may reference tables in the primary workspace +/// The inner `sql` may reference tables in the local database /// (unqualified) as well as tables in any attachment by its fully /// qualified form — e.g. `src.public.customers`. The destination is -/// resolved via `target_database` (main workspace by default). +/// resolved via `target_database` (local by default). #[derive(Debug, Deserialize, JsonSchema)] pub struct CopyQueryParams { /// Read-only SQL statement whose result rows will be inserted into @@ -772,7 +814,7 @@ pub struct CopyQueryParams { /// * `"replace"` — drop (if any) and recreate, atomically. pub mode: String, /// Alias of the destination database. `None` and `"local"` both - /// mean the server's primary workspace. Any other value must refer + /// mean the server's local database. Any other value must refer /// to an attachment registered with `writable: true`. pub target_database: Option, /// Optional list of databases to attach for the duration of this @@ -790,16 +832,15 @@ pub struct CopyQueryParams { /// and cannot be set through this tool. #[derive(Debug, Deserialize, JsonSchema)] pub struct SetTableMetadataParams { - /// Target table name. Must already exist in the workspace and have a - /// catalog entry — load the table first (or run `execute CREATE - /// TABLE`) so the server auto-stubs the row. + /// Target table name. Must have an existing catalog entry — load the table + /// first (or run `execute CREATE TABLE`) so the server auto-stubs the row. pub table: String, /// Where the data came from (URL, S3 path, internal system name). pub source_url: Option, /// Short description of the dataset (what's in the table, how to /// interpret it). pub source_description: Option, - /// Why this data is in the workspace — what questions it's intended + /// Why this data is in the database — what questions it's intended /// to answer. pub purpose: Option, /// License or attribution requirements for the source data. @@ -811,12 +852,11 @@ pub struct SetTableMetadataParams { /// Enables mechanical refresh: the server can re-ingest the table /// from this URL + `load_params` without prose parsing. pub data_url: Option, - /// Target database alias for the catalog write. Omit (or pass - /// `"local"` / `"persistent"`) to update the persistent catalog — - /// matches the default for the ephemeral primary's tables. - /// Pass any user-attached writable alias to update that DB's - /// per-database `_table_catalog` instead. Read-only attachments - /// are rejected with a clear "re-attach with writable:true" + /// Target database alias for the catalog write. Local and persistent tables + /// use one shared name-keyed persistent catalog; omit this field or pass + /// `"local"` / `"persistent"` to select it. A user-attached writable alias + /// selects that database's per-database `_table_catalog` instead. Read-only + /// attachments are rejected with a clear "re-attach with writable:true" /// message. pub database: Option, } @@ -829,8 +869,8 @@ pub struct KvKeyParams { pub store: String, /// Key to look up or delete within the store. pub key: String, - /// Target database alias. Omit (or pass `"local"`) to use the ephemeral - /// primary. Pass `"persistent"` to use the durable database that survives + /// Target database alias. Omit (or pass `"local"`) to use the local + /// database. Pass `"persistent"` to use the durable database that survives /// across sessions. Other values target a user-attached database (must be /// writable). Each database has its own isolated set of KV stores. pub database: Option, @@ -859,7 +899,7 @@ pub struct KvSetParams { /// existed:true`. Defaults to true (upsert). pub overwrite: Option, /// Target database alias. Omit (or pass `"local"`) to write to the - /// ephemeral primary. Pass `"persistent"` to write to the durable database + /// local database. Pass `"persistent"` to write to the durable database /// that survives across sessions. Other values target a user-attached /// database (must be writable). Each database has its own isolated stores. pub database: Option, @@ -892,7 +932,7 @@ pub struct KvSetManyParams { /// true (upsert). pub overwrite: Option, /// Target database alias. Omit (or pass `"local"`) to write to the - /// ephemeral primary. Pass `"persistent"` to write to the durable database + /// local database. Pass `"persistent"` to write to the durable database /// that survives across sessions. Other values target a user-attached /// database (must be writable). Each database has its own isolated stores. pub database: Option, @@ -907,9 +947,10 @@ pub struct KvSetManyParams { pub struct KvStoreParams { /// Namespace of the KV store to operate on. pub store: String, - /// Target database alias. Omit (or pass `"local"`) for the ephemeral - /// primary. Pass `"persistent"` for the durable database, or a - /// user-attached alias. Each database has its own isolated stores. + /// Target database alias. Omit (or pass `"local"`) for the local database. + /// Pass `"persistent"` for the durable database, or a user-attached alias + /// that was registered writable (required even for KV readers). Each + /// database has its own isolated stores. pub database: Option, /// Shorthand for `database: "persistent"`. If both `database` and /// `persist` are set, `database` wins. @@ -925,9 +966,10 @@ pub struct KvListParams { /// false or omitted, return only `keys` (the default behavior). Use /// `values:true` for whole-store reads without N×`kv_get`. pub values: Option, - /// Target database alias. Omit (or pass `"local"`) for the ephemeral - /// primary. Pass `"persistent"` for the durable database, or a - /// user-attached alias. Each database has its own isolated stores. + /// Target database alias. Omit (or pass `"local"`) for the local database. + /// Pass `"persistent"` for the durable database, or a user-attached alias + /// that was registered writable (required even for KV readers). Each + /// database has its own isolated stores. pub database: Option, /// Shorthand for `database: "persistent"`. If both `database` and /// `persist` are set, `database` wins. @@ -937,9 +979,10 @@ pub struct KvListParams { /// Parameters for `kv_list_stores` (enumerate every store in a database). #[derive(Debug, Deserialize, JsonSchema)] pub struct KvListStoresParams { - /// Target database alias. Omit (or pass `"local"`) for the ephemeral - /// primary. Pass `"persistent"` for the durable database, or a - /// user-attached alias. Each database has its own isolated stores. + /// Target database alias. Omit (or pass `"local"`) for the local database. + /// Pass `"persistent"` for the durable database, or a user-attached alias + /// that was registered writable (required even for KV readers). Each + /// database has its own isolated stores. pub database: Option, /// Shorthand for `database: "persistent"`. If both `database` and /// `persist` are set, `database` wins. @@ -990,6 +1033,10 @@ pub struct SuggestQueriesArgs { /// starting `hyperd` if the client never calls a tool. pub struct HyperMcpServer { engine: Arc>>, + /// Serializes construction while keeping the public engine mutex available + /// for status observers and health-plane callbacks. Only the initializer + /// holds this guard; it must never protect tool execution. + engine_initialization: Mutex<()>, /// `true` once [`Self::ensure_catalog_ready`] has successfully run on /// the current engine, so we only try to create / reconcile /// `_table_catalog` once per process. Reset to `false` if the @@ -1039,25 +1086,49 @@ impl std::fmt::Debug for HyperMcpServer { } impl HyperMcpServer { - /// Create a server instance. Pass `Some(path)` for persistent workspace, - /// `None` for ephemeral (temp directory, auto-cleaned). + /// Snapshot the exact generated router contract without warming an engine. /// - /// The saved-queries store is chosen to match the workspace mode: - /// persistent workspaces get a [`crate::saved_queries::WorkspaceStore`] - /// (backed by a meta-table in the `.hyper` file so queries survive - /// restarts), ephemeral workspaces get an in-memory - /// [`crate::saved_queries::SessionStore`]. + /// The temporary server exists only to obtain the same initialization + /// instructions exposed by [`ServerHandler::get_info`]. Its session query + /// store and router registries are in-memory; this path never constructs an + /// [`Engine`] or starts Hyper. + pub(crate) fn doctor_catalog_snapshot( + read_only: bool, + ) -> Result { + let tools = Self::tool_router().list_all(); + let canonical_tool_bytes = serde_json::to_vec(&tools)?.len(); + let server = Self::with_no_daemon(None, read_only, true); + let initialization_instructions_bytes = server + .get_info() + .instructions + .as_deref() + .map_or(0, str::len); + + Ok(DoctorCatalogSnapshot { + tool_count: tools.len(), + canonical_tool_bytes, + initialization_instructions_bytes, + get_readme_bytes: crate::readme::README.len(), + }) + } + + /// Create a server instance. /// - /// When `read_only` is `true`, the `execute`, `load_data`, `load_file`, - /// `save_query`, `delete_query`, and `set_table_metadata` tools return - /// a `ReadOnlyViolation` error, and exporting to the `hyper` format - /// (which is a raw file copy, harmless) remains allowed. + /// Every instance has a fresh ephemeral local database, which remains the + /// default target. `Some(persistent_path)` also attaches the optional + /// persistent database under the reserved `"persistent"` alias; `None` + /// leaves that attachment disabled. Saved queries use persistent + /// database-backed storage when that attachment is configured and + /// in-memory session storage otherwise. /// - /// When `bare` is `true`, the server does not create or maintain the - /// `_table_catalog` table, and saved queries fall back to the in-memory - /// [`crate::saved_queries::SessionStore`] regardless of `workspace_path` - /// `persistent_path` is the resolved path to the persistent database - /// (`Some`) or `None` for `--ephemeral-only` mode. + /// When `read_only` is `true`, the server guards `execute`, every `load_*` + /// tool, `watch_directory`, saved-query mutations, `set_table_metadata`, + /// `copy_query`, all KV mutators, and writable/create `attach_database`. + /// Queries and inspection, read-only attachment, detach/list, + /// `unwatch_directory`, `chart`, and every export format remain available. + /// Hyper export does not mutate its source database, but it creates or + /// replaces a destination database and materializes the source's user + /// tables into it. pub fn new(persistent_path: Option, read_only: bool) -> Self { Self::with_options(persistent_path, read_only, false) } @@ -1077,6 +1148,7 @@ impl HyperMcpServer { let saved_queries: Arc = build_store(persistent_path.as_deref()); Self { engine: Arc::new(Mutex::new(None)), + engine_initialization: Mutex::new(()), catalog_ready: Arc::new(Mutex::new(false)), watchers: Arc::new(crate::watcher::WatcherRegistry::new()), saved_queries, @@ -1236,6 +1308,45 @@ impl HyperMcpServer { Ok(Some(resolved)) } + /// Return the canonical name of the database a successful tool call used. + /// The primary ephemeral database is addressed as `local` in MCP results. + fn resolved_database_name(target_db: Option<&str>) -> &str { + target_db.unwrap_or(LOCAL_ALIAS) + } + + /// Add database-routing metadata to a successful object payload. + /// + /// Tool errors retain their established error response shape, so this + /// helper rejects non-object values rather than wrapping them. + fn with_resolved_database( + mut payload: Value, + target_db: Option<&str>, + ) -> Result { + let object = payload.as_object_mut().ok_or_else(|| { + McpError::new( + ErrorCode::InternalError, + "successful tool response must be a JSON object", + ) + })?; + object.insert( + "resolved_database".into(), + Value::String(Self::resolved_database_name(target_db).to_owned()), + ); + Ok(payload) + } + + /// Add routing metadata to a successful object payload and wrap it as an + /// MCP result. A non-object success is treated as an internal tool error. + fn ok_content_with_resolved_database( + payload: Value, + target_db: Option<&str>, + ) -> Result { + match Self::with_resolved_database(payload, target_db) { + Ok(payload) => Self::ok_content(payload), + Err(e) => Self::err_content(e), + } + } + /// Soft threshold (bytes) above which a single KV value triggers a non-fatal /// `warning` in the write response. The write always succeeds. const KV_SOFT_SIZE_WARN_BYTES: usize = 1_048_576; @@ -1305,48 +1416,76 @@ impl HyperMcpServer { /// [`Self::catalog_ready`] flag so the subsequent `with_engine` call /// runs the catalog bootstrap. We can't run the bootstrap here /// because it needs to issue SQL back through `Engine`, and we're - /// still holding the outer lock. + /// still holding the outer lock. Engine construction is single-flight but + /// deliberately occurs outside the public engine mutex: daemon discovery, + /// its best-effort health report, and attachment replay may all perform + /// slow I/O. fn ensure_engine(&self) -> Result>, McpError> { + let guard = self + .engine + .lock() + .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?; + if guard.is_some() { + return Ok(guard); + } + drop(guard); + + let initialization = self + .engine_initialization + .lock() + .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?; + + // A competing initializer may have completed while this caller was + // waiting for the single-flight guard. Recheck before constructing. + let guard = self + .engine + .lock() + .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?; + if guard.is_some() { + drop(initialization); + return Ok(guard); + } + drop(guard); + + tracing::info!( + persistent_db = self.workspace_path.as_deref().unwrap_or(""), + no_daemon = self.no_daemon, + "initializing hyper engine" + ); + let engine = if self.no_daemon { + Engine::new_no_daemon(self.workspace_path.clone())? + } else { + Engine::new(self.workspace_path.clone())? + }; + tracing::info!( + ephemeral_path = %engine.ephemeral_path().display(), + persistent_path = ?engine.persistent_path(), + log_dir = %engine.log_dir().display(), + "engine ready" + ); + // Replay any attachments tracked across the previous engine's lifetime + // before handing the engine out to a tool. This work stays outside the + // public engine mutex because it may issue SQL / I/O. + if let Err(e) = self.attachments.replay_all(&engine) { + tracing::warn!(err = %e.message, "failed to replay attachments on new engine"); + } + let mut guard = self .engine .lock() .map_err(|_| McpError::new(ErrorCode::InternalError, "Lock poisoned"))?; - if guard.is_none() { - tracing::info!( - persistent_db = self.workspace_path.as_deref().unwrap_or(""), - no_daemon = self.no_daemon, - "initializing hyper engine" - ); - let engine = if self.no_daemon { - Engine::new_no_daemon(self.workspace_path.clone())? - } else { - Engine::new(self.workspace_path.clone())? - }; - tracing::info!( - ephemeral_path = %engine.ephemeral_path().display(), - persistent_path = ?engine.persistent_path(), - log_dir = %engine.log_dir().display(), - "engine ready" - ); - // Replay any attachments tracked across the previous - // engine's lifetime *before* handing the engine out to a - // tool — otherwise the first post-reconnect tool call - // would see the attachments missing from Hyper's view even - // though the registry still lists them. Logs replay - // failures; those entries are dropped from the registry - // inside `replay_all` so a single stale attachment doesn't - // block recovery. - if let Err(e) = self.attachments.replay_all(&engine) { - tracing::warn!(err = %e.message, "failed to replay attachments on new engine"); - } - *guard = Some(engine); - // New engine → catalog may need to be created/reconciled - // even if we already did it against a prior (now-dead) - // engine. - if let Ok(mut ready) = self.catalog_ready.lock() { - *ready = false; - } + debug_assert!( + guard.is_none(), + "single-flight initializer lost engine ownership" + ); + *guard = Some(engine); + // New engine → catalog may need to be created/reconciled even if we + // already did it against a prior (now-dead) engine. Preserve the + // existing engine-then-catalog lock order. + if let Ok(mut ready) = self.catalog_ready.lock() { + *ready = false; } + drop(initialization); Ok(guard) } @@ -1486,46 +1625,52 @@ impl HyperMcpServer { where F: FnOnce(&Engine) -> Result, { - let mut guard = self.ensure_engine()?; - let engine = guard.as_ref().expect("ensure_engine guarantees Some"); - // Bootstrap the catalog exactly once per engine. Intentionally - // runs *inside* `with_engine` (not `ensure_engine`) so the - // catalog SQL can see errors classified via the normal error - // path. No-op in bare or read-only mode. - self.ensure_catalog_ready(engine); - // In daemon mode, send a heartbeat so the daemon knows we're still active. - // Debounced to avoid per-call TCP overhead (only sends if >60s since last). - // Pass the health port from the engine we already hold — calling - // self.engine.lock() here would deadlock (we already hold that mutex). - if !self.no_daemon { - self.maybe_send_heartbeat(engine.daemon_health_port()); - } - let result = f(engine); - if let Err(e) = &result { - tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error"); - if e.code == ErrorCode::ConnectionLost { - tracing::warn!( - // Matches both the "hyperd crashed / socket closed" family - // and the "wire desynchronized" family — see - // [`crate::error::is_connection_lost`] for the full - // classifier and both triggers. - "connection to hyperd lost or desynchronized ({}); \ - dropping engine so next call reconnects", - e.message - ); - *guard = None; - // Reset so the next call re-bootstraps the catalog - // against the fresh engine. - if let Ok(mut ready) = self.catalog_ready.lock() { - *ready = false; - } - // Tell the daemon hyperd looks dead from over here. The daemon - // will pick up the flag on its next monitor tick and restart. - // Skipped in --no-daemon mode because there's no daemon to tell. - if !self.no_daemon { - crate::daemon::health::report_hyperd_error_to_daemon(); + let (result, daemon_health_port, connection_lost) = { + let mut guard = self.ensure_engine()?; + let engine = guard.as_ref().expect("ensure_engine guarantees Some"); + let daemon_health_port = engine.daemon_health_port(); + // Bootstrap the catalog exactly once per engine. Intentionally + // runs *inside* `with_engine` (not `ensure_engine`) so the + // catalog SQL can see errors classified via the normal error + // path. No-op in bare or read-only mode. + self.ensure_catalog_ready(engine); + let result = f(engine); + let connection_lost = result + .as_ref() + .is_err_and(|e| e.code == ErrorCode::ConnectionLost); + if let Err(e) = &result { + tracing::debug!(code = ?e.code, message = %e.message, "tool call returned error"); + if connection_lost { + tracing::warn!( + // Matches both the "hyperd crashed / socket closed" family + // and the "wire desynchronized" family — see + // [`crate::error::is_connection_lost`] for the full + // classifier and both triggers. + "connection to hyperd lost or desynchronized ({}); \ + dropping engine so next call reconnects", + e.message + ); + *guard = None; + // Reset so the next call re-bootstraps the catalog + // against the fresh engine. + if let Ok(mut ready) = self.catalog_ready.lock() { + *ready = false; + } } } + drop(guard); + (result, daemon_health_port, connection_lost) + }; + + // Health-plane TCP I/O must not hold the engine mutex. The captured + // port is authoritative for the daemon this engine actually uses; + // `None` means local fallback and therefore no daemon report. + if connection_lost { + if let Some(port) = daemon_health_port { + crate::daemon::health::report_hyperd_error_to_daemon(port); + } + } else { + self.maybe_send_heartbeat(daemon_health_port); } result } @@ -1534,22 +1679,26 @@ impl HyperMcpServer { /// Debounced: only sends if more than 60 seconds have elapsed since the last heartbeat, /// avoiding a new TCP connection on every tool call. /// - /// Accepts the daemon health port directly (from the caller's already-held - /// engine reference) to avoid re-locking `self.engine` — which would deadlock - /// since `with_engine` holds that mutex when calling us. + /// Accepts the daemon health port captured while the engine was locked so + /// this method never needs to re-lock `self.engine` after guard release. fn maybe_send_heartbeat(&self, daemon_health_port: Option) { const HEARTBEAT_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); - let should_send = self - .last_heartbeat - .lock() - .is_ok_and(|guard| guard.elapsed() >= HEARTBEAT_INTERVAL); - if should_send { - if let Some(port) = daemon_health_port { - let _ = crate::daemon::health::send_command(port, "HEARTBEAT"); - if let Ok(mut guard) = self.last_heartbeat.lock() { - *guard = std::time::Instant::now(); - } + let Some(port) = daemon_health_port else { + return; + }; + + let should_send = self.last_heartbeat.lock().is_ok_and(|mut guard| { + if guard.elapsed() < HEARTBEAT_INTERVAL { + return false; } + // Reserve the interval before I/O. Concurrent callers observe this + // timestamp and return, and a failed best-effort send still avoids + // an immediate retry storm. + *guard = std::time::Instant::now(); + true + }); + if should_send { + let _ = crate::daemon::health::send_command(port, "HEARTBEAT"); } } @@ -1565,7 +1714,7 @@ impl HyperMcpServer { /// /// Clients should check `engine_busy: true` and retry `status` later if /// they need the full stats, or wait for the in-progress operation to finish. - fn status_degraded(&self) -> Value { + fn status_degraded(&self) -> Result { // Use discover() — NOT find_running_daemon(). discover() reads the // daemon.json file + one PING to the known health port (~1ms if alive, // 300ms timeout if dead). find_running_daemon() adds a 16-port scan on @@ -1606,17 +1755,60 @@ impl HyperMcpServer { .map(super::attach::AttachedDb::to_json) .collect(); - json!({ - "engine_busy": true, - "hyperd_running": hyperd_running, - "persistent_path": persistent_path, - "has_persistent": self.workspace_path.is_some(), - "engine": engine_block, - "hyper_rust_api_version": crate::version::mcp_version_string(), - "watchers": self.watchers.to_json(), - "read_only": self.read_only, - "attachments": attachments, - }) + self.augment_status_response( + json!({ + "hyperd_running": hyperd_running, + "persistent_path": persistent_path, + "has_persistent": self.workspace_path.is_some(), + "engine": engine_block, + }), + true, + attachments, + ) + } + + /// Add server-owned status facts to either a full engine response or the + /// lock-contended degraded response. Engine-owned metrics remain intact. + fn augment_status_response( + &self, + mut status: Value, + engine_busy: bool, + attachments: Vec, + ) -> Result { + let installation = crate::diagnostics::current_installation_identity().map_err(|e| { + McpError::new( + ErrorCode::InternalError, + format!("Could not identify the current MCP installation: {e}"), + ) + })?; + let installation = serde_json::to_value(installation).map_err(|e| { + McpError::new( + ErrorCode::InternalError, + format!("Could not serialize MCP installation identity: {e}"), + ) + })?; + let status = status.as_object_mut().ok_or_else(|| { + McpError::new( + ErrorCode::InternalError, + "Status response must be a JSON object", + ) + })?; + + status.insert("engine_busy".into(), json!(engine_busy)); + status.insert( + "mcp_version".into(), + json!(crate::version::mcp_version_string()), + ); + status.insert( + "hyper_rust_api_version".into(), + json!(crate::version::hyper_api_version_string()), + ); + status.insert("installation".into(), installation); + status.insert("default_database".into(), json!("local")); + status.insert("watchers".into(), self.watchers.to_json()); + status.insert("read_only".into(), json!(self.read_only)); + status.insert("attachments".into(), Value::Array(attachments)); + Ok(status.clone().into()) } /// Run a closure that accesses the saved-query store. @@ -1805,9 +1997,9 @@ impl HyperMcpServer { } } - /// Load inline data (JSON or CSV) into a named workspace table. + /// Load inline data (JSON or CSV) into a named database table. #[tool( - description = "Load inline data (JSON or CSV) into a named workspace table. Supports partial `schema` overrides keyed by column name — only list the columns you want to correct, the rest keep their inferred type. On SchemaMismatch / numeric overflow, follow the error's suggestion (typically widen an INT column to BIGINT or NUMERIC(38,0))." + description = "Load inline data (JSON or CSV) into a named table in local, persistent, or an attached database. Supports partial `schema` overrides keyed by column name — only list columns to correct; the rest keep their inferred type. On SchemaMismatch / numeric overflow, follow the error suggestion (typically widen INT to BIGINT or NUMERIC(38,0))." )] fn load_data( &self, @@ -1874,11 +2066,14 @@ impl HyperMcpServer { ); } - Ok(json!({ - "rows": ingest_result.rows, - "schema": schema_json, - "stats": ingest_result.stats.to_json(), - })) + Self::with_resolved_database( + json!({ + "rows": ingest_result.rows, + "schema": schema_json, + "stats": ingest_result.stats.to_json(), + }), + target_db.as_deref(), + ) }); match result { @@ -1896,9 +2091,9 @@ impl HyperMcpServer { } } - /// Load a file (CSV, JSON, JSONL, Parquet, Arrow IPC) into a named workspace table. + /// Load a file (CSV, JSON, JSONL, Parquet, Arrow IPC) into a named database table. #[tool( - description = "Load a CSV / JSON / JSONL / NDJSON / Parquet / Arrow IPC file into a named workspace table. Format is auto-detected from extension (or content for JSON vs CSV).\n\nWhen choosing a format for *new* data going into Hyper, prefer in this order:\n 1. **Parquet** (fastest, server-side): hyperd reads the file directly via `external()`. Types, NUMERIC precision, DATE / TIMESTAMP, and Snappy/ZSTD compression all preserved. This is the recommended format for large imports.\n 2. **CSV**: server-side `COPY FROM` — also fast, but types are inferred from a header + full-file numeric widening pass (CSV has no embedded type info), and empty unquoted cells load as SQL NULL per PostgreSQL CSV default.\n 3. **Arrow IPC** (.arrow / .ipc / .feather, File or Stream format, auto-detected): read in Rust and streamed into hyperd via the binary COPY protocol with zero value-level decoding. Fast but not quite as fast as Parquet, and schema overrides are rejected (the Arrow schema is authoritative).\n 4. **JSON / JSONL / NDJSON**: parsed in Rust (hyperd has no native JSON reader), with per-row insertion. Use for small / irregular data; large JSON should be converted to Parquet first.\n\nFor Apache Iceberg tables use `load_iceberg` instead — it takes a directory path rather than a single file.\n\nSupports partial `schema` overrides keyed by column name (`{\"col\":\"BIGINT\"}`) — only list columns you want to correct; unlisted columns keep their inferred type. Overrides are supported for Parquet, CSV, and JSON; rejected for Arrow IPC. Call `inspect_file` first when unsure about types or to debug a prior failure; the inspector reports per-column min/max/null_count using the exact same inference logic. Use `json_extract_path` to extract a nested data array from a JSON wrapper file — dot-separated path, numeric segments index into arrays, string values are parsed as JSON.\n\n**Mode**: `replace` (default — drops + recreates the table), `append` (adds rows to an existing table), or `merge` (upserts rows by `merge_key`). In merge mode, set `merge_key` to a column name (`\"job_id\"`) or list of names (`[\"cell\",\"job_id\"]`); rows with a matching key are replaced, rows with no match are inserted. New columns in the incoming file are auto-added via `ALTER TABLE ADD COLUMN`. Type changes on existing columns are rejected — use `replace` for breaking schema changes." + description = "Load a CSV / JSON / JSONL / NDJSON / Parquet / Arrow IPC file into a named table in local, persistent, or an attached database. Format is auto-detected from extension (or content for JSON vs CSV).\n\nWhen choosing a format for *new* data going into Hyper, prefer in this order:\n 1. **Parquet** (fastest, server-side): hyperd reads the file directly via `external()`. Types, NUMERIC precision, DATE / TIMESTAMP, and Snappy/ZSTD compression all preserved. This is the recommended format for large imports.\n 2. **CSV**: server-side `COPY FROM` — also fast, but types are inferred from a header + full-file numeric widening pass (CSV has no embedded type info), and empty unquoted cells load as SQL NULL per PostgreSQL CSV default.\n 3. **Arrow IPC** (.arrow / .ipc / .feather, File or Stream format, auto-detected): read in Rust and streamed into hyperd via the binary COPY protocol with zero value-level decoding. Fast but not quite as fast as Parquet, and schema overrides are rejected (the Arrow schema is authoritative).\n 4. **JSON / JSONL / NDJSON**: parsed in Rust (hyperd has no native JSON reader), with per-row insertion. Use for small / irregular data; large JSON should be converted to Parquet first.\n\nFor Apache Iceberg tables use `load_iceberg` instead — it takes a directory path rather than a single file.\n\nSupports partial `schema` overrides keyed by column name (`{\"col\":\"BIGINT\"}`) — only list columns you want to correct; unlisted columns keep their inferred type. Overrides are supported for Parquet, CSV, and JSON; rejected for Arrow IPC. Call `inspect_file` first when unsure about types or to debug a prior failure; the inspector reports per-column min/max/null_count using the exact same inference logic. Use `json_extract_path` to extract a nested data array from a JSON wrapper file — dot-separated path, numeric segments index into arrays, string values are parsed as JSON.\n\n**Mode**: `replace` (default — drops + recreates the table), `append` (adds rows to an existing table), or `merge` (upserts rows by `merge_key`). In merge mode, set `merge_key` to a column name (`\"job_id\"`) or list of names (`[\"cell\",\"job_id\"]`); rows with a matching key are replaced, rows with no match are inserted. New columns in the incoming file are auto-added via `ALTER TABLE ADD COLUMN`. Type changes on existing columns are rejected — use `replace` for breaking schema changes." )] fn load_file( &self, @@ -1992,14 +2187,16 @@ impl HyperMcpServer { ); } - Ok(( + let payload = Self::with_resolved_database( json!({ "rows": ingest_result.rows, "schema": schema_json, "stats": ingest_result.stats.to_json(), }), - schema_changed, - )) + target_db.as_deref(), + )?; + + Ok((payload, schema_changed)) }); match result { @@ -2374,21 +2571,24 @@ impl HyperMcpServer { let success_count = outcomes.iter().filter(|o| o.ok.is_some()).count(); let failure_count = outcomes.len() - success_count; - Self::ok_content(json!({ - "results": results_json, - "summary": { - "total": outcomes.len(), - "succeeded": success_count, - "failed": failure_count, - "concurrency": concurrency, - } - })) + Self::ok_content_with_resolved_database( + json!({ + "results": results_json, + "summary": { + "total": outcomes.len(), + "succeeded": success_count, + "failed": failure_count, + "concurrency": concurrency, + } + }), + target_db.as_deref(), + ) } - /// Ingest an Apache Iceberg table directory into a workspace table + /// Ingest an Apache Iceberg table directory into a local table /// using hyperd's native `external(..., format => 'iceberg')` reader. #[tool( - description = "Ingest an Apache Iceberg table into a workspace table using hyperd's native Iceberg reader. `path` must be an absolute path to the Iceberg table *root directory* (the one containing the `metadata/` and `data/` subdirs). Hyperd resolves the latest snapshot by default; pass `metadata_filename` (e.g. `v2.metadata.json`) or `version_as_of` to pin a specific snapshot or version. Mode is `replace` (default) or `append`. Single SQL statement under the hood — no Rust-side Arrow decode, no per-row INSERTs." + description = "Ingest an Apache Iceberg table into a local database table using hyperd's native Iceberg reader. `path` must be an absolute table-root directory containing `metadata/` and `data/`. Hyperd resolves the latest snapshot by default; use `metadata_filename` or `version_as_of` to pin one. Mode is `replace` (default) or `append`." )] fn load_iceberg( &self, @@ -2462,7 +2662,7 @@ impl HyperMcpServer { /// Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES). #[tool( - description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against the workspace. For DDL/DML use the execute tool." + description = "Run a read-only SQL query (SELECT, WITH, EXPLAIN, SHOW, VALUES) against local (default), persistent, or an attached database. Successful results include canonical `resolved_database`. For DDL/DML use execute." )] fn query( &self, @@ -2497,10 +2697,14 @@ impl HyperMcpServer { let elapsed = timer.elapsed_ms(); let stats = crate::stats::QueryStats { operation: "query".into(), - rows_returned: rows.len() as u64, + rows_returned: u64::try_from(rows.len()) + .expect("usize query result count always fits in u64"), rows_scanned: 0, elapsed_ms: elapsed, - result_size_bytes: serde_json::to_string(&rows).map_or(0, |s| s.len() as u64), + result_size_bytes: serde_json::to_string(&rows).map_or(0, |serialized| { + u64::try_from(serialized.len()) + .expect("usize serialized query size always fits in u64") + }), tables_touched: vec![], }; let payload = if truncated { @@ -2522,7 +2726,8 @@ impl HyperMcpServer { "stats": stats.to_json(), }) }; - Ok((params.sql.clone(), payload)) + Self::with_resolved_database(payload, target_db.as_deref()) + .map(|payload| (params.sql.clone(), payload)) }); match result { @@ -2648,12 +2853,12 @@ impl HyperMcpServer { if any_structural { self.after_execute_catalog_update(engine, target_db.as_deref()); } - Ok(json!({ + Self::with_resolved_database(json!({ "statements": per_statement.len(), "affected_rows": affected_total, "per_statement": per_statement, "stats": { "operation": operation, "elapsed_ms": elapsed }, - })) + }), target_db.as_deref()) }); match result { @@ -2692,7 +2897,7 @@ impl HyperMcpServer { json!({ "operation": "sample", "elapsed_ms": elapsed }), ); } - Ok(sample) + Self::with_resolved_database(sample, target_db.as_deref()) }); match result { @@ -2703,7 +2908,7 @@ impl HyperMcpServer { /// Render a chart (PNG or SVG) from a SQL query. #[tool( - description = "Render a chart (bar, line, scatter, or histogram) from a SQL query. Returns the PNG/SVG image inline by default so MCP clients can display it directly. Set `inline=false` to skip the inline bytes and write to disk only (keeps the MCP transcript small for batch workflows). Combine `inline=true` with `output_path` to get both.\n\n**Data shape:** The query must return long-format data with one numeric `y` column. For multi-series charts, use a `series` column to split by category. If your data is wide-format (multiple value columns), reshape it with `UNION ALL` into (label, series, value) tuples before charting.\n\n**DATE/TIMESTAMP x-axis:** Line and scatter charts auto-detect non-numeric x columns. DATE, TIMESTAMP, and TIMESTAMPTZ values render with a **proportional time axis** — gaps between data points reflect real wall-clock time (4.5 h gap and 17 h gap don't look the same). Tick labels are formatted in the input kind: `%Y-%m-%d` for DATE, `%Y-%m-%d %H:%M:%S` for TIMESTAMP, with the originating timezone offset preserved for TIMESTAMPTZ. TEXT x columns fall back to evenly-spaced categorical mode. Set `x_as_category: true` to force categorical layout on temporal data (useful when even spacing reads better than proportional gaps).\n\n- `output_path`: explicit destination file path. Parent directory is created automatically (no need to pre-create it). If omitted and `inline=true` (default), no file is written. If omitted and `inline=false`, a file is auto-generated under the system temp dir as `hyperdb-charts/chart---.`.\n- `inline`: when true (default), return the image bytes inline. Without `output_path`, suppresses the disk write entirely. With `output_path`, writes to disk AND returns inline. Set to false for disk-only output.\n- `format`: \"png\" (default) or \"svg\". Auto-derived from `output_path` extension when omitted. A mismatch between `format` and the path extension returns `INVALID_ARGUMENT`.\n- `overwrite`: default true. Set false to refuse overwriting an existing file (returns `PERMISSION_DENIED`).\n- `x_range` / `y_range`: fix axis extents across multiple charts (e.g. x_range=[0,1500], y_range=[0,1]).\n- `color_map`: stable per-series hex colors (e.g. {\"India\":\"#e41a1c\",\"China\":\"#ff7f0e\"}).\n- `label_points=true`: annotate each point with its series name instead of showing a legend — best when each series has exactly one point." + description = "Quick diagnostic: render one bar, line, scatter, or histogram from a SQL query. Returns PNG/SVG inline by default; `output_path` writes plus returns inline, while `inline=false` is disk-only.\n\n**Data shape:** Return long-format data with one numeric `y` column and optional `series`; reshape wide data with `UNION ALL`.\n\n**Temporal x:** Line/scatter DATE, TIMESTAMP, and TIMESTAMPTZ use proportional time spacing. TEXT is categorical; `x_as_category=true` deliberately forces even spacing. Bars are always categorical.\n\n- `format`: \"png\" (default) or \"svg\"; path extension and explicit format must agree.\n- `x_range` / `y_range`: finite, strictly increasing, representable extents; y applies to bars.\n- `bar_orientation`: \"vertical\" (default) or \"horizontal\" for bars.\n- `label_values=true`: label bars with each original y scalar.\n- `show_legend`: true by default; false hides the legend.\n- `y_scale`: \"linear\" or positive \"log\"; no log histograms, and explicit log ranges must contain every value.\n- `label_points=true`: label line/scatter points and suppress their legend." )] fn chart( &self, @@ -2728,6 +2933,14 @@ impl HyperMcpServer { params.format.as_deref(), params.output_path.as_deref(), )?; + let chart_type = ChartType::parse(¶ms.chart_type)?; + let presentation = ChartPresentation::from_mcp( + chart_type, + params.bar_orientation.as_deref(), + params.label_values, + params.show_legend, + params.y_scale.as_deref(), + )?; // Optional database routing — temporarily redirect search_path // so unqualified names in the chart SQL resolve there. @@ -2738,7 +2951,12 @@ impl HyperMcpServer { }; let timer = crate::stats::StatsTimer::start(); - let rows = engine.execute_query_to_json(¶ms.sql)?; + let measure_column = match chart_type { + ChartType::Histogram => params.x.as_deref().or(params.y.as_deref()), + ChartType::Bar | ChartType::Line | ChartType::Scatter => params.y.as_deref(), + }; + let chart_rows = + engine.execute_chart_query_to_json(¶ms.sql, measure_column)?; // Parse color_map: skip entries whose hex string is malformed, // logging them via the description rather than hard-failing. @@ -2756,7 +2974,7 @@ impl HyperMcpServer { .unwrap_or_default(); let opts = ChartOptions { - chart_type: ChartType::parse(¶ms.chart_type)?, + chart_type, x_column: params.x.clone(), y_column: params.y.clone(), series_column: params.series.clone(), @@ -2772,7 +2990,12 @@ impl HyperMcpServer { label_points: params.label_points.unwrap_or(false), }; - let chart = render_chart(&rows, &opts)?; + let chart = render_chart_with_measure_metadata( + &chart_rows.rows, + &opts, + presentation, + &chart_rows.measures, + )?; // Decide disk vs inline vs both. Write to disk *before* // building the content vec so an I/O failure surfaces as a @@ -2788,11 +3011,11 @@ impl HyperMcpServer { } let elapsed = timer.elapsed_ms(); - Ok((chart, elapsed, opts, disposition)) + Ok((chart, elapsed, opts, disposition, target_db)) }); match result { - Ok((chart, elapsed_ms, opts, disposition)) => { + Ok((chart, elapsed_ms, opts, disposition, target_db)) => { let format_str = match opts.format { ChartFormat::Png => "png", ChartFormat::Svg => "svg", @@ -2812,8 +3035,14 @@ impl HyperMcpServer { if let Some(p) = output_path_str { stats.insert("output_path".into(), json!(p)); } - let stats_text = - serde_json::to_string_pretty(&Value::Object(stats)).unwrap_or_default(); + let stats = match Self::with_resolved_database( + Value::Object(stats), + target_db.as_deref(), + ) { + Ok(stats) => stats, + Err(e) => return Self::err_content(e), + }; + let stats_text = serde_json::to_string_pretty(&stats).unwrap_or_default(); let mut content = Vec::with_capacity(2); if wants_inline { @@ -2874,7 +3103,7 @@ impl HyperMcpServer { Some(self.subscriptions_handle()), path.clone(), params.table.clone(), - target_db, + target_db.clone(), options, ); match result { @@ -2889,7 +3118,7 @@ impl HyperMcpServer { "files_failed": stats.files_failed, }, }); - Self::ok_content(body) + Self::ok_content_with_resolved_database(body, target_db.as_deref()) } Err(e) => Self::err_content(e), } @@ -2911,10 +3140,10 @@ impl HyperMcpServer { } } - /// Describe workspace tables. With `table` set, returns just that + /// Describe tables in the selected database. With `table` set, returns just that /// table's columns and row count; without it, lists every public table. #[tool( - description = "Describe workspace tables. With `table` set, returns that single table's columns and row count (TABLE_NOT_FOUND if missing). Without `table`, lists every public table." + description = "Describe tables in local (default), persistent, or an attached database. With `table`, returns that table's columns and row count; without it, lists every public table. Successful results include canonical `resolved_database`." )] fn describe( &self, @@ -2922,16 +3151,17 @@ impl HyperMcpServer { ) -> Result { let result = self.with_engine(|engine| { let target_db = self.resolve_db(engine, params.database.as_deref(), None, false)?; - match params.table.as_deref() { + let tables = match params.table.as_deref() { Some(name) => engine .describe_table_in(target_db.as_deref(), name) .map(|t| vec![t]), None => engine.describe_tables_in(target_db.as_deref()), - } + }?; + Self::with_resolved_database(json!({"tables": tables}), target_db.as_deref()) }); match result { - Ok(tables) => Self::ok_content(json!({"tables": tables})), + Ok(val) => Self::ok_content(val), Err(e) => Self::err_content(e), } } @@ -3038,12 +3268,15 @@ impl HyperMcpServer { source_db: target_db.clone(), }; let export_result = export_to_file(engine, &opts)?; - Ok(json!({ - "output_path": export_result.stats.output_path, - "rows": export_result.rows, - "file_size_bytes": export_result.stats.file_size_bytes, - "stats": export_result.stats.to_json(), - })) + Self::with_resolved_database( + json!({ + "output_path": export_result.stats.output_path, + "rows": export_result.rows, + "file_size_bytes": export_result.stats.file_size_bytes, + "stats": export_result.stats.to_json(), + }), + target_db.as_deref(), + ) }); match result { @@ -3056,7 +3289,7 @@ impl HyperMcpServer { /// exposed as two MCP resources — see the struct-level docs on /// [`SaveQueryParams`] for the full URI pattern. #[tool( - description = "Save a named read-only SQL query. Creates two resources: `hyper://queries/{name}/definition` (sql + metadata JSON) and `hyper://queries/{name}/result` (re-runs the SQL on every read). Persisted in the workspace when `--workspace` is set; session-only otherwise. Rejects non-read-only SQL and duplicate names; delete first to overwrite." + description = "Save a named read-only SQL query. Creates `hyper://queries/{name}/definition` and `/result` resources. With the normal persistent attachment it survives restarts; under `--ephemeral-only` it is session-only. Rejects non-read-only SQL and duplicate names; delete first to overwrite." )] fn save_query( &self, @@ -3173,22 +3406,23 @@ impl HyperMcpServer { // would also fail at the Hyper layer, but the resolve_db // error is more actionable). let target_db = self.resolve_db(engine, params.database.as_deref(), None, true)?; - crate::table_catalog::set_metadata_in( + let entry = crate::table_catalog::set_metadata_in( engine, &table_name, &fields, target_db.as_deref(), - ) + )?; + Self::with_resolved_database(entry.to_json(), target_db.as_deref()) }); match result { - Ok(entry) => Self::ok_content(entry.to_json()), + Ok(body) => Self::ok_content(body), Err(e) => Self::err_content(e), } } /// Read a value from the KV scratchpad by store + key. #[tool( - description = "Read a value from the KV scratchpad by store + key. Returns {found, value}; `value` is null when the key is absent (not an error). Omit `database` to read the ephemeral store; pass \"persistent\" (or persist=true) or an attached alias to read elsewhere." + description = "Read a value from the KV scratchpad by store + key. Returns {found, value}; `value` is null when the key is absent (not an error). Omit `database` to read the local store; pass \"persistent\" (or persist=true) or an attached alias to read elsewhere." )] fn kv_get( &self, @@ -3197,17 +3431,21 @@ impl HyperMcpServer { let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; - kv.get(&p.key).map_err(McpError::from) + let value = kv.get(&p.key).map_err(McpError::from)?; + Ok((value, db)) }); match result { - Ok(value) => Self::ok_content(json!({ "found": value.is_some(), "value": value })), + Ok((value, db)) => Self::ok_content_with_resolved_database( + json!({ "found": value.is_some(), "value": value }), + db.as_deref(), + ), Err(e) => Self::err_content(e), } } - /// Save a value under store + key (upsert). Ephemeral unless routed. + /// Save a value under store + key (upsert). Local unless routed. #[tool( - description = "KV scratchpad. Save a variable, state, summary, or JSON config under store + key to remember later without creating a database table. IMPORTANT: without `database` the value is written to the EPHEMERAL database and is LOST when the server restarts. To persist across restarts, pass database=\"persistent\" (or persist=true). Returns {stored, created, value_bytes}; `created:false` means an existing value was overwritten. Pass overwrite=false to avoid clobbering (skips + returns stored:false, existed:true). Pass value_path= to store a file's contents server-side instead of `value` (exactly one of value/value_path; reads any server-readable path — no sandbox; files over 64 MiB are rejected before reading)." + description = "KV scratchpad. Save a variable, state, summary, or JSON config under store + key to remember later without creating a database table. IMPORTANT: without `database` the value is written to the local database and is LOST when the server restarts. To persist across restarts, pass database=\"persistent\" (or persist=true). Returns {stored, created, value_bytes}; `created:false` means an existing value was overwritten. Pass overwrite=false to avoid clobbering (skips + returns stored:false, existed:true). Pass value_path= to store a file's contents server-side instead of `value` (exactly one of value/value_path; reads any server-readable path — no sandbox; files over 64 MiB are rejected before reading)." )] fn kv_set( &self, @@ -3258,18 +3496,17 @@ impl HyperMcpServer { let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; - if overwrite { - kv.set(&p.key, &value) - .map(|o| (true, o.created)) - .map_err(McpError::from) + let (stored, created) = if overwrite { + let outcome = kv.set(&p.key, &value).map_err(McpError::from)?; + (true, outcome.created) } else { - kv.set_if_absent(&p.key, &value) - .map(|written| (written, written)) - .map_err(McpError::from) - } + let written = kv.set_if_absent(&p.key, &value).map_err(McpError::from)?; + (written, written) + }; + Ok((stored, created, db)) }); match result { - Ok((stored, created)) => { + Ok((stored, created, db)) => { let mut body = json!({ "stored": stored, "created": created, @@ -3283,7 +3520,7 @@ impl HyperMcpServer { if let Some(w) = Self::kv_size_warning(value_bytes) { body["warning"] = json!(w); } - Self::ok_content(body) + Self::ok_content_with_resolved_database(body, db.as_deref()) } Err(e) => Self::err_content(e), } @@ -3291,7 +3528,7 @@ impl HyperMcpServer { /// Atomic batch write to the KV scratchpad. #[tool( - description = "Write multiple KV pairs atomically. All keys validated before the transaction opens, so an invalid key aborts the whole batch. Returns {stored, created, overwritten, total_bytes} when overwrite=true (default); returns {stored, created, skipped, total_bytes} when overwrite=false (guard mode — skips existing keys). Empty `entries` is an error. Omit `database` to write to the ephemeral store; pass \"persistent\" (or persist=true) or an attached alias to write elsewhere." + description = "Write multiple KV pairs atomically. All keys validated before the transaction opens, so an invalid key aborts the whole batch. Returns {stored, created, overwritten, total_bytes} when overwrite=true (default); returns {stored, created, skipped, total_bytes} when overwrite=false (guard mode — skips existing keys). Empty `entries` is an error. Omit `database` to write to the local store; pass \"persistent\" (or persist=true) or an attached alias to write elsewhere." )] fn kv_set_many( &self, @@ -3323,41 +3560,44 @@ impl HyperMcpServer { } // Shape the outcome JSON *inside* each branch so the `with_engine` - // closure returns a single type (`Result`). The two - // batch primitives return different outcome structs (`BatchSetOutcome` - // vs `BatchGuardOutcome`), so a bare `if`/`else` returning both would - // not type-check — a closure, like any block, needs one return type. + // closure returns a single type + // (`Result<(Value, Option), McpError>`). The two batch + // primitives return different outcome structs (`BatchSetOutcome` vs + // `BatchGuardOutcome`), so a bare `if`/`else` returning both would not + // type-check — a closure, like any block, needs one return type. // `total_bytes` and `warnings` are engine-independent (computed above), - // so they are spliced into the object after the closure returns. This - // mirrors the single-type-closure pattern used by `kv_list` (Task 11). + // so they are spliced into the object after the closure returns; the + // database alias is carried out for `resolved_database` metadata. This + // mirrors the single-type-closure pattern used by `kv_list`. let overwrite = p.overwrite.unwrap_or(true); let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; - if overwrite { + let body = if overwrite { let o = kv.set_batch(&pairs).map_err(McpError::from)?; - Ok(json!({ + json!({ "stored": o.created + o.overwritten, "created": o.created, "overwritten": o.overwritten, - })) + }) } else { let o = kv.set_batch_if_absent(&pairs).map_err(McpError::from)?; - Ok(json!({ + json!({ "stored": o.written, "created": o.written, "skipped": o.skipped, - })) - } + }) + }; + Ok((body, db)) }); match result { - Ok(mut body) => { + Ok((mut body, db)) => { body["total_bytes"] = json!(total_bytes); if !warnings.is_empty() { body["warnings"] = json!(warnings); } - Self::ok_content(body) + Self::ok_content_with_resolved_database(body, db.as_deref()) } Err(e) => Self::err_content(e), } @@ -3365,7 +3605,7 @@ impl HyperMcpServer { /// Delete a key from the scratchpad. #[tool( - description = "Delete a key from the KV scratchpad. Returns {deleted: true} when the key existed, {deleted: false} otherwise (no error). Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias." + description = "Delete a key from the KV scratchpad. Returns {deleted: true} when the key existed, {deleted: false} otherwise (no error). Omit `database` for the local store, or route with \"persistent\"/persist=true/an attached alias." )] fn kv_delete( &self, @@ -3377,19 +3617,21 @@ impl HyperMcpServer { let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; - kv.delete(&p.key).map_err(McpError::from) + let deleted = kv.delete(&p.key).map_err(McpError::from)?; + Ok((deleted, db)) }); match result { - Ok(deleted) => { - Self::ok_content(json!({ "deleted": deleted, "store": p.store, "key": p.key })) - } + Ok((deleted, db)) => Self::ok_content_with_resolved_database( + json!({ "deleted": deleted, "store": p.store, "key": p.key }), + db.as_deref(), + ), Err(e) => Self::err_content(e), } } /// List all keys in a scratchpad store, sorted ascending. #[tool( - description = "List all keys in a KV scratchpad store, sorted ascending. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias. Pass values=true to return full (key, value) pairs as an `entries` array instead of just keys — useful for reading a whole store without N×kv_get." + description = "List all keys in a KV scratchpad store, sorted ascending. Omit `database` for the local store, or route with \"persistent\"/persist=true/an attached alias. Pass values=true to return full (key, value) pairs as an `entries` array instead of just keys — useful for reading a whole store without N×kv_get." )] fn kv_list( &self, @@ -3400,23 +3642,28 @@ impl HyperMcpServer { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; if with_values { - kv.entries().map(|pairs| (Some(pairs), None)) + let entries = kv.entries().map_err(McpError::from)?; + Ok((Some(entries), None, db)) } else { - kv.keys().map(|keys| (None, Some(keys))) + let keys = kv.keys().map_err(McpError::from)?; + Ok((None, Some(keys), db)) } - .map_err(McpError::from) }); match result { - Ok((Some(entries), None)) => { + Ok((Some(entries), None, db)) => { let arr: Vec = entries .into_iter() .map(|(k, v)| json!({ "key": k, "value": v })) .collect(); - Self::ok_content(json!({ "store": p.store, "entries": arr })) - } - Ok((None, Some(keys))) => { - Self::ok_content(json!({ "store": p.store, "count": keys.len(), "keys": keys })) + Self::ok_content_with_resolved_database( + json!({ "store": p.store, "entries": arr }), + db.as_deref(), + ) } + Ok((None, Some(keys), db)) => Self::ok_content_with_resolved_database( + json!({ "store": p.store, "count": keys.len(), "keys": keys }), + db.as_deref(), + ), Ok(_) => unreachable!("exactly one of entries/keys is Some"), Err(e) => Self::err_content(e), } @@ -3424,7 +3671,7 @@ impl HyperMcpServer { /// List all scratchpad store namespaces that hold data in a database. #[tool( - description = "List all KV scratchpad store namespaces that currently hold data in a database. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias. Each database has its own isolated set of stores. A store drops off this list once its last key is removed — there is no separate registry." + description = "List all KV scratchpad store namespaces that currently hold data in a database. Omit `database` for the local store, or route with \"persistent\"/persist=true/an attached alias. Each database has its own isolated set of stores. A store drops off this list once its last key is removed — there is no separate registry." )] fn kv_list_stores( &self, @@ -3432,21 +3679,25 @@ impl HyperMcpServer { ) -> Result { let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; - match db.as_deref() { + let stores = match db.as_deref() { Some(alias) => engine.connection().kv_list_stores_in(alias), None => engine.connection().kv_list_stores(), } - .map_err(McpError::from) + .map_err(McpError::from)?; + Ok((stores, db)) }); match result { - Ok(stores) => Self::ok_content(json!({ "count": stores.len(), "stores": stores })), + Ok((stores, db)) => Self::ok_content_with_resolved_database( + json!({ "count": stores.len(), "stores": stores }), + db.as_deref(), + ), Err(e) => Self::err_content(e), } } /// Count the keys in a scratchpad store. #[tool( - description = "Returns {store, size, bytes} where `size` is the key count and `bytes` is the total `OCTET_LENGTH` of all values (0 for empty stores). Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias." + description = "Returns {store, size, bytes} where `size` is the key count and `bytes` is the total `OCTET_LENGTH` of all values (0 for empty stores). Omit `database` for the local store, or route with \"persistent\"/persist=true/an attached alias." )] fn kv_size( &self, @@ -3457,14 +3708,17 @@ impl HyperMcpServer { let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; let key_count = kv.size().map_err(McpError::from)?; let value_bytes = kv.byte_size().map_err(McpError::from)?; - Ok(json!({ - "store": p.store, - "size": key_count, - "bytes": value_bytes, - })) + Ok(( + json!({ + "store": p.store, + "size": key_count, + "bytes": value_bytes, + }), + db, + )) }); match result { - Ok(val) => Self::ok_content(val), + Ok((body, db)) => Self::ok_content_with_resolved_database(body, db.as_deref()), Err(e) => Self::err_content(e), } } @@ -3472,7 +3726,7 @@ impl HyperMcpServer { /// Destructively read-and-remove the lowest-keyed entry in lexicographic /// key order (atomic). #[tool( - description = "Destructively read-and-remove the lowest-keyed entry (lexicographic key order, not insertion order) from a KV store (peek+delete in one transaction, atomic within a single server process — useful as a work queue for one session; two separate server processes popping a shared persistent store could double-serve an entry). Returns {found, key, value}; {found: false} on an empty store. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias." + description = "Destructively read-and-remove the lowest-keyed entry (lexicographic key order, not insertion order) from a KV store (peek+delete in one transaction, atomic within a single server process — useful as a work queue for one session; two separate server processes popping a shared persistent store could double-serve an entry). Returns {found, key, value}; {found: false} on an empty store. Omit `database` for the local store, or route with \"persistent\"/persist=true/an attached alias." )] fn kv_pop( &self, @@ -3484,20 +3738,24 @@ impl HyperMcpServer { let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; - kv.pop().map_err(McpError::from) + let entry = kv.pop().map_err(McpError::from)?; + Ok((entry, db)) }); match result { - Ok(Some((key, value))) => { - Self::ok_content(json!({ "found": true, "key": key, "value": value })) + Ok((Some((key, value)), db)) => Self::ok_content_with_resolved_database( + json!({ "found": true, "key": key, "value": value }), + db.as_deref(), + ), + Ok((None, db)) => { + Self::ok_content_with_resolved_database(json!({ "found": false }), db.as_deref()) } - Ok(None) => Self::ok_content(json!({ "found": false })), Err(e) => Self::err_content(e), } } /// Delete all keys in a scratchpad store. #[tool( - description = "Delete all keys in a KV scratchpad store. Returns the number of keys removed. Omit `database` for the ephemeral store, or route with \"persistent\"/persist=true/an attached alias." + description = "Delete all keys in a KV scratchpad store. Returns the number of keys removed. Omit `database` for the local store, or route with \"persistent\"/persist=true/an attached alias." )] fn kv_clear( &self, @@ -3509,19 +3767,22 @@ impl HyperMcpServer { let result = self.with_engine(|engine| { let db = self.resolve_db(engine, p.database.as_deref(), p.persist, true)?; let kv = Self::kv_open(engine, db.as_deref(), &p.store)?; - kv.clear().map_err(McpError::from) + let removed = kv.clear().map_err(McpError::from)?; + Ok((removed, db)) }); match result { - Ok(removed) => Self::ok_content(json!({ "store": p.store, "removed": removed })), + Ok((removed, db)) => Self::ok_content_with_resolved_database( + json!({ "store": p.store, "removed": removed }), + db.as_deref(), + ), Err(e) => Self::err_content(e), } } - /// Returns plugin health, workspace info, table count, total rows, disk - /// usage, the backing `hyperd` connection (mode, endpoint, daemon health - /// port), and the list of active directory watchers with their stats. + /// Returns installation identity, local/persistent database state, engine + /// health, attachments, watchers, and full or degraded statistics. #[tool( - description = "Returns plugin health, workspace info, table count, total rows, disk usage, the backing hyperd connection (engine.mode, engine.hyperd_endpoint, engine.daemon_health_port), and active directory watchers." + description = "Returns MCP/Rust API installation identity, `default_database: local`, read-only state, attachments, watchers, and Hyper/daemon health. `engine_busy:false` includes full SQL statistics; `engine_busy:true` is a prompt partial response, so omitted statistics and `hyperd_running:false` are inconclusive—retry later." )] fn status(&self) -> Result { // Use try_lock so `status` never hangs behind a stalled/slow data-plane @@ -3537,29 +3798,32 @@ impl HyperMcpServer { // hyperd was down at startup, or a ConnectionLost just dropped it), we // report the degraded response honestly rather than blocking to init. let Ok(guard) = self.engine.try_lock() else { - return Self::ok_content(self.status_degraded()); + return match self.status_degraded() { + Ok(status) => Self::ok_content(status), + Err(e) => Self::err_content(e), + }; }; let Some(engine) = guard.as_ref() else { - return Self::ok_content(self.status_degraded()); + return match self.status_degraded() { + Ok(status) => Self::ok_content(status), + Err(e) => Self::err_content(e), + }; }; self.ensure_catalog_ready(engine); let result = engine.status(); match result { - Ok(mut val) => { - if let Some(obj) = val.as_object_mut() { - obj.insert("engine_busy".into(), json!(false)); - obj.insert("watchers".into(), self.watchers.to_json()); - obj.insert("read_only".into(), json!(self.read_only)); - let attachments: Vec = self - .attachments - .list() - .iter() - .map(super::attach::AttachedDb::to_json) - .collect(); - obj.insert("attachments".into(), Value::Array(attachments)); + Ok(val) => { + let attachments = self + .attachments + .list() + .iter() + .map(super::attach::AttachedDb::to_json) + .collect(); + match self.augment_status_response(val, false, attachments) { + Ok(status) => Self::ok_content(status), + Err(e) => Self::err_content(e), } - Self::ok_content(val) } Err(e) => Self::err_content(e), } @@ -3589,7 +3853,7 @@ impl HyperMcpServer { /// Attach an additional `.hyper` database under a user-chosen /// alias so its tables can participate in cross-database queries. #[tool( - description = "Attach an additional .hyper database under a chosen alias. Tables in the attachment are addressable as `{alias}.public.{table}` in any subsequent SELECT; tables in the primary workspace remain addressable as `local.public.{table}` or by their file stem. Default is read-only; pass writable:true to allow mutations (still respects --read-only). Set on_missing='create' (with writable:true) to create an empty .hyper file at the target path first and then attach it — useful for scratch databases without a separate file-creation step; the parent directory must already exist. Only kind='local_file' is supported today; 'tcp' and 'grpc' (Data 360) are planned. The alias 'local' is reserved for the primary workspace." + description = "Attach a .hyper database under a canonical lowercase alias. Its tables are `{alias}.public.{table}`; local tables are `local.public.{table}` or unqualified. Default is read-only. `writable:true` and `on_missing:'create'` permit writes only when the server is not `--read-only`; read-only attachment remains available. Only `kind:'local_file'` is supported; `local` is reserved." )] fn attach_database( &self, @@ -3759,7 +4023,7 @@ impl HyperMcpServer { /// `append`, `replace`) are explicit — the target's actual /// existence must match the chosen mode. #[tool( - description = "Run a SELECT (or WITH / VALUES) across local and attached databases and insert the result into a target table. Required `mode`: 'create' (target must not exist, creates via CREATE TABLE AS), 'append' (target must exist, INSERT INTO ... SELECT), or 'replace' (drops and recreates atomically). `target_database` defaults to the primary workspace ('local' also accepted); any other value must be an attachment registered with writable:true. Optional `temp_attach` attaches additional databases for this call only and detaches them on exit (even on failure). Disabled in read-only mode." + description = "Run SELECT/WITH/VALUES across local and attached databases and insert into a target table. `mode` is `create`, `append`, or `replace`. `target_database` defaults to local; another alias must be attached writable. `temp_attach` lasts only for this call. Success retains `target_database` and adds canonical `resolved_database`. Disabled in read-only mode." )] fn copy_query( &self, @@ -3902,7 +4166,7 @@ impl HyperMcpServer { ); } - copy_outcome + copy_outcome.and_then(|outcome| Self::with_resolved_database(outcome, target_db)) }); match result { @@ -4307,8 +4571,8 @@ impl HyperMcpServer { } /// Build the `hyper://readme` markdown body: a human-friendly overview - /// of the current workspace, its tables, and pointers to the other - /// resources and tools an LLM might reach for. + /// of the local and persistent databases, local tables, and pointers to + /// the other resources and tools an LLM might reach for. /// /// Designed to be dropped into an LLM context block so the model can /// orient itself in a single resource read without first calling @@ -4319,31 +4583,32 @@ impl HyperMcpServer { .with_engine(super::engine::Engine::describe_tables) .unwrap_or_default(); - let workspace_mode = status - .get("workspace_mode") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let workspace_path = status - .get("workspace_path") - .and_then(|v| v.as_str()) - .unwrap_or(""); - let read_only = status - .get("read_only") + let has_persistent = status + .get("has_persistent") .and_then(serde_json::Value::as_bool) .unwrap_or(false); + let persistent_path = status + .get("persistent_path") + .and_then(|v| v.as_str()) + .unwrap_or(""); let table_count = tables.len(); let mut md = String::new(); - md.push_str("# HyperDB workspace\n\n"); + md.push_str("# HyperDB databases\n\n"); let _ = writeln!( md, - "- Mode: **{workspace_mode}**{}\n", - if read_only { " (read-only)" } else { "" } + "- Local database: **ephemeral** (default){}", + if self.read_only { " (read-only)" } else { "" } ); - if !workspace_path.is_empty() { - let _ = writeln!(md, "- Path: `{workspace_path}`\n"); + if has_persistent { + md.push_str("- Persistent database: **attached**\n"); + if !persistent_path.is_empty() { + let _ = writeln!(md, "- Persistent path: `{persistent_path}`"); + } + } else { + md.push_str("- Persistent database: **disabled**\n"); } - let _ = write!(md, "- Tables: **{table_count}**\n\n"); + let _ = write!(md, "- Local tables: **{table_count}**\n\n"); if tables.is_empty() { md.push_str( @@ -4352,7 +4617,7 @@ impl HyperMcpServer { first if you're unsure of the schema.\n", ); } else { - md.push_str("## Tables\n\n"); + md.push_str("## Local tables\n\n"); md.push_str("| Table | Rows | Columns |\n"); md.push_str("|---|---:|---|\n"); for t in &tables { @@ -4661,7 +4926,7 @@ Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query- Ok(()) } - /// List MCP resources: the workspace, the tables list, a markdown + /// List MCP resources: database status, the local tables list, a markdown /// readme, the KV store schema, and three entries per existing table /// (schema, JSON sample, CSV sample). Calling this lazily starts the /// engine, so it doubles as a "wake up" signal for MCP clients that @@ -4674,9 +4939,13 @@ Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query- let mut resources = vec![ RawResource { uri: "hyper://workspace".into(), - name: "Workspace Info".into(), - title: Some("Hyper Workspace".into()), - description: Some("Workspace mode, table count, total rows, disk usage".into()), + name: "Local and Persistent Database Info".into(), + title: Some("HyperDB local and persistent databases".into()), + description: Some( + "Local/default database and persistent attachment state, table count, \ + total rows, and disk usage" + .into(), + ), mime_type: Some("application/json".into()), size: None, icons: None, @@ -4696,11 +4965,11 @@ Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query- .no_annotation(), RawResource { uri: "hyper://readme".into(), - name: "Workspace Readme".into(), - title: Some("HyperDB workspace readme".into()), + name: "Database Readme".into(), + title: Some("HyperDB local and persistent database readme".into()), description: Some( - "Markdown overview of the workspace: tables, row counts, related \ - resources, and tool hints for LLMs orienting themselves." + "Markdown overview of local/default and persistent databases: local \ + tables, row counts, related resources, and tool hints for LLMs." .into(), ), mime_type: Some("text/markdown".into()), @@ -4715,7 +4984,7 @@ Full SQL reference: https://developer.salesforce.com/docs/data/data-cloud-query- title: Some("Key-value scratchpad schema".into()), description: Some( "Schema of the _hyperdb_kv_store table backing the kv_* tools, the \ - ephemeral-vs-persistent durability rule, and the LEFT JOIN enrichment \ + local-vs-persistent durability rule, and the LEFT JOIN enrichment \ pattern for joining KV metadata onto analytical tables." .into(), ), @@ -5442,3 +5711,272 @@ mod kv_value_path_size_tests { ); } } + +#[cfg(test)] +mod heartbeat_debounce_tests { + use std::io::{BufRead, BufReader, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::mpsc::{self, Receiver, Sender}; + use std::sync::{Arc, Barrier}; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + use super::HyperMcpServer; + + struct HeldHeartbeatPeer { + port: u16, + release: Option>, + handle: Option, String>>>, + } + + impl HeldHeartbeatPeer { + fn spawn() -> Self { + let listener = + TcpListener::bind(("127.0.0.1", 0)).expect("bind controlled heartbeat listener"); + listener + .set_nonblocking(true) + .expect("make controlled heartbeat listener nonblocking"); + let port = listener + .local_addr() + .expect("controlled heartbeat listener address") + .port(); + let (release_tx, release_rx) = mpsc::channel(); + let handle = + std::thread::spawn(move || hold_heartbeat_responses(&listener, &release_rx)); + Self { + port, + release: Some(release_tx), + handle: Some(handle), + } + } + + fn release_and_join(mut self) -> Result, String> { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + self.handle + .take() + .expect("controlled heartbeat listener handle must exist") + .join() + .map_err(|payload| format!("controlled heartbeat listener panicked: {payload:?}"))? + } + } + + impl Drop for HeldHeartbeatPeer { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + fn hold_heartbeat_responses( + listener: &TcpListener, + release: &Receiver<()>, + ) -> Result, String> { + let hard_deadline = Instant::now() + Duration::from_secs(5); + let mut streams = Vec::new(); + loop { + loop { + match listener.accept() { + Ok((stream, _)) => streams.push(stream), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => break, + Err(error) => { + return Err(format!("controlled heartbeat accept failed: {error}")); + } + } + } + + match release.try_recv() { + Ok(()) | Err(mpsc::TryRecvError::Disconnected) => break, + Err(mpsc::TryRecvError::Empty) => {} + } + if Instant::now() >= hard_deadline { + return Err("controlled heartbeat listener was never released".to_string()); + } + std::thread::sleep(Duration::from_millis(2)); + } + + // Accept any connections already queued when the release signal won + // the race with the nonblocking accept above. + let drain_deadline = Instant::now() + Duration::from_millis(50); + while Instant::now() < drain_deadline { + match listener.accept() { + Ok((stream, _)) => streams.push(stream), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => return Err(format!("heartbeat queue drain failed: {error}")), + } + } + + let mut commands = Vec::with_capacity(streams.len()); + for mut stream in streams { + commands.push(read_heartbeat_command(&stream)?); + stream + .write_all(b"OK\n") + .map_err(|error| format!("acknowledge held heartbeat: {error}"))?; + } + Ok(commands) + } + + fn read_heartbeat_command(stream: &TcpStream) -> Result { + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .map_err(|error| format!("set heartbeat read timeout: {error}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(1))) + .map_err(|error| format!("set heartbeat write timeout: {error}"))?; + let reader = stream + .try_clone() + .map_err(|error| format!("clone heartbeat stream: {error}"))?; + let mut command = String::new(); + BufReader::new(reader) + .read_line(&mut command) + .map_err(|error| format!("read heartbeat command: {error}"))?; + Ok(command) + } + + fn collect_immediate_heartbeats( + listener: &TcpListener, + window: Duration, + ) -> Result, String> { + listener + .set_nonblocking(true) + .map_err(|error| format!("make follow-up listener nonblocking: {error}"))?; + let deadline = Instant::now() + window; + let mut commands = Vec::new(); + while Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + commands.push(read_heartbeat_command(&stream)?); + stream + .write_all(b"OK\n") + .map_err(|error| format!("acknowledge follow-up heartbeat: {error}"))?; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => return Err(format!("follow-up heartbeat accept failed: {error}")), + } + } + Ok(commands) + } + + #[test] + fn heartbeat_reservation_is_atomic_and_survives_send_failure() { + const CALLERS: usize = 4; + const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(60); + + let server = Arc::new(HyperMcpServer::with_no_daemon(None, false, false)); + *server + .last_heartbeat + .lock() + .expect("heartbeat timestamp mutex") = Instant::now() + .checked_sub(HEARTBEAT_INTERVAL) + .expect("60-second heartbeat interval fits before current instant"); + + let peer = HeldHeartbeatPeer::spawn(); + let barrier = Arc::new(Barrier::new(CALLERS + 1)); + let (completed_tx, completed_rx) = mpsc::channel(); + let mut callers = Vec::with_capacity(CALLERS); + for _ in 0..CALLERS { + let server = Arc::clone(&server); + let barrier = Arc::clone(&barrier); + let completed = completed_tx.clone(); + let port = peer.port; + callers.push(std::thread::spawn(move || { + barrier.wait(); + server.maybe_send_heartbeat(Some(port)); + let _ = completed.send(()); + })); + } + drop(completed_tx); + barrier.wait(); + + // With an atomic reservation, all but the single sender return while + // that sender remains blocked on the held response. The old split + // check/update lets every caller enter network I/O instead. + let early_deadline = Instant::now() + Duration::from_secs(2); + let mut early_completions = 0; + while early_completions < CALLERS - 1 { + let remaining = early_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + match completed_rx.recv_timeout(remaining) { + Ok(()) => early_completions += 1, + Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => { + break; + } + } + } + + let concurrent_commands = peer.release_and_join(); + let caller_results: Vec<_> = callers.into_iter().map(JoinHandle::join).collect(); + + // Preserve the existing best-effort rule: even a refused connection + // consumes the debounce interval, so an immediate retry does not + // create a heartbeat storm while the daemon is unavailable. + let reservation = + TcpListener::bind(("127.0.0.1", 0)).expect("reserve a closed heartbeat port"); + let failed_port = reservation + .local_addr() + .expect("closed heartbeat port address") + .port(); + drop(reservation); + *server + .last_heartbeat + .lock() + .expect("heartbeat timestamp mutex") = Instant::now() + .checked_sub(HEARTBEAT_INTERVAL) + .expect("60-second heartbeat interval fits before current instant"); + server.maybe_send_heartbeat(Some(failed_port)); + + let follow_up_listener = TcpListener::bind(("127.0.0.1", failed_port)) + .expect("bind follow-up listener on refused heartbeat port"); + let follow_up = std::thread::spawn(move || { + collect_immediate_heartbeats(&follow_up_listener, Duration::from_millis(300)) + }); + server.maybe_send_heartbeat(Some(failed_port)); + let follow_up_commands = follow_up.join(); + + let mut failures = Vec::new(); + match concurrent_commands { + Ok(commands) if commands == ["HEARTBEAT\n"] => {} + Ok(commands) => failures.push(format!( + "concurrent callers sent {} commands instead of one: {commands:?}", + commands.len() + )), + Err(error) => failures.push(error), + } + if early_completions != CALLERS - 1 { + failures.push(format!( + "only {early_completions} of {} non-senders returned before the held heartbeat was released", + CALLERS - 1 + )); + } + for (index, result) in caller_results.into_iter().enumerate() { + if let Err(payload) = result { + failures.push(format!("heartbeat caller {index} panicked: {payload:?}")); + } + } + match follow_up_commands { + Ok(Ok(commands)) if commands.is_empty() => {} + Ok(Ok(commands)) => failures.push(format!( + "a failed heartbeat did not reserve the debounce interval; immediate retry sent {commands:?}" + )), + Ok(Err(error)) => failures.push(error), + Err(payload) => failures.push(format!("follow-up heartbeat peer panicked: {payload:?}")), + } + + assert!( + failures.is_empty(), + "heartbeat reservation failures:\n{}", + failures.join("\n") + ); + } +} diff --git a/hyperdb-mcp/tests/chart_tests.rs b/hyperdb-mcp/tests/chart_tests.rs index ee06ca4..df78860 100644 --- a/hyperdb-mcp/tests/chart_tests.rs +++ b/hyperdb-mcp/tests/chart_tests.rs @@ -22,6 +22,36 @@ fn bar_opts() -> ChartOptions { } } +/// The public chart API is intentionally frozen while richer MCP-only +/// presentation controls are added behind it. An exhaustive literal catches +/// any added public field at compile time; the call pins the legacy renderer +/// signature as `render_chart(&[Value], &ChartOptions)`. +#[test] +fn legacy_chart_options_literal_is_source_compatible() { + let opts = ChartOptions { + chart_type: ChartType::Bar, + x_column: Some("category".into()), + y_column: Some("value".into()), + series_column: None, + title: Some("Legacy chart".into()), + format: ChartFormat::Svg, + width: 320, + height: 240, + bins: 20, + x_as_category: None, + x_range: None, + y_range: None, + color_map: std::collections::HashMap::new(), + label_points: false, + }; + let rows = vec![json!({"category": "legacy", "value": 1})]; + + let result = render_chart(&rows, &opts).expect("legacy chart API must still render"); + + assert_eq!(result.mime_type, "image/svg+xml"); + assert_eq!(result.rows_plotted, 1); +} + /// PNG output should start with the 8-byte PNG magic signature. #[test] fn bar_chart_png_has_magic_bytes() { @@ -209,6 +239,11 @@ fn hex_color_parse() { assert!(parse_hex_color("not-a-color").is_none()); assert!(parse_hex_color("#gg0000").is_none()); // invalid hex digit assert!(parse_hex_color("#fff").is_none()); // too short + // Regression: a 6-*byte* string whose bytes are not all ASCII (here `é` + // is two UTF-8 bytes) has `len() == 6` but its byte offsets don't land on + // char boundaries — slicing `[0..2]` used to panic. It must be rejected. + assert!(parse_hex_color("1é234").is_none()); // 6 bytes, not 6 ASCII chars + assert!(parse_hex_color("#1é234").is_none()); // same, with a leading '#' } /// `x_range` and `y_range` fix the axis extents; the chart still renders with diff --git a/hyperdb-mcp/tests/daemon_tests.rs b/hyperdb-mcp/tests/daemon_tests.rs index b1f82c8..8386046 100644 --- a/hyperdb-mcp/tests/daemon_tests.rs +++ b/hyperdb-mcp/tests/daemon_tests.rs @@ -10,6 +10,7 @@ //! shared mutex — every test that touches env vars acquires `ENV_LOCK` first. use std::net::TcpListener; +use std::process::{Output, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -21,6 +22,8 @@ use tempfile::TempDir; /// Cargo runs tests in the same process by default — this prevents races. /// We recover from poison to prevent one test's panic from cascading. static ENV_LOCK: Mutex<()> = Mutex::new(()); +const ENGINE_REPORT_CHILD_ENV: &str = "HYPERDB_MCP_ENGINE_REPORT_CHILD"; +const ENGINE_REPORT_TEST_NAME: &str = "report_hyperd_error_targets_discovered_health_port"; fn acquire_env_lock() -> std::sync::MutexGuard<'static, ()> { ENV_LOCK @@ -230,6 +233,117 @@ fn health_listener_second_bind_same_port_fails() { assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::AddrInUse); } +#[test] +fn health_listener_waits_for_command_after_accept() { + use std::io::{BufRead, BufReader, Write}; + use std::net::{Shutdown, SocketAddr, TcpStream}; + use std::sync::mpsc; + + const CLIENT_IO_TIMEOUT: Duration = Duration::from_secs(1); + const OVERALL_WATCHDOG: Duration = Duration::from_secs(3); + + let listener = HealthListener::bind(0).expect("bind OS-assigned health-listener port"); + let port = listener.port; + let info = DaemonInfo { + pid: 12345, + hyperd_endpoint: "127.0.0.1:54321".to_string(), + health_port: port, + started_at: "2026-05-20T10:30:00Z".to_string(), + version: "0.1.3".to_string(), + }; + let mut listener = OwnedHealthListener::start(listener, info); + let (outcome_tx, outcome_rx) = mpsc::sync_channel(1); + let started = Instant::now(); + + let client = std::thread::spawn(move || { + struct ShutdownOnDrop(TcpStream); + + impl ShutdownOnDrop { + fn connect(port: u16, timeout: Duration, label: &str) -> Result { + let address = SocketAddr::from(([127, 0, 0, 1], port)); + let stream = TcpStream::connect_timeout(&address, timeout) + .map_err(|error| format!("connect {label} raw health client: {error}"))?; + stream + .set_read_timeout(Some(timeout)) + .map_err(|error| format!("set {label} client read timeout: {error}"))?; + stream + .set_write_timeout(Some(timeout)) + .map_err(|error| format!("set {label} client write timeout: {error}"))?; + Ok(Self(stream)) + } + + fn ping(&mut self, label: &str) -> Result { + self.0.write_all(b"PING\n").map_err(|error| { + format!("{label} PING write failed ({:?}): {error}", error.kind()) + })?; + + let mut response = String::new(); + match BufReader::new(&self.0).read_line(&mut response) { + Ok(0) => Err(format!( + "health listener returned early EOF for {label} PING" + )), + Ok(_) => Ok(response), + Err(error) => Err(format!( + "{label} PING read failed ({:?}): {error}", + error.kind() + )), + } + } + } + + impl Drop for ShutdownOnDrop { + fn drop(&mut self) { + let _ = self.0.shutdown(Shutdown::Both); + } + } + + let outcome = (|| -> Result { + let expected_pong = format!("PONG hyperdb-mcp {}\n", hyperdb_mcp::version::MCP_VERSION); + let mut idle_client = ShutdownOnDrop::connect(port, CLIENT_IO_TIMEOUT, "first idle")?; + + // A PONG from a later connection proves the listener has accepted + // and serviced connections while the first one remains byte-empty. + { + let mut progress_probe = + ShutdownOnDrop::connect(port, CLIENT_IO_TIMEOUT, "progress probe")?; + let probe_response = progress_probe.ping("progress-probe")?; + if probe_response != expected_pong { + return Err(format!( + "progress probe returned unidentified response: {probe_response:?}" + )); + } + } + + // Keep the already-accepted first socket idle for more than two + // additional 100ms listener polls before sending its first command. + std::thread::sleep(Duration::from_millis(350)); + idle_client.ping("delayed first-client") + })(); + let _ = outcome_tx.send(outcome); + }); + + let outcome = outcome_rx.recv_timeout(OVERALL_WATCHDOG); + listener.stop_and_join(); + let client_join = client.join(); + + assert!( + client_join.is_ok(), + "raw health-client thread panicked after listener cleanup" + ); + let response = outcome.unwrap_or_else(|error| { + panic!("raw health-client scenario exceeded the {OVERALL_WATCHDOG:?} watchdog: {error}") + }); + assert_eq!( + response, + Ok(format!( + "PONG hyperdb-mcp {}\n", + hyperdb_mcp::version::MCP_VERSION + )), + "HealthListener must keep an accepted connection open until its first command; elapsed={:?}", + started.elapsed() + ); +} + #[test] fn health_protocol_ping_pong() { let (port, _handle, _state) = start_health_listener(); @@ -411,6 +525,235 @@ fn resolve_port_scan_scans_when_env_unset() { assert_eq!(scan.span, hyperdb_mcp::daemon::DAEMON_PORT_SCAN_SPAN); } +#[test] +fn daemon_status_post_action_port_targets_explicit_listener() { + let _lock = acquire_env_lock(); + let temp_dir = TempDir::new().expect("create isolated daemon-status state root"); + let state_dir = temp_dir.path().join("state"); + std::fs::create_dir_all(&state_dir).expect("create isolated daemon-status state directory"); + let discovery_path = state_dir.join("daemon.json"); + let stale_discovery = br#"{ + "pid": 424242, + "hyperd_endpoint": "127.0.0.1:1", + "health_port": 0, + "started_at": "stale-discovery-sentinel", + "version": "0.0.0-stale" +}"#; + std::fs::write(&discovery_path, stale_discovery) + .expect("write stale discovery sentinel for explicit-port bypass proof"); + + let mut explicit_listener = CliStatusListener::start(); + let reserved_base = TcpListener::bind(("127.0.0.1", 0)) + .expect("reserve an isolated discovery base port different from the explicit listener"); + let base_port = reserved_base + .local_addr() + .expect("read reserved discovery base address") + .port(); + assert_ne!( + base_port, explicit_listener.port, + "isolated discovery base must differ from the explicit target" + ); + + let spellings = [ + vec![ + "daemon".to_string(), + "status".to_string(), + "--port".to_string(), + explicit_listener.port.to_string(), + ], + vec![ + "daemon".to_string(), + "--port".to_string(), + explicit_listener.port.to_string(), + "status".to_string(), + ], + ]; + let mut failures = Vec::new(); + + for args in &spellings { + let output = run_cli_child_with_watchdog(args, &state_dir, base_port); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + failures.push(format!( + "`hyperdb-mcp {}` exited {}\nstdout:\n{stdout}\nstderr:\n{stderr}", + args.join(" "), + output.status + )); + } + let expected_port_line = format!("Health port: {}", explicit_listener.port); + if !stdout.contains(&expected_port_line) { + failures.push(format!( + "`hyperdb-mcp {}` did not report the explicit listener {expected_port_line:?}\nstdout:\n{stdout}\nstderr:\n{stderr}", + args.join(" ") + )); + } + } + + let received_commands = explicit_listener.stop_and_join(); + if received_commands != ["STATUS", "STATUS"] { + failures.push(format!( + "explicit listener received {received_commands:?}, expected one STATUS from each literal spelling" + )); + } + match std::fs::read(&discovery_path) { + Ok(contents) if contents == stale_discovery => {} + Ok(contents) => failures.push(format!( + "explicit status mutated stale discovery sentinel: {:?}", + String::from_utf8_lossy(&contents) + )), + Err(error) => failures.push(format!( + "explicit status removed or made stale discovery unreadable: {error}" + )), + } + + assert!( + failures.is_empty(), + "explicit daemon status contract failures:\n{}", + failures.join("\n\n") + ); +} + +#[test] +fn report_hyperd_error_targets_discovered_health_port() { + let _lock = acquire_env_lock(); + if std::env::var_os(ENGINE_REPORT_CHILD_ENV).is_some() { + run_engine_report_child(); + } else { + run_bounded_engine_report_child(); + } +} + +fn run_bounded_engine_report_child() { + let temp_dir = TempDir::new().expect("create isolated engine-construction state root"); + let state_dir = temp_dir.path().join("state"); + let process_temp_dir = temp_dir.path().join("tmp"); + std::fs::create_dir_all(&state_dir).expect("create isolated engine-construction state"); + std::fs::create_dir_all(&process_temp_dir).expect("create isolated process temp directory"); + let configured_base = + TcpListener::bind(("127.0.0.1", 0)).expect("reserve configured daemon base port"); + let configured_base_port = configured_base + .local_addr() + .expect("read configured daemon base port") + .port(); + + let mut command = std::process::Command::new( + std::env::current_exe().expect("locate daemon integration-test binary"), + ); + command + .arg("--exact") + .arg(ENGINE_REPORT_TEST_NAME) + .arg("--nocapture") + .current_dir(temp_dir.path()) + .env(ENGINE_REPORT_CHILD_ENV, "1") + .env("HOME", &state_dir) + .env("USERPROFILE", &state_dir) + .env("HYPERDB_STATE_DIR", &state_dir) + .env("HYPERDB_DAEMON_PORT", configured_base_port.to_string()) + .env("TMPDIR", &process_temp_dir) + .env("TEMP", &process_temp_dir) + .env("TMP", &process_temp_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command + .spawn() + .expect("spawn exact Engine-report regression child"); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + let kill_result = child.kill(); + let output = child + .wait_with_output() + .expect("reap timed-out Engine-report child"); + panic!( + "Engine-report child exceeded 10s; kill={kill_result:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + let kill_result = child.kill(); + let output = child + .wait_with_output() + .expect("reap Engine-report child after status error"); + panic!( + "Engine-report child status failed: {error}; kill={kill_result:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + } + let output = child + .wait_with_output() + .expect("collect completed Engine-report child output"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stdout.contains("running 1 test"), + "exact child filter executed zero or multiple tests\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + output.status.success(), + "Engine-report child failed with {}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ); +} + +fn run_engine_report_child() { + let configured_base_port = std::env::var("HYPERDB_DAEMON_PORT") + .expect("parent must configure a distinct daemon base port") + .parse::() + .expect("configured daemon base port must be a u16"); + + let health_listener = HealthListener::bind(0).expect("bind discovered health listener"); + let health_port = health_listener.port; + assert_ne!( + health_port, configured_base_port, + "discovered health port must differ from the configured base" + ); + let daemon_info = DaemonInfo { + pid: 616_161, + // Port zero is never a reachable TCP server endpoint. It makes the + // public Engine constructor enter the daemon connection-error branch + // without racing another process for a recently released port. + hyperd_endpoint: "127.0.0.1:0".to_string(), + health_port, + started_at: "2026-08-14T12:34:56Z".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + let mut health_listener = OwnedHealthListener::start(health_listener, daemon_info.clone()); + discovery::write_discovery_file(&daemon_info) + .expect("write isolated non-base daemon discovery record"); + assert!( + !health_listener.state.consume_restart_request(), + "restart flag must begin clear" + ); + + let error = hyperdb_mcp::engine::Engine::new(None) + .expect_err("unreachable discovered Hyper endpoint must fail Engine construction"); + + let restart_requested = health_listener.state.consume_restart_request(); + health_listener.stop_and_join(); + assert!( + error + .message + .contains("Failed to connect to daemon hyperd at 127.0.0.1:0"), + "public Engine constructor must fail in try_daemon_mode, got: {}", + error.message + ); + assert!( + restart_requested, + "Engine::try_daemon_mode must report through the non-base health port from discovery" + ); +} + // ─── Unit tests: idle timeout logic (no env vars) ───────────────────────────── #[test] @@ -481,6 +824,28 @@ fn daemon_heartbeat_prevents_idle_shutdown() { // ─── Unit tests: Discovery file (require ENV_LOCK) ──────────────────────────── +#[test] +fn legacy_daemon_info_literal_is_source_compatible() { + let info = DaemonInfo { + pid: 12345, + hyperd_endpoint: "127.0.0.1:54321".to_string(), + health_port: 7484, + started_at: "2026-05-20T10:30:00Z".to_string(), + version: "0.1.3".to_string(), + }; + + assert_eq!( + serde_json::to_value(info).unwrap(), + serde_json::json!({ + "pid": 12345, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": 7484, + "started_at": "2026-05-20T10:30:00Z", + "version": "0.1.3" + }) + ); +} + #[test] fn discovery_file_write_and_read() { let _lock = acquire_env_lock(); @@ -1090,6 +1455,198 @@ fn daemon_mode_ephemeral_database_cleaned_up_on_drop() { // ─── Test helpers ───────────────────────────────────────────────────────────── +struct OwnedHealthListener { + state: Arc, + handle: Option>, +} + +impl OwnedHealthListener { + fn start(listener: HealthListener, info: DaemonInfo) -> Self { + let state = Arc::new(DaemonState::new()); + let run_state = Arc::clone(&state); + let info = Arc::new(Mutex::new(info)); + let handle = std::thread::spawn(move || listener.run(run_state, info)); + Self { + state, + handle: Some(handle), + } + } + + fn stop_and_join(&mut self) { + self.state.request_shutdown(); + if let Some(handle) = self.handle.take() { + handle + .join() + .expect("owned health listener must shut down cleanly"); + } + } +} + +impl Drop for OwnedHealthListener { + fn drop(&mut self) { + self.stop_and_join(); + } +} + +struct CliStatusListener { + port: u16, + handle: Option>>, +} + +impl CliStatusListener { + fn start() -> Self { + use std::io::{BufRead, BufReader, Write}; + + let listener = TcpListener::bind(("127.0.0.1", 0)) + .expect("bind OS-assigned explicit daemon-status listener"); + let port = listener + .local_addr() + .expect("read explicit daemon-status listener address") + .port(); + let handle = std::thread::spawn(move || { + let mut commands = Vec::new(); + loop { + let (mut stream, _) = listener + .accept() + .expect("explicit daemon-status listener accept"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bound explicit daemon-status request read"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("bound explicit daemon-status response write"); + let mut line = String::new(); + BufReader::new(&stream) + .read_line(&mut line) + .expect("read explicit daemon-status command"); + let command = line.trim(); + if command == "TEST_SHUTDOWN" { + break; + } + commands.push(command.to_string()); + let response = serde_json::json!({ + "pid": 515151, + "hyperd_endpoint": "127.0.0.1:54321", + "health_port": port, + "started_at": "2026-08-14T12:34:56Z", + "version": env!("CARGO_PKG_VERSION") + }); + writeln!(stream, "{response}").expect("write explicit daemon-status response"); + } + commands + }); + Self { + port, + handle: Some(handle), + } + } + + fn stop_and_join(&mut self) -> Vec { + use std::io::Write; + + if self.handle.is_none() { + return Vec::new(); + } + let mut stream = std::net::TcpStream::connect(("127.0.0.1", self.port)) + .expect("connect explicit daemon-status listener shutdown"); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .expect("bound explicit daemon-status shutdown write"); + stream + .write_all(b"TEST_SHUTDOWN\n") + .expect("signal explicit daemon-status listener shutdown"); + self.handle + .take() + .expect("explicit daemon-status listener handle exists") + .join() + .expect("explicit daemon-status listener must join") + } +} + +impl Drop for CliStatusListener { + fn drop(&mut self) { + if self.handle.is_some() { + let _ = self.stop_and_join(); + } + } +} + +fn run_cli_child_with_watchdog( + args: &[String], + state_dir: &std::path::Path, + base_port: u16, +) -> Output { + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_hyperdb-mcp")); + command.env_clear(); + preserve_child_runtime_environment(&mut command); + command + .current_dir( + state_dir + .parent() + .expect("isolated daemon-status state has a parent"), + ) + .env("HOME", state_dir) + .env("USERPROFILE", state_dir) + .env("HYPERDB_STATE_DIR", state_dir) + .env("HYPERDB_DAEMON_PORT", base_port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .args(args); + let mut child = command.spawn().expect("spawn isolated daemon-status child"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(_)) => { + return child + .wait_with_output() + .expect("collect completed daemon-status child output"); + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + let kill_error = child.kill().err(); + let output = child + .wait_with_output() + .expect("wait for timed-out daemon-status child after kill"); + panic!( + "daemon-status child exceeded 5s and was killed ({kill_error:?})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Err(error) => { + let kill_error = child.kill().err(); + let output = child + .wait_with_output() + .expect("wait for daemon-status child after status error"); + panic!( + "daemon-status child status failed: {error}; kill result: {kill_error:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } + } +} + +fn preserve_child_runtime_environment(command: &mut std::process::Command) { + for key in [ + "PATH", + "SystemRoot", + "WINDIR", + "COMSPEC", + "PATHEXT", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } +} + /// Starts a health listener on a random port and returns the port, join handle, /// and shared state. Does NOT touch env vars — safe for parallel use. fn start_health_listener() -> (u16, std::thread::JoinHandle<()>, Arc) { diff --git a/hyperdb-mcp/tests/diagnostics_tests.rs b/hyperdb-mcp/tests/diagnostics_tests.rs new file mode 100644 index 0000000..490a150 --- /dev/null +++ b/hyperdb-mcp/tests/diagnostics_tests.rs @@ -0,0 +1,329 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Contract tests for bounded, launcher-reported installation identity. + +use std::ffi::{OsStr, OsString}; + +use hyperdb_mcp::diagnostics::{ + installation_identity_from_parts, parse_launcher_identity, IdentityWarning, PathEncoding, + ReportedPath, +}; +use serde_json::{json, Value}; + +fn launcher_json( + wrapper_name: &str, + wrapper_version: Option<&str>, + platform_name: &str, + platform_version: Option<&str>, +) -> String { + json!({ + "wrapper": { + "name": wrapper_name, + "version": wrapper_version, + "package_path": "/opt/hyperdb/node_modules/hyperdb-mcp/package.json" + }, + "platform": { + "name": platform_name, + "version": platform_version, + "package_path": "/opt/hyperdb/node_modules/hyperdb-mcp-linux-x64-gnu/package.json" + }, + "executable_path": "/opt/hyperdb/node_modules/hyperdb-mcp-linux-x64-gnu/hyperdb-mcp" + }) + .to_string() +} + +#[cfg(unix)] +fn non_utf8_os_string() -> OsString { + use std::os::unix::ffi::OsStringExt; + + OsString::from_vec(b"/tmp/hyperdb-\xff-mcp".to_vec()) +} + +#[cfg(windows)] +fn non_utf8_os_string() -> OsString { + use std::os::windows::ffi::OsStringExt; + + OsString::from_wide(&[ + u16::from(b'C'), + u16::from(b':'), + u16::from(b'\\'), + 0xD800, + u16::from(b'x'), + ]) +} + +#[test] +fn launcher_identity_parsing_contract() { + let absent = parse_launcher_identity(None); + assert_eq!(absent.identity, None); + assert!(absent.warnings.is_empty()); + + let secret = "UNKNOWN_SECRET_SENTINEL_4c4c08"; + let valid = json!({ + "wrapper": { + "name": "hyperdb-mcp", + "version": "1.2.3", + "package_path": "/opt/hyperdb/node_modules/hyperdb-mcp/package.json", + "unknown_secret": secret + }, + "platform": { + "name": "hyperdb-mcp-linux-x64-gnu", + "version": "1.2.3", + "package_path": "/opt/hyperdb/node_modules/hyperdb-mcp-linux-x64-gnu/package.json", + "credentials": { "token": secret } + }, + "executable_path": "/opt/hyperdb/node_modules/hyperdb-mcp-linux-x64-gnu/hyperdb-mcp", + "unknown_root": secret + }) + .to_string(); + + let parsed = parse_launcher_identity(Some(OsStr::new(&valid))); + assert!(parsed.warnings.is_empty()); + let identity = parsed.identity.expect("valid launcher metadata must parse"); + assert_eq!( + serde_json::to_value(&identity).expect("launcher identity must serialize"), + json!({ + "wrapper": { + "name": "hyperdb-mcp", + "version": "1.2.3", + "package_path": { "display": "/opt/hyperdb/node_modules/hyperdb-mcp/package.json", "encoding": "utf8" } + }, + "platform": { + "name": "hyperdb-mcp-linux-x64-gnu", + "version": "1.2.3", + "package_path": { "display": "/opt/hyperdb/node_modules/hyperdb-mcp-linux-x64-gnu/package.json", "encoding": "utf8" } + }, + "executable_path": { "display": "/opt/hyperdb/node_modules/hyperdb-mcp-linux-x64-gnu/hyperdb-mcp", "encoding": "utf8" } + }) + ); + assert!( + !serde_json::to_string(&identity) + .expect("launcher identity must serialize") + .contains(secret), + "unknown launcher keys must never be re-emitted" + ); + + let malformed = + parse_launcher_identity(Some(OsStr::new(r#"{"wrapper":{"name":"hyperdb-mcp"}"#))); + assert_eq!(malformed.identity, None); + assert_eq!( + malformed.warnings, + vec![IdentityWarning::MalformedLauncherInfo] + ); + + let utf8_path = ReportedPath::from_os_str(OsStr::new("/tmp/HyperDB/über.hyper")); + assert_eq!(utf8_path.display, "/tmp/HyperDB/über.hyper"); + assert_eq!(utf8_path.encoding, PathEncoding::Utf8); + + let non_utf8 = non_utf8_os_string(); + let lossy_path = ReportedPath::from_os_str(&non_utf8); + assert_eq!(lossy_path.encoding, PathEncoding::Lossy); + assert!(lossy_path.display.contains('\u{fffd}')); + assert!(lossy_path.display.len() <= 4 * 1024); + + let long_path = format!("/tmp/{}", "x".repeat(5 * 1024)); + let bounded_path = ReportedPath::from_os_str(OsStr::new(&long_path)); + assert_eq!(bounded_path.encoding, PathEncoding::Utf8); + assert!(bounded_path.display.len() <= 4 * 1024); +} + +#[test] +fn installation_identity_version_warning_contract() { + let matching_launcher = launcher_json( + "hyperdb-mcp", + Some("1.2.3"), + "hyperdb-mcp-linux-x64-gnu", + Some("1.2.3"), + ); + let matching = installation_identity_from_parts( + OsStr::new("/opt/hyperdb/hyperdb-mcp"), + "1.2.3.rdeadbeef-dirty-20260814T120000Z", + "0.7.0.rdeadbeef-dirty-20260814T120000Z", + Some(OsStr::new(&matching_launcher)), + ); + + assert_eq!( + matching.mcp.source, + "1.2.3.rdeadbeef-dirty-20260814T120000Z" + ); + assert_eq!(matching.mcp.version.as_deref(), Some("1.2.3")); + assert_eq!( + matching.mcp.build.as_deref(), + Some("deadbeef-dirty-20260814T120000Z") + ); + assert_eq!( + matching.hyper_rust_api.source, + "0.7.0.rdeadbeef-dirty-20260814T120000Z" + ); + assert_eq!(matching.hyper_rust_api.version.as_deref(), Some("0.7.0")); + assert_eq!( + matching.hyper_rust_api.build.as_deref(), + Some("deadbeef-dirty-20260814T120000Z") + ); + assert!( + matching.warnings.is_empty(), + "the native .r suffix is not part of npm semver: {:?}", + matching.warnings + ); + + let mismatched_launcher = launcher_json( + "hyperdb-mcp", + Some("2.0.0"), + "hyperdb-mcp-linux-x64-gnu", + Some("3.0.0"), + ); + let mismatched = installation_identity_from_parts( + OsStr::new("/opt/hyperdb/hyperdb-mcp"), + "1.2.3.rdeadbeef", + "0.7.0.rdeadbeef", + Some(OsStr::new(&mismatched_launcher)), + ); + assert!(mismatched.warnings.iter().any(|warning| { + matches!( + warning, + IdentityWarning::VersionMismatch { + native, + wrapper: Some(wrapper), + platform: Some(platform), + } if native == "1.2.3" && wrapper == "2.0.0" && platform == "3.0.0" + ) + })); + + let malformed_launcher = launcher_json( + "hyperdb-mcp", + Some("1.2.3.rlauncher-hash"), + "hyperdb-mcp-linux-x64-gnu", + Some("1.2.3"), + ); + let malformed = installation_identity_from_parts( + OsStr::new("/opt/hyperdb/hyperdb-mcp"), + "1.2.3.rdeadbeef", + "0.7.0.rdeadbeef", + Some(OsStr::new(&malformed_launcher)), + ); + assert!(malformed.warnings.iter().any(|warning| { + matches!( + warning, + IdentityWarning::MalformedVersion { component } + if component == "wrapper.version" + ) + })); +} + +#[test] +fn launcher_identity_rejects_oversize_without_secret_leakage() { + const WHOLE_LIMIT: usize = 16 * 1024; + const STRING_LIMIT: usize = 4 * 1024; + const SECRET: &str = "OVERSIZE_SECRET_SENTINEL_7637fb"; + + let base = launcher_json( + "hyperdb-mcp", + Some("1.2.3"), + "hyperdb-mcp-linux-x64-gnu", + Some("1.2.3"), + ); + let mut at_whole_limit = base.clone(); + at_whole_limit.push_str(&" ".repeat(WHOLE_LIMIT - at_whole_limit.len())); + assert_eq!(at_whole_limit.len(), WHOLE_LIMIT); + assert!( + parse_launcher_identity(Some(OsStr::new(&at_whole_limit))) + .identity + .is_some(), + "the 16 KiB boundary itself must remain accepted" + ); + + let mut over_whole_limit = json!({ + "wrapper": { "name": "hyperdb-mcp", "version": "1.2.3", "package_path": "/wrapper" }, + "platform": { "name": "hyperdb-mcp-linux-x64-gnu", "version": "1.2.3", "package_path": "/platform" }, + "executable_path": "/platform/hyperdb-mcp", + "unknown_secret": SECRET + }) + .to_string(); + over_whole_limit.push_str(&" ".repeat(WHOLE_LIMIT + 1 - over_whole_limit.len())); + let over_whole = parse_launcher_identity(Some(OsStr::new(&over_whole_limit))); + assert_eq!(over_whole.identity, None); + assert_eq!( + over_whole.warnings, + vec![IdentityWarning::LauncherInfoTooLarge] + ); + assert!(!serde_json::to_string(&over_whole).unwrap().contains(SECRET)); + + let boundary_cases = [ + ("/wrapper/name", "n".repeat(STRING_LIMIT)), + ( + "/wrapper/version", + format!("1.0.0+{}", "a".repeat(STRING_LIMIT - 6)), + ), + ( + "/wrapper/package_path", + format!("/{}", "w".repeat(STRING_LIMIT - 1)), + ), + ("/platform/name", "p".repeat(STRING_LIMIT)), + ( + "/platform/version", + format!("1.0.0+{}", "b".repeat(STRING_LIMIT - 6)), + ), + ( + "/platform/package_path", + format!("/{}", "q".repeat(STRING_LIMIT - 1)), + ), + ( + "/executable_path", + format!("/{}", "e".repeat(STRING_LIMIT - 1)), + ), + ]; + for (pointer, boundary_value) in boundary_cases { + assert_eq!(boundary_value.len(), STRING_LIMIT); + let mut metadata = json!({ + "wrapper": { "name": "hyperdb-mcp", "version": "1.2.3", "package_path": "/wrapper" }, + "platform": { "name": "hyperdb-mcp-linux-x64-gnu", "version": "1.2.3", "package_path": "/platform" }, + "executable_path": "/platform/hyperdb-mcp" + }); + *metadata + .pointer_mut(pointer) + .unwrap_or_else(|| panic!("test fixture pointer {pointer} must exist")) = + Value::String(boundary_value); + let raw = metadata.to_string(); + assert!( + parse_launcher_identity(Some(OsStr::new(&raw))) + .identity + .is_some(), + "the 4 KiB boundary itself must remain accepted for {pointer}" + ); + } + + let over_limit_fields = [ + ("/wrapper/name", "wrapper.name"), + ("/wrapper/version", "wrapper.version"), + ("/wrapper/package_path", "wrapper.package_path"), + ("/platform/name", "platform.name"), + ("/platform/version", "platform.version"), + ("/platform/package_path", "platform.package_path"), + ("/executable_path", "executable_path"), + ]; + for (pointer, field) in over_limit_fields { + let mut metadata = json!({ + "wrapper": { "name": "hyperdb-mcp", "version": "1.2.3", "package_path": "/wrapper" }, + "platform": { "name": "hyperdb-mcp-linux-x64-gnu", "version": "1.2.3", "package_path": "/platform" }, + "executable_path": "/platform/hyperdb-mcp", + "unknown_secret": SECRET + }); + *metadata + .pointer_mut(pointer) + .unwrap_or_else(|| panic!("test fixture pointer {pointer} must exist")) = + Value::String("x".repeat(STRING_LIMIT + 1)); + + let raw = metadata.to_string(); + let over_string = parse_launcher_identity(Some(OsStr::new(&raw))); + assert_eq!(over_string.identity, None, "overlong {field} was accepted"); + assert_eq!( + over_string.warnings, + vec![IdentityWarning::LauncherFieldTooLarge { + field: field.to_owned() + }] + ); + let serialized = serde_json::to_value(&over_string).unwrap_or(Value::Null); + assert!(!serialized.to_string().contains(SECRET)); + } +} diff --git a/hyperdb-mcp/tests/doctor_tests.rs b/hyperdb-mcp/tests/doctor_tests.rs new file mode 100644 index 0000000..dc9ebcb --- /dev/null +++ b/hyperdb-mcp/tests/doctor_tests.rs @@ -0,0 +1,2310 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Native CLI contracts for the side-effect-free `doctor` report. + +use std::ffi::{OsStr, OsString}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use hyperdb_mcp::daemon::discovery::DaemonInfo; +use hyperdb_mcp::daemon::health::{self, DaemonState, HealthListener}; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const SECRET_SENTINEL: &str = "UNKNOWN_SECRET_SENTINEL_doctor_7d31e9"; +const MAX_REPORTED_STRING_BYTES: usize = 4 * 1024; +const PUBLIC_README: &str = include_str!("../README.md"); + +/// Canonicalize a path the way the `doctor` binary reports its own paths. +/// +/// `std::fs::canonicalize` prepends the `\\?\` verbatim prefix on Windows, +/// but the paths the running binary reports come from `current_dir` / +/// `current_exe`, which are *un-prefixed*. Comparing a prefixed expected +/// path against an un-prefixed reported one fails only on Windows. Strip the +/// prefix (leaving genuine UNC paths, `\\?\UNC\...`, alone) so the expected +/// paths match what the binary emits. On non-Windows this is a plain +/// canonicalize. +fn canonicalize_for_test(path: &Path) -> std::io::Result { + let canonical = std::fs::canonicalize(path)?; + #[cfg(windows)] + { + if let Some(s) = canonical.to_str() { + let stripped = match s.strip_prefix(r"\\?\") { + Some(rest) if !rest.starts_with("UNC\\") => rest, + _ => s, + }; + return Ok(PathBuf::from(stripped)); + } + } + Ok(canonical) +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum SnapshotNode { + Directory, + File(Vec), + Symlink(PathBuf), +} + +#[derive(Debug)] +struct DoctorSandbox { + _temp_dir: TempDir, + root: PathBuf, + state_dir: PathBuf, + persistent_path: PathBuf, + home_dir: PathBuf, + runtime_tmp_dir: PathBuf, + wrapper_package_path: PathBuf, + platform_package_path: PathBuf, + launcher_executable_path: PathBuf, + isolated_daemon_port: u16, + _isolated_daemon_listener: TcpListener, +} + +impl DoctorSandbox { + fn new() -> Self { + let temp_dir = TempDir::new().expect("create isolated doctor test root"); + let root = + canonicalize_for_test(temp_dir.path()).expect("canonicalize isolated doctor test root"); + let isolated_daemon_listener = TcpListener::bind(("127.0.0.1", 0)) + .expect("reserve an OS-assigned foreign daemon-isolation port"); + let isolated_daemon_port = isolated_daemon_listener + .local_addr() + .expect("read daemon-isolation listener address") + .port(); + Self { + state_dir: root.join("state-must-not-be-created"), + persistent_path: root + .join("persistent-parent-must-not-be-created") + .join("default.hyper"), + home_dir: root.join("home-must-not-be-created"), + runtime_tmp_dir: root.join("tmp-must-not-be-created"), + wrapper_package_path: root.join("npm/wrapper/package.json"), + platform_package_path: root.join("npm/platform/package.json"), + launcher_executable_path: root.join("npm/platform/hyperdb-mcp"), + isolated_daemon_port, + _isolated_daemon_listener: isolated_daemon_listener, + _temp_dir: temp_dir, + root, + } + } + + fn launcher_metadata(&self, wrapper_name: &str) -> String { + json!({ + "wrapper": { + "name": wrapper_name, + "version": env!("CARGO_PKG_VERSION"), + "package_path": self.wrapper_package_path.to_string_lossy() + }, + "platform": { + "name": "hyperdb-mcp-test-platform", + "version": env!("CARGO_PKG_VERSION"), + "package_path": self.platform_package_path.to_string_lossy() + }, + "executable_path": self.launcher_executable_path.to_string_lossy(), + "unknown_secret": SECRET_SENTINEL + }) + .to_string() + } + + fn run(&self, json_output: bool, launcher_metadata: &str, hyperd_path: &OsStr) -> Output { + let args = if json_output { + &["doctor", "--json", "--read-only", "--no-daemon"][..] + } else { + &["doctor", "--read-only", "--no-daemon"][..] + }; + self.run_with_options( + args, + Some(self.persistent_path.as_os_str()), + launcher_metadata, + Some(hyperd_path), + self.isolated_daemon_port, + ) + } + + fn run_with_options( + &self, + args: &[&str], + persistent_environment: Option<&OsStr>, + launcher_metadata: &str, + hyperd_path: Option<&OsStr>, + daemon_port: u16, + ) -> Output { + self.run_with_home_options( + args, + persistent_environment, + launcher_metadata, + hyperd_path, + daemon_port, + self.home_dir.as_os_str(), + ) + } + + fn run_with_home_options( + &self, + args: &[&str], + persistent_environment: Option<&OsStr>, + launcher_metadata: &str, + hyperd_path: Option<&OsStr>, + daemon_port: u16, + home_profile: &OsStr, + ) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_hyperdb-mcp")); + command.env_clear(); + preserve_child_runtime_environment(&mut command); + command + .current_dir(&self.root) + .env("HOME", home_profile) + .env("USERPROFILE", home_profile) + .env( + "XDG_DATA_HOME", + self.root.join("xdg-data-must-not-be-created"), + ) + .env("APPDATA", self.root.join("appdata-must-not-be-created")) + .env( + "LOCALAPPDATA", + self.root.join("localappdata-must-not-be-created"), + ) + .env("TMPDIR", &self.runtime_tmp_dir) + .env("TMP", &self.runtime_tmp_dir) + .env("TEMP", &self.runtime_tmp_dir) + .env("HYPERDB_STATE_DIR", &self.state_dir) + .env("HYPERDB_MCP_LAUNCHER_INFO", launcher_metadata) + .env("HYPERDB_DAEMON_PORT", daemon_port.to_string()) + .env("NO_COLOR", "1") + .args(args); + if let Some(path) = persistent_environment { + command.env("HYPERDB_PERSISTENT_DB", path); + } + if let Some(path) = hyperd_path { + command.env("HYPERD_PATH", path); + } + command.output().expect("run isolated hyperdb-mcp doctor") + } + + fn required_utf8_reported_paths(&self, hyperd_path: &Path) -> Vec { + vec![ + self.persistent_path.clone(), + self.state_dir.clone(), + self.state_dir.join("daemon.json"), + self.persistent_path + .parent() + .expect("persistent fixture has a parent") + .join("hyperdb-mcp.log"), + hyperd_path.to_path_buf(), + self.wrapper_package_path.clone(), + self.platform_package_path.clone(), + self.launcher_executable_path.clone(), + ] + } + + fn assert_no_artifacts(&self) { + for path in [ + &self.state_dir, + &self.persistent_path, + &self.home_dir, + &self.runtime_tmp_dir, + ] { + assert!( + !path.exists(), + "doctor must not create isolated path {}", + path.display() + ); + } + assert!( + !self.state_dir.join("daemon.json").exists(), + "doctor must not create a discovery file" + ); + assert!( + !self + .persistent_path + .parent() + .expect("persistent fixture has a parent") + .join("hyperdb-mcp.log") + .exists(), + "doctor must not create a client log" + ); + } +} + +#[derive(Debug)] +struct RunningHealthListener { + port: u16, + info: DaemonInfo, + state: Arc, + handle: Option>, +} + +impl RunningHealthListener { + fn start() -> Self { + let listener = HealthListener::bind(0).expect("bind OS-assigned health-listener port"); + let port = listener.port; + let info = DaemonInfo { + pid: std::process::id(), + hyperd_endpoint: "127.0.0.1:54321".to_owned(), + health_port: port, + started_at: "2026-08-14T12:34:56Z".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + }; + let state = Arc::new(DaemonState::new()); + let shared_info = Arc::new(Mutex::new(info.clone())); + let run_state = Arc::clone(&state); + let handle = std::thread::spawn(move || listener.run(run_state, shared_info)); + Self { + port, + info, + state, + handle: Some(handle), + } + } + + fn prime_accept_sleep(&self) { + let response = health::send_command(self.port, "PING") + .expect("real health listener must answer the priming PING"); + assert!( + response.starts_with("PONG hyperdb-mcp "), + "unexpected health-listener PING response: {response:?}" + ); + // The response comes from a per-connection worker. Give the accept + // thread a small scheduling window to re-enter its real 100 ms + // WouldBlock sleep before launching the already-warm doctor child. + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +impl Drop for RunningHealthListener { + fn drop(&mut self) { + self.state.request_shutdown(); + if let Some(handle) = self.handle.take() { + handle + .join() + .expect("health listener must shut down cleanly"); + } + } +} + +fn preserve_child_runtime_environment(command: &mut Command) { + // The binary path is absolute, but these variables may still be required by + // the platform loader. No application configuration is inherited. + for key in [ + "PATH", + "SystemRoot", + "WINDIR", + "COMSPEC", + "PATHEXT", + "LD_LIBRARY_PATH", + "DYLD_LIBRARY_PATH", + ] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } +} + +fn snapshot_tree(root: &Path) -> Vec<(PathBuf, SnapshotNode)> { + fn visit(root: &Path, directory: &Path, entries: &mut Vec<(PathBuf, SnapshotNode)>) { + let mut children: Vec<_> = std::fs::read_dir(directory) + .unwrap_or_else(|error| { + panic!("read snapshot directory {}: {error}", directory.display()) + }) + .map(|entry| entry.expect("read snapshot entry").path()) + .collect(); + children.sort_unstable(); + + for path in children { + let relative = path + .strip_prefix(root) + .expect("snapshot entry must be below root") + .to_path_buf(); + let metadata = std::fs::symlink_metadata(&path).unwrap_or_else(|error| { + panic!("inspect snapshot entry {}: {error}", path.display()) + }); + if metadata.file_type().is_symlink() { + let target = std::fs::read_link(&path).unwrap_or_else(|error| { + panic!("read snapshot symlink {}: {error}", path.display()) + }); + entries.push((relative, SnapshotNode::Symlink(target))); + } else if metadata.is_dir() { + entries.push((relative, SnapshotNode::Directory)); + visit(root, &path, entries); + } else { + let bytes = std::fs::read(&path).unwrap_or_else(|error| { + panic!("read snapshot file {}: {error}", path.display()) + }); + entries.push((relative, SnapshotNode::File(bytes))); + } + } + } + + let mut entries = Vec::new(); + visit(root, root, &mut entries); + entries +} + +fn assert_success(output: &Output, invocation: &str) { + assert!( + output.status.success(), + "`{invocation}` must produce a report and exit zero, even with warnings:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn parse_json_report(output: &Output, invocation: &str) -> Value { + assert_success(output, invocation); + serde_json::from_slice(&output.stdout) + .unwrap_or_else(|error| panic!("`{invocation}` must emit valid JSON: {error}")) +} + +fn assert_exact_top_level_keys(report: &Value) { + let object = report + .as_object() + .unwrap_or_else(|| panic!("doctor JSON must be a top-level object: {report}")); + let mut actual: Vec<_> = object.keys().map(String::as_str).collect(); + actual.sort_unstable(); + assert_eq!( + actual, + [ + "configuration", + "daemon", + "installation", + "status", + "tool_catalog", + "warnings", + ], + "doctor top-level JSON contract must be exact" + ); +} + +fn report_object<'a>(report: &'a Value, section: &str) -> &'a serde_json::Map { + report + .get(section) + .and_then(Value::as_object) + .unwrap_or_else(|| panic!("doctor JSON needs typed object section `{section}`: {report}")) +} + +fn path_fact_failure( + configuration: &serde_json::Map, + key: &str, + expected_display: &str, + expected_encoding: &str, + expected_exists: bool, + expected_is_file: bool, + expected_is_directory: bool, +) -> Option { + let Some(facts) = configuration.get(key).and_then(Value::as_object) else { + return Some(format!("`configuration.{key}` must be a path-facts object")); + }; + let Some(path) = facts.get("path").and_then(Value::as_object) else { + return Some(format!( + "`configuration.{key}.path` must carry display + encoding" + )); + }; + let actual = ( + path.get("display").and_then(Value::as_str), + path.get("encoding").and_then(Value::as_str), + facts.get("exists").and_then(Value::as_bool), + facts.get("is_file").and_then(Value::as_bool), + facts.get("is_directory").and_then(Value::as_bool), + ); + let expected = ( + Some(expected_display), + Some(expected_encoding), + Some(expected_exists), + Some(expected_is_file), + Some(expected_is_directory), + ); + (actual != expected).then(|| { + format!("`configuration.{key}` mismatch: actual={actual:?}, expected={expected:?}") + }) +} + +fn reported_path_failure( + configuration: &serde_json::Map, + key: &str, + expected_display: &str, + expected_encoding: &str, +) -> Option { + let Some(value) = configuration.get(key).and_then(Value::as_object) else { + return Some(format!("`configuration.{key}` must report a path")); + }; + let path = value + .get("path") + .and_then(Value::as_object) + .unwrap_or(value); + let actual = ( + path.get("display").and_then(Value::as_str), + path.get("encoding").and_then(Value::as_str), + ); + let expected = (Some(expected_display), Some(expected_encoding)); + (actual != expected).then(|| { + format!("`configuration.{key}` mismatch: actual={actual:?}, expected={expected:?}") + }) +} + +fn warning_text(report: &Value) -> String { + report + .get("warnings") + .cloned() + .unwrap_or(Value::Null) + .to_string() + .to_lowercase() +} + +fn has_warning_code(report: &Value, expected: &str) -> bool { + report + .get("warnings") + .and_then(Value::as_array) + .is_some_and(|warnings| { + warnings + .iter() + .any(|warning| warning.get("code").and_then(Value::as_str) == Some(expected)) + }) +} + +fn daemon_state(report: &Value) -> Option<&str> { + report.pointer("/daemon/state").and_then(Value::as_str) +} + +fn human_path_parity_failure( + report: &Value, + human: &str, + configuration_key: &str, + human_label: &str, +) -> Option { + let Some(configuration) = report.get("configuration").and_then(Value::as_object) else { + return Some("doctor omitted typed configuration".to_owned()); + }; + let Some(value) = configuration.get(configuration_key) else { + return Some(format!( + "configuration omitted `{configuration_key}` needed for JSON/human parity" + )); + }; + let expected = if value.is_null() { + format!(" {human_label}: unavailable") + } else { + let Some(object) = value.as_object() else { + return Some(format!( + "configuration.{configuration_key} was not a path object: {value}" + )); + }; + let path = object + .get("path") + .and_then(Value::as_object) + .unwrap_or(object); + let Some(display) = path.get("display").and_then(Value::as_str) else { + return Some(format!( + "configuration.{configuration_key} omitted path display" + )); + }; + let Some(encoding) = path.get("encoding").and_then(Value::as_str) else { + return Some(format!( + "configuration.{configuration_key} omitted path encoding" + )); + }; + match ( + object.get("exists").and_then(Value::as_bool), + object.get("is_file").and_then(Value::as_bool), + object.get("is_directory").and_then(Value::as_bool), + ) { + (Some(exists), Some(is_file), Some(is_directory)) => format!( + " {human_label}: {display} (encoding: {encoding}; exists: {exists}; file: {is_file}; directory: {is_directory})" + ), + (None, None, None) => { + format!(" {human_label}: {display} (encoding: {encoding})") + } + facts => { + return Some(format!( + "configuration.{configuration_key} had incomplete filesystem facts: {facts:?}" + )); + } + } + }; + (!human.contains(&expected)).then(|| { + format!( + "human report disagrees with configuration.{configuration_key}; missing {expected:?}" + ) + }) +} + +fn catalog_human_parity_failures(report: &Value, human: &str) -> Vec { + let Some(catalog) = report.get("tool_catalog").and_then(Value::as_object) else { + return vec!["doctor omitted typed tool_catalog".to_owned()]; + }; + let metrics = [ + ("tool_count", "Tools"), + ("canonical_tool_bytes", "Canonical generated tools bytes"), + ( + "initialization_instructions_bytes", + "Initialization instructions bytes", + ), + ("get_readme_bytes", "get_readme bytes"), + ]; + let mut failures = Vec::new(); + for (key, label) in metrics { + let Some(value) = catalog.get(key).and_then(Value::as_u64) else { + failures.push(format!("tool_catalog.{key} must be an unsigned metric")); + continue; + }; + if value == 0 { + failures.push(format!("tool_catalog.{key} must be nonzero")); + } + let expected = format!(" {label}: {value}"); + if !human.contains(&expected) { + failures.push(format!( + "human report omitted tool_catalog.{key} parity line {expected:?}" + )); + } + } + if catalog.get("tool_count").and_then(Value::as_u64) != Some(33) { + failures.push("generated catalog must contain exactly 33 tools".to_owned()); + } + failures +} + +fn live_daemon_human_parity_failure(report: &Value, human: &str) -> Option { + let Some(daemon) = report.get("daemon").and_then(Value::as_object) else { + return Some("doctor omitted typed daemon identity".to_owned()); + }; + let required_text = [ + ( + "state", + "State", + daemon.get("state").and_then(Value::as_str), + ), + ( + "hyperd_endpoint", + "Hyperd endpoint", + daemon.get("hyperd_endpoint").and_then(Value::as_str), + ), + ( + "started_at", + "Started", + daemon.get("started_at").and_then(Value::as_str), + ), + ( + "version", + "Takeover version", + daemon.get("version").and_then(Value::as_str), + ), + ( + "mcp_version", + "MCP build", + daemon.get("mcp_version").and_then(Value::as_str), + ), + ]; + let mut missing = Vec::new(); + for (key, label, value) in required_text { + let Some(value) = value else { + missing.push(format!("daemon.{key} missing from live identity")); + continue; + }; + let expected = format!(" {label}: {value}"); + if !human.contains(&expected) { + missing.push(format!("human live identity omitted {expected:?}")); + } + } + for (key, label) in [("pid", "PID"), ("health_port", "Health port")] { + let Some(value) = daemon.get(key).and_then(Value::as_u64) else { + missing.push(format!("daemon.{key} missing from live identity")); + continue; + }; + let expected = format!(" {label}: {value}"); + if !human.contains(&expected) { + missing.push(format!("human live identity omitted {expected:?}")); + } + } + let executable = daemon.get("executable_path").and_then(Value::as_object); + match executable { + Some(executable) => { + let display = executable.get("display").and_then(Value::as_str); + let encoding = executable.get("encoding").and_then(Value::as_str); + match (display, encoding) { + (Some(display), Some(encoding)) => { + let expected = format!(" Daemon executable: {display} (encoding: {encoding})"); + if !human.contains(&expected) { + missing.push(format!("human live identity omitted {expected:?}")); + } + } + _ => missing.push( + "daemon.executable_path must carry display + encoding in live identity" + .to_owned(), + ), + } + } + None => missing.push("daemon.executable_path missing from live identity".to_owned()), + } + (!missing.is_empty()).then(|| missing.join("; ")) +} + +fn live_daemon_fact_failure( + report: &Value, + expected_state: &str, + expected: &DaemonInfo, +) -> Option { + let Some(daemon) = report.get("daemon").and_then(Value::as_object) else { + return Some("doctor omitted the typed daemon section".to_owned()); + }; + let actual = ( + daemon.get("state").and_then(Value::as_str), + daemon.get("pid").and_then(Value::as_u64), + daemon.get("hyperd_endpoint").and_then(Value::as_str), + daemon.get("health_port").and_then(Value::as_u64), + daemon.get("started_at").and_then(Value::as_str), + daemon.get("version").and_then(Value::as_str), + ); + let expected_facts = ( + Some(expected_state), + Some(u64::from(expected.pid)), + Some(expected.hyperd_endpoint.as_str()), + Some(u64::from(expected.health_port)), + Some(expected.started_at.as_str()), + Some(expected.version.as_str()), + ); + (actual != expected_facts).then(|| { + format!("live daemon facts mismatch: actual={actual:?}, expected={expected_facts:?}") + }) +} + +fn collect_reported_paths(value: &Value, paths: &mut Vec<(String, String)>) { + match value { + Value::Object(object) => { + if object.contains_key("display") || object.contains_key("encoding") { + let display = object + .get("display") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("reported path missing string display: {value}")); + let encoding = object + .get("encoding") + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("reported path missing string encoding: {value}")); + assert!( + matches!(encoding, "utf8" | "lossy"), + "reported path encoding must be utf8 or lossy: {value}" + ); + assert!( + display.len() <= MAX_REPORTED_STRING_BYTES, + "reported path exceeds 4 KiB: {} bytes", + display.len() + ); + paths.push((display.to_owned(), encoding.to_owned())); + } + for child in object.values() { + collect_reported_paths(child, paths); + } + } + Value::Array(values) => { + for child in values { + collect_reported_paths(child, paths); + } + } + _ => {} + } +} + +fn contains_string(value: &Value, expected: &str) -> bool { + match value { + Value::String(actual) => actual == expected, + Value::Array(values) => values.iter().any(|value| contains_string(value, expected)), + Value::Object(object) => object + .values() + .any(|value| contains_string(value, expected)), + _ => false, + } +} + +fn contains_number(value: &Value, expected: u64) -> bool { + match value { + Value::Number(actual) => actual.as_u64() == Some(expected), + Value::Array(values) => values.iter().any(|value| contains_number(value, expected)), + Value::Object(object) => object + .values() + .any(|value| contains_number(value, expected)), + _ => false, + } +} + +fn assert_all_strings_bounded(value: &Value) { + match value { + Value::String(string) => assert!( + string.len() <= MAX_REPORTED_STRING_BYTES, + "doctor emitted an overlong string ({} bytes)", + string.len() + ), + Value::Array(values) => { + for child in values { + assert_all_strings_bounded(child); + } + } + Value::Object(object) => { + for child in object.values() { + assert_all_strings_bounded(child); + } + } + _ => {} + } +} + +fn assert_local_path_sharing_warning(text: &str) { + let lower = text.to_lowercase(); + assert!( + lower.contains("local paths") && lower.contains("review") && lower.contains("shar"), + "doctor must explicitly warn users to review local paths before sharing:\n{text}" + ); +} + +fn normalized_human_text(text: &str) -> String { + text.to_lowercase().replace(['_', '-'], " ") +} + +fn assert_visible_escape(text: &str, codepoint: u32) { + let candidates = [ + format!("\\u{{{codepoint:x}}}"), + format!("\\u{codepoint:04x}"), + format!("\\x{codepoint:02x}"), + ]; + assert!( + candidates.iter().any(|candidate| text.contains(candidate)), + "human output must preserve U+{codepoint:04X} as a visible escape; accepted forms: {candidates:?}\n{text}" + ); +} + +#[derive(Debug)] +struct NonUtf8PathFixture { + raw: OsString, + display: String, +} + +#[cfg(unix)] +fn non_utf8_path(root: &Path, stem: &str) -> NonUtf8PathFixture { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let mut bytes = root.as_os_str().as_bytes().to_vec(); + bytes.extend_from_slice(format!("/{stem}-").as_bytes()); + bytes.push(0xff); + bytes.extend_from_slice(b".hyper"); + NonUtf8PathFixture { + raw: OsString::from_vec(bytes), + display: format!("{}/{stem}-\u{fffd}.hyper", root.display()), + } +} + +#[cfg(windows)] +fn non_utf8_path(root: &Path, stem: &str) -> NonUtf8PathFixture { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let mut wide: Vec = root.as_os_str().encode_wide().collect(); + wide.push(u16::from(b'\\')); + wide.extend(stem.encode_utf16()); + wide.push(u16::from(b'-')); + wide.push(0xD800); + wide.extend(".hyper".encode_utf16()); + NonUtf8PathFixture { + raw: OsString::from_wide(&wide), + display: format!("{}\\{stem}-\u{fffd}.hyper", root.display()), + } +} + +#[cfg(windows)] +const fn platform_hyperd_name() -> &'static str { + "hyperd.exe" +} + +#[cfg(not(windows))] +const fn platform_hyperd_name() -> &'static str { + "hyperd" +} + +fn create_upward_hyperd_candidate(sandbox: &DoctorSandbox) -> PathBuf { + let candidate = sandbox + .root + .join(".hyperd") + .join("current") + .join(platform_hyperd_name()); + std::fs::create_dir_all(candidate.parent().expect("candidate has parent")) + .expect("create upward hyperd fixture directory"); + std::fs::write(&candidate, b"not executed by doctor").expect("write upward hyperd fixture"); + candidate +} + +#[cfg(unix)] +fn non_utf8_overlong_path(root: &Path) -> OsString { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let mut bytes = root.as_os_str().as_bytes().to_vec(); + bytes.extend_from_slice(b"/hyperd-\xff-"); + bytes.extend(std::iter::repeat(b'x').take(5 * 1024)); + OsString::from_vec(bytes) +} + +#[cfg(windows)] +fn non_utf8_overlong_path(root: &Path) -> OsString { + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + + let mut wide: Vec = root.as_os_str().encode_wide().collect(); + wide.extend([u16::from(b'\\'), 0xD800, u16::from(b'-')]); + wide.extend(std::iter::repeat(u16::from(b'x')).take(5 * 1024)); + OsString::from_wide(&wide) +} + +#[cfg(not(any(unix, windows)))] +fn non_utf8_overlong_path(root: &Path) -> OsString { + root.join(format!("hyperd-{}", "x".repeat(5 * 1024))) + .into_os_string() +} + +#[test] +fn doctor_cli_json_and_human_smoke_is_side_effect_free() { + let sandbox = DoctorSandbox::new(); + let hyperd_path = sandbox.root.join("missing-hyperd"); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let before = snapshot_tree(&sandbox.root); + + let json_output = sandbox.run(true, &launcher_metadata, hyperd_path.as_os_str()); + assert_success(&json_output, "hyperdb-mcp doctor --json"); + assert_eq!( + snapshot_tree(&sandbox.root), + before, + "JSON doctor must be byte-for-byte side-effect-free" + ); + sandbox.assert_no_artifacts(); + + let report: Value = serde_json::from_slice(&json_output.stdout) + .unwrap_or_else(|error| panic!("doctor --json must emit valid JSON: {error}")); + assert_exact_top_level_keys(&report); + let status = report + .get("status") + .and_then(Value::as_str) + .expect("doctor JSON needs typed string section `status`"); + let installation = report_object(&report, "installation"); + let configuration = report_object(&report, "configuration"); + let daemon = report_object(&report, "daemon"); + let tool_catalog = report_object(&report, "tool_catalog"); + let warnings = report + .get("warnings") + .and_then(Value::as_array) + .expect("doctor JSON needs typed array section `warnings`"); + + assert!(!status.is_empty(), "doctor status must be meaningful"); + assert!( + contains_string(&Value::Object(configuration.clone()), "persistent_attached"), + "environment-provided persistent path must report persistent_attached mode" + ); + assert!( + contains_string(&Value::Object(configuration.clone()), "environment"), + "persistent path source must be reported as environment" + ); + assert!( + contains_number(&Value::Object(tool_catalog.clone()), 33), + "doctor must measure all 33 generated MCP tools" + ); + assert_eq!( + configuration.get("read_only").and_then(Value::as_bool), + Some(true), + "--read-only must survive in typed JSON" + ); + assert_eq!( + configuration.get("no_daemon").and_then(Value::as_bool), + Some(true), + "--no-daemon must survive in typed JSON" + ); + let daemon_state = daemon + .get("state") + .and_then(Value::as_str) + .expect("daemon section must contain one typed discovery state"); + assert_eq!( + daemon_state, "missing", + "OS-assigned pinned isolation port must prevent a resident developer daemon from changing this smoke test" + ); + + let mut reported_paths = Vec::new(); + collect_reported_paths(&report, &mut reported_paths); + for expected in sandbox.required_utf8_reported_paths(&hyperd_path) { + let expected = expected.to_string_lossy(); + assert!( + reported_paths + .iter() + .any(|(display, encoding)| display == expected.as_ref() && encoding == "utf8"), + "required path must carry display + utf8 semantics: {expected}; paths={reported_paths:?}" + ); + } + let current_exe = canonicalize_for_test(Path::new(env!("CARGO_BIN_EXE_hyperdb-mcp"))) + .expect("canonicalize test binary"); + assert!( + reported_paths.iter().any(|(display, encoding)| { + display == current_exe.to_string_lossy().as_ref() && encoding == "utf8" + }), + "installation must report the actual native executable path" + ); + assert_local_path_sharing_warning(&report.to_string()); + assert!( + !report.to_string().contains(SECRET_SENTINEL), + "unknown launcher keys must never appear in JSON" + ); + + let human_output = sandbox.run(false, &launcher_metadata, hyperd_path.as_os_str()); + assert_success(&human_output, "hyperdb-mcp doctor"); + assert_eq!( + snapshot_tree(&sandbox.root), + before, + "human doctor must be byte-for-byte side-effect-free" + ); + sandbox.assert_no_artifacts(); + + let human = + std::str::from_utf8(&human_output.stdout).expect("human doctor report must be valid UTF-8"); + let normalized = normalized_human_text(human); + for heading in [ + "status", + "installation", + "configuration", + "daemon", + "tool catalog", + "warnings", + ] { + assert!( + normalized.contains(heading), + "human report missing `{heading}` section:\n{human}" + ); + } + for core_fact in [ + status, + "persistent_attached", + "environment", + daemon_state, + env!("CARGO_PKG_VERSION"), + ] { + let normalized_fact = core_fact.replace('_', " "); + assert!( + normalized.contains(&normalized_fact), + "JSON/human reports disagree about core fact `{core_fact}`:\n{human}" + ); + } + assert!( + normalized.contains("33") && normalized.contains("tool"), + "human report must carry the generated 33-tool count:\n{human}" + ); + assert!( + human.contains(" Read only: true") && human.contains(" No daemon: true"), + "human report must agree with both true configuration flags:\n{human}" + ); + let path_parity_contracts = [ + ("observed_persistent_path", "Observed persistent path"), + ("resolved_persistent_path", "Resolved persistent path"), + ("resolved_persistent_parent", "Resolved persistent parent"), + ("daemon_state_directory", "Daemon state directory"), + ("daemon_discovery_file", "Daemon discovery file"), + ("client_log", "Client log"), + ("observed_hyperd_path", "Observed HYPERD_PATH"), + ("effective_hyperd_path", "Effective hyperd path"), + ( + "upward_hyperd_candidate", + "Upward .hyperd/current candidate", + ), + ]; + for (key, label) in path_parity_contracts { + if let Some(failure) = human_path_parity_failure(&report, human, key, label) { + panic!("JSON/human path parity failed for {key}: {failure}"); + } + } + let catalog_failures = catalog_human_parity_failures(&report, human); + assert!( + catalog_failures.is_empty(), + "JSON/human catalog parity failures:\n{}", + catalog_failures.join("\n") + ); + for expected in sandbox.required_utf8_reported_paths(&hyperd_path) { + assert!( + human.contains(expected.to_string_lossy().as_ref()), + "human report missing path {}", + expected.display() + ); + } + assert_local_path_sharing_warning(human); + assert!( + !human.contains(SECRET_SENTINEL), + "unknown launcher keys must never appear in human output" + ); + assert!( + installation.contains_key("native_executable"), + "installation section must identify the native executable" + ); + assert!(warnings.iter().all(Value::is_object) || warnings.iter().all(Value::is_string)); +} + +#[test] +fn doctor_human_output_escapes_and_bounds_reported_paths() { + let sandbox = DoctorSandbox::new(); + let controlled_wrapper_name = "hyperdb-mcp\0\u{1}\u{1b}\u{7f}"; + let launcher_metadata = sandbox.launcher_metadata(controlled_wrapper_name); + let hyperd_path = non_utf8_overlong_path(&sandbox.root); + let before = snapshot_tree(&sandbox.root); + + let json_output = sandbox.run(true, &launcher_metadata, &hyperd_path); + assert_success(&json_output, "hyperdb-mcp doctor --json"); + let report: Value = serde_json::from_slice(&json_output.stdout) + .unwrap_or_else(|error| panic!("doctor --json must emit valid JSON: {error}")); + assert_eq!( + daemon_state(&report), + Some("missing"), + "OS-assigned pinned isolation port must keep the edge-case report daemon-independent" + ); + assert_eq!(snapshot_tree(&sandbox.root), before); + sandbox.assert_no_artifacts(); + + assert!( + contains_string(&report, controlled_wrapper_name), + "known launcher field must survive in typed JSON" + ); + assert_all_strings_bounded(&report); + let mut reported_paths = Vec::new(); + collect_reported_paths(&report, &mut reported_paths); + #[cfg(any(unix, windows))] + assert!( + reported_paths + .iter() + .any(|(display, encoding)| { encoding == "lossy" && display.contains('\u{fffd}') }), + "OS-supported non-UTF-8 paths must be marked lossy: {reported_paths:?}" + ); + assert!( + !report.to_string().contains(SECRET_SENTINEL), + "unknown secret sentinel must not be re-emitted" + ); + assert_local_path_sharing_warning(&report.to_string()); + + let human_output = sandbox.run(false, &launcher_metadata, &hyperd_path); + assert_success(&human_output, "hyperdb-mcp doctor"); + assert_eq!(snapshot_tree(&sandbox.root), before); + sandbox.assert_no_artifacts(); + + let human = + std::str::from_utf8(&human_output.stdout).expect("human doctor report must be valid UTF-8"); + let stderr = String::from_utf8_lossy(&human_output.stderr); + assert!( + human.contains("hyperdb-mcp"), + "known field must be rendered" + ); + for raw_control in ['\0', '\u{1}', '\u{1b}', '\u{7f}'] { + assert!( + !human.contains(raw_control), + "human report leaked raw control U+{:04X}", + u32::from(raw_control) + ); + } + for codepoint in [0, 1, 0x1b, 0x7f] { + assert_visible_escape(human, codepoint); + } + assert!( + normalized_human_text(human).contains("lossy"), + "human paths must expose lossy encoding semantics:\n{human}" + ); + assert!( + !human.contains(&"x".repeat(MAX_REPORTED_STRING_BYTES + 1)), + "human output contains an unbounded reported path" + ); + assert!( + !human.contains(SECRET_SENTINEL) && !stderr.contains(SECRET_SENTINEL), + "unknown secret sentinel must be absent from all human-mode output" + ); + assert_local_path_sharing_warning(human); +} + +#[test] +fn doctor_persistent_tilde_sources_match_runtime_and_preserve_cli_semantics() { + struct Case { + label: &'static str, + args: &'static [&'static str], + persistent_environment: Option<&'static str>, + expected_source: &'static str, + } + + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let missing_hyperd = sandbox.root.join("missing-hyperd"); + let expected_effective = sandbox.home_dir.join("data.hyper"); + let expected_parent = sandbox.home_dir.clone(); + let expected_log = sandbox.home_dir.join("hyperdb-mcp.log"); + let before = snapshot_tree(&sandbox.root); + let cases = [ + Case { + label: "preferred CLI", + args: &["doctor", "--json", "--persistent-db", "~/data.hyper"], + persistent_environment: Some("~/ignored-environment.hyper"), + expected_source: "cli", + }, + Case { + label: "environment", + args: &["doctor", "--json"], + persistent_environment: Some("~/data.hyper"), + expected_source: "environment", + }, + Case { + label: "deprecated alias", + args: &["doctor", "--json", "--workspace", "~/data.hyper"], + persistent_environment: Some("~/ignored-environment.hyper"), + expected_source: "deprecated_alias", + }, + ]; + let mut failures = Vec::new(); + + for case in cases { + let output = sandbox.run_with_options( + case.args, + case.persistent_environment.map(OsStr::new), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + if !output.status.success() { + failures.push(format!( + "{} did not exit zero: stdout={:?}, stderr={:?}", + case.label, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + continue; + } + let report = match serde_json::from_slice::(&output.stdout) { + Ok(report) => report, + Err(error) => { + failures.push(format!("{} did not emit JSON: {error}", case.label)); + continue; + } + }; + let Some(configuration) = report.get("configuration").and_then(Value::as_object) else { + failures.push(format!("{} omitted typed configuration", case.label)); + continue; + }; + + if configuration + .get("persistent_path_source") + .and_then(Value::as_str) + != Some(case.expected_source) + { + failures.push(format!( + "{} source was not {}: {}", + case.label, case.expected_source, report + )); + } + if let Some(failure) = reported_path_failure( + configuration, + "observed_persistent_path", + "~/data.hyper", + "utf8", + ) { + failures.push(format!("{}: {failure}", case.label)); + } + for failure in [ + path_fact_failure( + configuration, + "resolved_persistent_path", + expected_effective.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + path_fact_failure( + configuration, + "resolved_persistent_parent", + expected_parent.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + path_fact_failure( + configuration, + "client_log", + expected_log.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + ] + .into_iter() + .flatten() + { + failures.push(format!("{}: {failure}", case.label)); + } + if daemon_state(&report) != Some("missing") { + failures.push(format!( + "{} escaped the pinned missing-daemon sandbox: {:?}", + case.label, + daemon_state(&report) + )); + } + if case.expected_source == "deprecated_alias" + && !has_warning_code(&report, "deprecated_persistent_alias") + { + failures.push("deprecated alias omitted its typed warning".to_owned()); + } + if snapshot_tree(&sandbox.root) != before { + failures.push(format!("{} changed the isolated filesystem", case.label)); + } + } + + let disabled = sandbox.run_with_options( + &["doctor", "--json", "--ephemeral-only"], + Some(OsStr::new("~/ignored-while-disabled.hyper")), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + if disabled.status.success() { + match serde_json::from_slice::(&disabled.stdout) { + Ok(report) => { + let configuration = report_object(&report, "configuration"); + if configuration.get("persistent_mode").and_then(Value::as_str) + != Some("ephemeral_only") + || configuration + .get("persistent_path_source") + .and_then(Value::as_str) + != Some("disabled") + || configuration + .get("observed_persistent_path") + .is_some_and(|value| !value.is_null()) + || configuration + .get("resolved_persistent_path") + .is_some_and(|value| !value.is_null()) + || configuration + .get("resolved_persistent_parent") + .is_some_and(|value| !value.is_null()) + { + failures.push(format!( + "ephemeral-only did not preserve exact disabled semantics: {report}" + )); + } + let log_display = report + .pointer("/configuration/client_log/path/display") + .and_then(Value::as_str) + .unwrap_or_default(); + if !Path::new(log_display).starts_with(&sandbox.runtime_tmp_dir) + || !Path::new(log_display).ends_with("hyperdb-mcp.log") + { + failures.push(format!( + "ephemeral-only client log was not under isolated runtime temp: {log_display:?}" + )); + } + if daemon_state(&report) != Some("missing") { + failures.push("ephemeral-only daemon state was not exactly missing".to_owned()); + } + } + Err(error) => failures.push(format!("ephemeral-only did not emit JSON: {error}")), + } + } else { + failures.push(format!( + "ephemeral-only doctor failed: {}", + String::from_utf8_lossy(&disabled.stderr) + )); + } + + let conflicts = [ + ( + "ephemeral/path conflict", + &[ + "doctor", + "--json", + "--ephemeral-only", + "--persistent-db", + "~/data.hyper", + ][..], + "error: --ephemeral-only is incompatible with --persistent-db / --workspace\n", + ), + ( + "preferred/deprecated conflict", + &[ + "doctor", + "--json", + "--persistent-db", + "~/data.hyper", + "--workspace", + "~/other.hyper", + ][..], + "error: Both --persistent-db and --workspace were supplied. --workspace is a deprecated alias; pass only --persistent-db.\n", + ), + ]; + for (label, args, expected_stderr) in conflicts { + let output = sandbox.run_with_options( + args, + None, + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + if output.status.code() != Some(2) + || !output.stdout.is_empty() + || output.stderr != expected_stderr.as_bytes() + { + failures.push(format!( + "{label} contract mismatch: status={:?}, stdout={:?}, stderr={:?}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + } + + if snapshot_tree(&sandbox.root) != before { + failures.push("persistent resolution cases changed isolated filesystem bytes".to_owned()); + } + assert!( + failures.is_empty(), + "persistent path doctor contract failures:\n{}", + failures.join("\n") + ); +} + +#[test] +fn doctor_relative_persistent_path_normalizes_parent_and_matches_runtime() { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let missing_hyperd = sandbox.root.join("missing-hyperd"); + let before = snapshot_tree(&sandbox.root); + let mut failures = Vec::new(); + + let json_output = sandbox.run_with_options( + &["doctor", "--json", "--persistent-db", "foo.hyper"], + Some(OsStr::new("ignored-environment.hyper")), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + let report = parse_json_report( + &json_output, + "hyperdb-mcp doctor --json --persistent-db foo.hyper", + ); + assert_exact_top_level_keys(&report); + let configuration = report_object(&report, "configuration"); + if configuration + .get("persistent_path_source") + .and_then(Value::as_str) + != Some("cli") + { + failures.push("relative preferred CLI path lost its source".to_owned()); + } + if let Some(failure) = reported_path_failure( + configuration, + "observed_persistent_path", + "foo.hyper", + "utf8", + ) { + failures.push(failure); + } + for failure in [ + path_fact_failure( + configuration, + "resolved_persistent_path", + "foo.hyper", + "utf8", + false, + false, + false, + ), + path_fact_failure( + configuration, + "resolved_persistent_parent", + ".", + "utf8", + true, + false, + true, + ), + path_fact_failure( + configuration, + "client_log", + "hyperdb-mcp.log", + "utf8", + false, + false, + false, + ), + ] + .into_iter() + .flatten() + { + failures.push(failure); + } + if daemon_state(&report) != Some("missing") { + failures.push("relative path case escaped pinned missing-daemon isolation".to_owned()); + } + + let human_output = sandbox.run_with_options( + &["doctor", "--persistent-db", "foo.hyper"], + Some(OsStr::new("ignored-environment.hyper")), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + assert_success( + &human_output, + "hyperdb-mcp doctor --persistent-db foo.hyper", + ); + let human = std::str::from_utf8(&human_output.stdout) + .expect("relative-path human doctor report must be UTF-8"); + for (key, label) in [ + ("observed_persistent_path", "Observed persistent path"), + ("resolved_persistent_path", "Resolved persistent path"), + ("resolved_persistent_parent", "Resolved persistent parent"), + ("client_log", "Client log"), + ] { + if let Some(failure) = human_path_parity_failure(&report, human, key, label) { + failures.push(failure); + } + } + let expected_parent = + " Resolved persistent parent: . (encoding: utf8; exists: true; file: false; directory: true)"; + if !human.contains(expected_parent) { + failures.push(format!( + "human report did not normalize the relative parent to an existing directory: {human}" + )); + } + if snapshot_tree(&sandbox.root) != before { + failures.push("relative-path doctor changed isolated filesystem bytes".to_owned()); + } + sandbox.assert_no_artifacts(); + assert!( + failures.is_empty(), + "relative persistent path contract failures:\n{}", + failures.join("\n") + ); +} + +#[test] +fn doctor_nested_tilde_home_expands_once_and_preserves_raw_input() { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let missing_hyperd = sandbox.root.join("missing-hyperd"); + let nested_home = PathBuf::from("~").join("outer"); + let nested_home_fixture = sandbox.root.join(&nested_home); + std::fs::create_dir_all(&nested_home_fixture) + .expect("create literal nested-tilde HOME fixture"); + let expected_effective = nested_home.join("data.hyper"); + let expected_log = nested_home.join("hyperdb-mcp.log"); + let before = snapshot_tree(&sandbox.root); + let mut failures = Vec::new(); + + let json_output = sandbox.run_with_home_options( + &["doctor", "--json", "--persistent-db", "~/data.hyper"], + Some(OsStr::new("~/ignored-environment.hyper")), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + nested_home.as_os_str(), + ); + let report = parse_json_report(&json_output, "hyperdb-mcp doctor --json with HOME=~/outer"); + assert_exact_top_level_keys(&report); + let configuration = report_object(&report, "configuration"); + if configuration + .get("persistent_path_source") + .and_then(Value::as_str) + != Some("cli") + { + failures.push("nested-tilde preferred CLI path lost its source".to_owned()); + } + if let Some(failure) = reported_path_failure( + configuration, + "observed_persistent_path", + "~/data.hyper", + "utf8", + ) { + failures.push(failure); + } + for failure in [ + path_fact_failure( + configuration, + "resolved_persistent_path", + expected_effective.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + path_fact_failure( + configuration, + "resolved_persistent_parent", + nested_home.to_string_lossy().as_ref(), + "utf8", + true, + false, + true, + ), + path_fact_failure( + configuration, + "client_log", + expected_log.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + ] + .into_iter() + .flatten() + { + failures.push(failure); + } + if daemon_state(&report) != Some("missing") { + failures.push("nested-tilde case escaped pinned missing-daemon isolation".to_owned()); + } + + let human_output = sandbox.run_with_home_options( + &["doctor", "--persistent-db", "~/data.hyper"], + Some(OsStr::new("~/ignored-environment.hyper")), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + nested_home.as_os_str(), + ); + assert_success(&human_output, "hyperdb-mcp doctor with HOME=~/outer"); + let human = std::str::from_utf8(&human_output.stdout) + .expect("nested-tilde human doctor report must be UTF-8"); + for (key, label) in [ + ("observed_persistent_path", "Observed persistent path"), + ("resolved_persistent_path", "Resolved persistent path"), + ("resolved_persistent_parent", "Resolved persistent parent"), + ("client_log", "Client log"), + ] { + if let Some(failure) = human_path_parity_failure(&report, human, key, label) { + failures.push(failure); + } + } + for expected in [ + " Observed persistent path: ~/data.hyper (encoding: utf8)".to_owned(), + format!( + " Resolved persistent path: {} (encoding: utf8; exists: false; file: false; directory: false)", + expected_effective.display() + ), + format!( + " Client log: {} (encoding: utf8; exists: false; file: false; directory: false)", + expected_log.display() + ), + ] { + if !human.contains(&expected) { + failures.push(format!( + "nested-tilde JSON/human single-expansion fact missing: {expected:?}" + )); + } + } + if snapshot_tree(&sandbox.root) != before { + failures.push("nested-tilde doctor changed isolated filesystem bytes".to_owned()); + } + assert!( + !sandbox.persistent_path.exists() + && !sandbox.root.join(&expected_effective).exists() + && !sandbox.root.join(&expected_log).exists() + && !sandbox.state_dir.join("daemon.json").exists(), + "nested-tilde doctor must not create a database, log, or discovery file" + ); + assert!( + failures.is_empty(), + "nested-tilde single-expansion contract failures:\n{}", + failures.join("\n") + ); +} + +#[cfg(any(unix, windows))] +#[test] +fn doctor_non_utf8_persistent_environment_reports_observed_and_effective_paths() { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let missing_hyperd = sandbox.root.join("missing-hyperd"); + let configured = non_utf8_path(&sandbox.root, "persistent"); + let effective = PathBuf::from(&configured.display); + let effective_parent = effective.parent().expect("effective path has a parent"); + let expected_log = effective_parent.join("hyperdb-mcp.log"); + let before = snapshot_tree(&sandbox.root); + + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(configured.raw.as_os_str()), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json with non-UTF-8 HYPERDB_PERSISTENT_DB", + ); + let configuration = report_object(&report, "configuration"); + let mut failures = Vec::new(); + + if configuration + .get("persistent_path_source") + .and_then(Value::as_str) + != Some("environment") + { + failures.push(format!( + "non-UTF-8 environment path lost its source: {report}" + )); + } + if let Some(failure) = reported_path_failure( + configuration, + "observed_persistent_path", + &configured.display, + "lossy", + ) { + failures.push(failure); + } + for failure in [ + path_fact_failure( + configuration, + "resolved_persistent_path", + effective.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + path_fact_failure( + configuration, + "resolved_persistent_parent", + effective_parent.to_string_lossy().as_ref(), + "utf8", + true, + false, + true, + ), + path_fact_failure( + configuration, + "client_log", + expected_log.to_string_lossy().as_ref(), + "utf8", + false, + false, + false, + ), + ] + .into_iter() + .flatten() + { + failures.push(failure); + } + if daemon_state(&report) != Some("missing") { + failures.push(format!( + "pinned daemon state was not missing: {:?}", + daemon_state(&report) + )); + } + if snapshot_tree(&sandbox.root) != before { + failures.push("doctor changed filesystem bytes for non-UTF-8 input".to_owned()); + } + sandbox.assert_no_artifacts(); + assert_all_strings_bounded(&report); + assert!( + failures.is_empty(), + "non-UTF-8 observed/effective persistent path failures:\n{}", + failures.join("\n") + ); +} + +#[test] +fn doctor_hyperd_path_diagnostics_match_runtime_resolution() { + let mut failures = Vec::new(); + + #[cfg(any(unix, windows))] + { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let configured = non_utf8_path(&sandbox.root, "ignored-hyperd"); + let upward = create_upward_hyperd_candidate(&sandbox); + let before = snapshot_tree(&sandbox.root); + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(configured.raw.as_os_str()), + sandbox.isolated_daemon_port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json with non-UTF-8 HYPERD_PATH", + ); + let configuration = report_object(&report, "configuration"); + for failure in [ + path_fact_failure( + configuration, + "observed_hyperd_path", + &configured.display, + "lossy", + false, + false, + false, + ), + path_fact_failure( + configuration, + "upward_hyperd_candidate", + upward.to_string_lossy().as_ref(), + "utf8", + true, + true, + false, + ), + ] + .into_iter() + .flatten() + { + failures.push(format!("non-UTF-8 HYPERD_PATH: {failure}")); + } + let warnings = normalized_human_text(&warning_text(&report)); + if !warnings.contains("non utf 8") + || !(warnings.contains("ignored") || warnings.contains("upward")) + { + failures.push(format!( + "non-UTF-8 HYPERD_PATH needs a bounded warning that runtime ignores it and uses upward resolution: {}", + report.get("warnings").unwrap_or(&Value::Null) + )); + } + if has_warning_code(&report, "observed_hyperd_path_missing") { + failures.push( + "non-UTF-8 HYPERD_PATH was misleadingly diagnosed as the effective missing path" + .to_owned(), + ); + } + if daemon_state(&report) != Some("missing") { + failures.push("non-UTF-8 case escaped the pinned daemon port".to_owned()); + } + if snapshot_tree(&sandbox.root) != before { + failures.push("non-UTF-8 HYPERD_PATH case changed filesystem bytes".to_owned()); + } + assert_all_strings_bounded(&report); + sandbox.assert_no_artifacts(); + } + + { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let configured_directory = sandbox.root.join("configured-hyperd-directory"); + std::fs::create_dir(&configured_directory).expect("create empty HYPERD_PATH directory"); + let _unused_upward = create_upward_hyperd_candidate(&sandbox); + let before = snapshot_tree(&sandbox.root); + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(configured_directory.as_os_str()), + sandbox.isolated_daemon_port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json with an empty HYPERD_PATH directory", + ); + let configuration = report_object(&report, "configuration"); + if let Some(failure) = path_fact_failure( + configuration, + "observed_hyperd_path", + configured_directory.to_string_lossy().as_ref(), + "utf8", + true, + false, + true, + ) { + failures.push(format!("empty HYPERD_PATH directory: {failure}")); + } + if configuration + .get("upward_hyperd_candidate") + .is_some_and(|value| !value.is_null()) + { + failures + .push("valid UTF-8 directory HYPERD_PATH must suppress upward fallback".to_owned()); + } + let warnings = warning_text(&report); + if !warnings.contains("directory") + || !warnings.contains("hyperd") + || !(warnings.contains("not found") || warnings.contains("missing")) + { + failures.push(format!( + "existing HYPERD_PATH directory without {} needs an actionable bounded diagnostic: {}", + platform_hyperd_name(), + report.get("warnings").unwrap_or(&Value::Null) + )); + } + if daemon_state(&report) != Some("missing") { + failures.push("empty-directory case escaped the pinned daemon port".to_owned()); + } + if snapshot_tree(&sandbox.root) != before { + failures.push("empty-directory HYPERD_PATH case changed filesystem bytes".to_owned()); + } + assert_all_strings_bounded(&report); + sandbox.assert_no_artifacts(); + } + + { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let _unused_upward = create_upward_hyperd_candidate(&sandbox); + let before = snapshot_tree(&sandbox.root); + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(OsStr::new("")), + sandbox.isolated_daemon_port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json with empty UTF-8 HYPERD_PATH", + ); + let configuration = report_object(&report, "configuration"); + if let Some(failure) = path_fact_failure( + configuration, + "observed_hyperd_path", + "", + "utf8", + false, + false, + false, + ) { + failures.push(format!("empty UTF-8 HYPERD_PATH: {failure}")); + } + if configuration + .get("upward_hyperd_candidate") + .is_some_and(|value| !value.is_null()) + { + failures.push( + "empty UTF-8 HYPERD_PATH must match runtime by suppressing upward fallback" + .to_owned(), + ); + } + if !has_warning_code(&report, "observed_hyperd_path_missing") { + failures.push( + "empty UTF-8 HYPERD_PATH needs the bounded missing-path diagnostic runtime would produce" + .to_owned(), + ); + } + if daemon_state(&report) != Some("missing") { + failures.push("empty UTF-8 case escaped the pinned daemon port".to_owned()); + } + if snapshot_tree(&sandbox.root) != before { + failures.push("empty UTF-8 HYPERD_PATH case changed filesystem bytes".to_owned()); + } + assert_all_strings_bounded(&report); + sandbox.assert_no_artifacts(); + } + + #[cfg(windows)] + { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let configured_stem = sandbox.root.join("configured-hyperd"); + let accepted_executable = PathBuf::from(format!("{}.exe", configured_stem.display())); + std::fs::write(&accepted_executable, b"not executed by doctor") + .expect("create Windows .exe fallback fixture"); + let _unused_upward = create_upward_hyperd_candidate(&sandbox); + let before = snapshot_tree(&sandbox.root); + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(configured_stem.as_os_str()), + sandbox.isolated_daemon_port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json with Windows HYPERD_PATH stem", + ); + let configuration = report_object(&report, "configuration"); + if let Some(failure) = path_fact_failure( + configuration, + "effective_hyperd_path", + accepted_executable.to_string_lossy().as_ref(), + "utf8", + true, + true, + false, + ) { + failures.push(format!("Windows .exe fallback: {failure}")); + } + if has_warning_code(&report, "observed_hyperd_path_missing") { + failures.push( + "Windows HYPERD_PATH stem accepted through .exe must not be diagnosed as missing" + .to_owned(), + ); + } + if configuration + .get("upward_hyperd_candidate") + .is_some_and(|value| !value.is_null()) + { + failures.push("accepted Windows HYPERD_PATH must suppress upward fallback".to_owned()); + } + if daemon_state(&report) != Some("missing") { + failures.push("Windows .exe case escaped the pinned daemon port".to_owned()); + } + if snapshot_tree(&sandbox.root) != before { + failures.push("Windows .exe HYPERD_PATH case changed filesystem bytes".to_owned()); + } + assert_all_strings_bounded(&report); + sandbox.assert_no_artifacts(); + } + + assert!( + failures.is_empty(), + "HYPERD_PATH doctor/runtime resolution mismatches:\n{}", + failures.join("\n") + ); +} + +#[test] +fn doctor_cli_reports_live_from_discovery_via_real_health_listener() { + const ATTEMPTS: usize = 4; + let mut failures = Vec::new(); + + for attempt in 1..=ATTEMPTS { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let missing_hyperd = sandbox.root.join("missing-hyperd"); + + // Warm the already-built child before aligning the real listener's + // nonblocking accept loop. This keeps process-loader latency from + // determining whether the child lands inside the 100 ms sleep cadence. + let warm_before = snapshot_tree(&sandbox.root); + let warm = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + let warm_report = parse_json_report(&warm, "warm hyperdb-mcp doctor --json"); + if daemon_state(&warm_report) != Some("missing") { + failures.push(format!( + "attempt {attempt}: warmup escaped pinned missing-daemon isolation" + )); + } + if snapshot_tree(&sandbox.root) != warm_before { + failures.push(format!( + "attempt {attempt}: warmup changed filesystem bytes" + )); + } + + let listener = RunningHealthListener::start(); + std::fs::create_dir_all(&sandbox.state_dir).expect("create discovery fixture directory"); + let discovery_path = sandbox.state_dir.join("daemon.json"); + let discovery_bytes = + serde_json::to_vec_pretty(&listener.info).expect("serialize discovery fixture"); + std::fs::write(&discovery_path, &discovery_bytes).expect("write discovery fixture"); + let before = snapshot_tree(&sandbox.root); + + listener.prime_accept_sleep(); + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + listener.port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json against discovery HealthListener", + ); + assert_exact_top_level_keys(&report); + if let Some(failure) = + live_daemon_fact_failure(&report, "live_from_discovery", &listener.info) + { + failures.push(format!("attempt {attempt}: {failure}")); + } + if attempt == 1 { + listener.prime_accept_sleep(); + let human_output = sandbox.run_with_options( + &["doctor"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + listener.port, + ); + assert_success( + &human_output, + "hyperdb-mcp doctor against discovery HealthListener", + ); + let human = std::str::from_utf8(&human_output.stdout) + .expect("live-daemon human report must be UTF-8"); + if let Some(failure) = live_daemon_human_parity_failure(&report, human) { + failures.push(format!( + "attempt {attempt}: live JSON/human identity parity: {failure}" + )); + } + } + if snapshot_tree(&sandbox.root) != before { + failures.push(format!( + "attempt {attempt}: discovery doctor changed filesystem bytes" + )); + } + match std::fs::read(&discovery_path) { + Ok(after) if after == discovery_bytes => {} + Ok(after) => failures.push(format!( + "attempt {attempt}: discovery bytes changed: before={discovery_bytes:?}, after={after:?}" + )), + Err(error) => failures.push(format!( + "attempt {attempt}: discovery fixture disappeared: {error}" + )), + } + assert_all_strings_bounded(&report); + drop(listener); + } + + assert!( + failures.is_empty(), + "real discovery HealthListener regressions:\n{}", + failures.join("\n") + ); +} + +#[test] +fn doctor_cli_reports_live_from_scan_via_real_health_listener() { + const ATTEMPTS: usize = 4; + let mut failures = Vec::new(); + + for attempt in 1..=ATTEMPTS { + let sandbox = DoctorSandbox::new(); + let launcher_metadata = sandbox.launcher_metadata("hyperdb-mcp-test-wrapper"); + let missing_hyperd = sandbox.root.join("missing-hyperd"); + + let warm_before = snapshot_tree(&sandbox.root); + let warm = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + sandbox.isolated_daemon_port, + ); + let warm_report = parse_json_report(&warm, "warm hyperdb-mcp doctor --json"); + if daemon_state(&warm_report) != Some("missing") { + failures.push(format!( + "attempt {attempt}: warmup escaped pinned missing-daemon isolation" + )); + } + if snapshot_tree(&sandbox.root) != warm_before { + failures.push(format!( + "attempt {attempt}: warmup changed filesystem bytes" + )); + } + + let listener = RunningHealthListener::start(); + let before = snapshot_tree(&sandbox.root); + listener.prime_accept_sleep(); + let output = sandbox.run_with_options( + &["doctor", "--json"], + Some(sandbox.persistent_path.as_os_str()), + &launcher_metadata, + Some(missing_hyperd.as_os_str()), + listener.port, + ); + let report = parse_json_report( + &output, + "hyperdb-mcp doctor --json against scanned HealthListener", + ); + if let Some(failure) = live_daemon_fact_failure(&report, "live_from_scan", &listener.info) { + failures.push(format!("attempt {attempt}: {failure}")); + } + if snapshot_tree(&sandbox.root) != before { + failures.push(format!( + "attempt {attempt}: scan doctor changed filesystem bytes" + )); + } + if sandbox.state_dir.exists() { + failures.push(format!( + "attempt {attempt}: scan doctor created a daemon state directory" + )); + } + sandbox.assert_no_artifacts(); + assert_all_strings_bounded(&report); + drop(listener); + } + + assert!( + failures.is_empty(), + "real scanned HealthListener regressions:\n{}", + failures.join("\n") + ); +} + +/// `--help` is the recovery surface available even when neither MCP nor +/// `hyperd` can start, so its resolution and read-only claims must be exact. +/// This catches mutations to Clap help or its checked-in static README mirror, +/// especially cross-option token matches that conceal a false export claim. +#[test] +fn cli_help_matches_hyperd_and_read_only_contract() { + fn run_help(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_hyperdb-mcp")) + .args(args) + .output() + .expect("run hyperdb-mcp help without starting the engine") + } + + fn normalized_output(output: &Output) -> String { + let mut bytes = output.stdout.clone(); + bytes.extend_from_slice(&output.stderr); + String::from_utf8_lossy(&bytes) + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() + } + + fn option_scope<'a>(help: &'a str, flag: &str, next_flag: &str) -> &'a str { + let Some((_, after_flag)) = help.split_once(flag) else { + return ""; + }; + let Some((scope, _)) = after_flag.split_once(next_flag) else { + return after_flag; + }; + scope + } + + const GUARDED_TOOLS: &[&str] = &[ + "execute", + "load_data", + "load_file", + "load_files", + "load_iceberg", + "watch_directory", + "save_query", + "delete_query", + "set_table_metadata", + "copy_query", + "kv_set", + "kv_set_many", + "kv_delete", + "kv_pop", + "kv_clear", + ]; + + let root_output = run_help(&["--help"]); + let daemon_output = run_help(&["daemon", "--help"]); + let root_help = normalized_output(&root_output); + let daemon_help = normalized_output(&daemon_output); + let read_only_help = option_scope(&root_help, "--read-only", "--no-daemon"); + let static_cli = PUBLIC_README + .to_lowercase() + .split_once("## cli reference") + .and_then(|(_, tail)| tail.split_once("\n---")) + .map(|(section, _)| section.to_owned()) + .unwrap_or_default(); + let mut failures = Vec::new(); + + if !root_output.status.success() { + failures.push(format!( + "hyperdb-mcp --help exited with {}: {root_help}", + root_output.status + )); + } + if !daemon_output.status.success() { + failures.push(format!( + "hyperdb-mcp daemon --help exited with {}: {daemon_help}", + daemon_output.status + )); + } + + if !(root_help.contains("hyperd_path") + && root_help.contains("executable") + && root_help.contains("directory") + && root_help.contains(".hyperd/current") + && ["walk upward", "search upward", "ancestor"] + .iter() + .any(|phrase| root_help.contains(phrase))) + { + failures.push( + "root help must describe HYPERD_PATH as an executable or containing directory and the upward .hyperd/current fallback" + .to_owned(), + ); + } + if ["searches path", "path fallback", "or on path"] + .iter() + .any(|phrase| root_help.contains(phrase)) + { + failures.push("root help must not claim the runtime searches PATH".to_owned()); + } + + for tool in GUARDED_TOOLS { + if !read_only_help.contains(tool) { + failures.push(format!("--read-only help is missing guarded tool {tool}")); + } + } + if !(read_only_help.contains("attach_database") && read_only_help.contains("writable")) { + failures.push( + "--read-only help must distinguish writable attach_database from allowed read-only attachment" + .to_owned(), + ); + } + let export_availability = if let Some((_, after_export)) = read_only_help.split_once("export") { + after_export.contains("hyper") + && [ + "allowed", + "remain available", + "stays available", + "stay available", + ] + .iter() + .any(|phrase| after_export.contains(phrase)) + } else { + false + }; + if !(read_only_help.contains("unwatch_directory") && export_availability) { + failures.push( + "--read-only help must explicitly keep unwatch_directory and Hyper-format export available" + .to_owned(), + ); + } + for false_claim in [ + "disables export", + "export is disabled", + "hyper-format export is disabled", + "disables hyper-format export", + ] { + if read_only_help.contains(false_claim) { + failures.push(format!( + "--read-only help still makes the associated false claim {false_claim:?}" + )); + } + } + + if !(daemon_help.contains("auto-spawn") + && daemon_help.contains("scan") + && daemon_help.contains("foreground") + && daemon_help.contains("exact")) + { + failures.push( + "daemon help must distinguish auto-spawn port scanning from the foreground daemon's exact/base-port bind" + .to_owned(), + ); + } + if daemon_help.contains("daemon scans from the base port to find a free port") { + failures.push( + "foreground daemon help must not promise startup scanning that it does not perform" + .to_owned(), + ); + } + + let static_daemon_command = static_cli + .lines() + .find(|line| line.trim_start().starts_with("daemon ")) + .unwrap_or(""); + if !static_daemon_command.contains("foreground") || static_daemon_command.contains("background") + { + failures.push( + "static README CLI command summary must describe `daemon` as foreground, not background" + .to_owned(), + ); + } + if !(static_cli.contains("hyperdb_daemon_port") + && static_cli.contains("auto-spawn") + && static_cli.contains("configured/base") + && static_cli.contains("exact")) + { + failures.push( + "static README CLI reference must distinguish HYPERDB_DAEMON_PORT auto-spawn discovery from foreground configured/base binding" + .to_owned(), + ); + } + + assert!( + failures.is_empty(), + "CLI help contract failures:\n- {}", + failures.join("\n- ") + ); +} diff --git a/hyperdb-mcp/tests/end_to_end_mcp_tests.rs b/hyperdb-mcp/tests/end_to_end_mcp_tests.rs index 3e0175a..1eaed02 100644 --- a/hyperdb-mcp/tests/end_to_end_mcp_tests.rs +++ b/hyperdb-mcp/tests/end_to_end_mcp_tests.rs @@ -10,32 +10,74 @@ //! plumbing, error mapping — exercising server-handler behavior that //! engine-level tests can't reach. -use rmcp::model::{CallToolRequestParams, CallToolResult, ClientInfo}; +use base64::Engine as _; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, ClientInfo, ResourceUpdatedNotificationParam, + SubscribeRequestParams, +}; use rmcp::service::{RoleClient, RunningService}; use rmcp::{ClientHandler, ServiceExt}; -use std::path::PathBuf; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; use tempfile::TempDir; +use hyperdb_mcp::engine::Engine; use hyperdb_mcp::server::HyperMcpServer; type TestResult = Result<(), Box>; -/// Minimal client handler — its only job is to satisfy `ServiceExt` -/// so the server-side tool calls can be issued. +const PERSISTENT_LOCK_MCP_CHILD_ENV: &str = "HYPERDB_MCP_PERSISTENT_LOCK_MCP_CHILD"; + +/// Resource notifications captured by the in-memory client. The aggregate +/// routed-response tests use these to pin the mutation side effects that must +/// survive additive `resolved_database` metadata. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum NotificationEvent { + ResourceUpdated(String), + ResourceListChanged, +} + +/// Minimal client handler that also records resource notifications emitted by +/// the server after successful mutations. #[derive(Debug, Clone)] -struct DummyClientHandler; +struct DummyClientHandler { + notification_tx: tokio::sync::mpsc::UnboundedSender, +} impl ClientHandler for DummyClientHandler { fn get_info(&self) -> ClientInfo { ClientInfo::default() } + + async fn on_resource_updated( + &self, + params: ResourceUpdatedNotificationParam, + _context: rmcp::service::NotificationContext, + ) { + let _ = self + .notification_tx + .send(NotificationEvent::ResourceUpdated(params.uri)); + } + + async fn on_resource_list_changed( + &self, + _context: rmcp::service::NotificationContext, + ) { + let _ = self + .notification_tx + .send(NotificationEvent::ResourceListChanged); + } } /// In-memory client+server pair backed by a `tokio::io::duplex`. struct TestHarness { client: RunningService, server_handle: tokio::task::JoinHandle>>, + /// Shared engine handle retained for status-lock regression scenarios. + engine_handle: Arc>>, + notification_rx: tokio::sync::mpsc::UnboundedReceiver, /// Persistent workspace path — kept alive via the temp dir. /// Held by the harness so individual tests can read it back if a /// scenario ever needs to inspect the on-disk file directly. @@ -66,6 +108,7 @@ impl TestHarness { Some(persistent_path.to_string_lossy().to_string()) }; let server = HyperMcpServer::with_no_daemon(workspace, read_only, true); + let engine_handle = server.engine_handle(); let server_handle = tokio::spawn(async move { let running = server @@ -79,7 +122,8 @@ impl TestHarness { Ok(()) }); - let client = DummyClientHandler + let (notification_tx, notification_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = DummyClientHandler { notification_tx } .serve(client_io) .await .map_err(|e| -> Box { Box::new(e) })?; @@ -87,6 +131,47 @@ impl TestHarness { Ok(Self { client, server_handle, + engine_handle, + notification_rx, + persistent_path, + _temp_dir: temp_dir, + }) + } + + /// Same in-memory MCP harness, but attaches a caller-owned persistent + /// workspace. The parent of the self-child fixture owns that path's RAII + /// directory, so it remains valid for the complete contention scenario. + async fn start_at_persistent( + persistent_path: PathBuf, + ) -> Result> { + let temp_dir = Arc::new(TempDir::new()?); + let (server_io, client_io) = tokio::io::duplex(64 * 1024); + let workspace = Some(persistent_path.to_string_lossy().to_string()); + let server = HyperMcpServer::with_no_daemon(workspace, false, true); + let engine_handle = server.engine_handle(); + + let server_handle = tokio::spawn(async move { + let running = server + .serve(server_io) + .await + .map_err(|e| -> Box { Box::new(e) })?; + running + .waiting() + .await + .map_err(|e| -> Box { Box::new(e) })?; + Ok(()) + }); + let (notification_tx, notification_rx) = tokio::sync::mpsc::unbounded_channel(); + let client = DummyClientHandler { notification_tx } + .serve(client_io) + .await + .map_err(|e| -> Box { Box::new(e) })?; + + Ok(Self { + client, + server_handle, + engine_handle, + notification_rx, persistent_path, _temp_dir: temp_dir, }) @@ -102,6 +187,48 @@ impl TestHarness { } } +/// A native thread holds the engine mutex until this guard is dropped. Keeping +/// the lock outside Tokio means a non-Send `MutexGuard` never crosses an +/// `.await`, while `Drop` releases the fixture even when an assertion panics. +struct EngineLockHolder { + release: Option>, + thread: Option>, +} + +impl Drop for EngineLockHolder { + fn drop(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + if let Some(thread) = self.thread.take() { + thread + .join() + .expect("engine-lock fixture thread must finish"); + } + } +} + +/// Lock the server engine on a native thread and wait until it definitely owns +/// the mutex before issuing a status request. +fn hold_engine_lock(engine_handle: Arc>>) -> EngineLockHolder { + let (ready_tx, ready_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let thread = std::thread::spawn(move || { + let _guard = engine_handle.lock().expect("engine mutex"); + ready_tx.send(()).expect("test must await engine lock"); + release_rx.recv().expect("test must release engine lock"); + }); + + ready_rx + .recv_timeout(Duration::from_secs(1)) + .expect("engine-lock fixture must become ready promptly"); + + EngineLockHolder { + release: Some(release_tx), + thread: Some(thread), + } +} + /// Helper — invoke a tool by name, building the request params from a /// JSON value's top-level object fields. async fn call_tool( @@ -143,11 +270,1371 @@ fn all_text(result: &CallToolResult) -> String { .join("\n") } +/// Record a successful object response that must remain mirrored between its +/// sole text block and `structuredContent`. Returns the parsed text payload so +/// callers can pin their tool-specific legacy fields as well. +fn record_legacy_object_response( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_fields: &[&str], +) -> Option { + if is_error(result) { + failures.push(format!( + "{case}: tool returned an error: {:?}", + first_text(result) + )); + } + if result.content.len() != 1 { + failures.push(format!( + "{case}: expected one JSON text block, got {} content blocks", + result.content.len() + )); + } + + let Some(text) = result + .content + .first() + .and_then(|content| content.raw.as_text()) + .map(|content| content.text.as_str()) + else { + failures.push(format!("{case}: first content block must be text JSON")); + return None; + }; + let payload = match serde_json::from_str::(text) { + Ok(payload) => payload, + Err(error) => { + failures.push(format!("{case}: text block is not JSON: {error}")); + return None; + } + }; + + if result.structured_content.as_ref() != Some(&payload) { + failures.push(format!( + "{case}: structuredContent must exactly mirror the JSON text block" + )); + } + let Some(object) = payload.as_object() else { + failures.push(format!("{case}: JSON payload must be an object")); + return Some(payload); + }; + let mut actual_fields: Vec<_> = object.keys().map(String::as_str).collect(); + actual_fields.sort_unstable(); + let mut expected_fields = expected_fields.to_vec(); + expected_fields.sort_unstable(); + if actual_fields != expected_fields { + failures.push(format!( + "{case}: top-level fields changed: expected {expected_fields:?}, got {actual_fields:?}" + )); + } + Some(payload) +} + +fn record_object_response( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_database: &str, + expected_fields: &[&str], +) -> Option { + let payload = record_legacy_object_response(failures, case, result, expected_fields)?; + if payload.get("resolved_database") != Some(&serde_json::json!(expected_database)) { + failures.push(format!( + "{case}: resolved_database must be {expected_database:?}, got {:?}", + payload.get("resolved_database") + )); + } + Some(payload) +} + +fn record_fields( + failures: &mut Vec, + case: &str, + value: &serde_json::Value, + expected_fields: &[&str], +) { + let Some(object) = value.as_object() else { + failures.push(format!("{case}: expected a JSON object, got {value}")); + return; + }; + let mut actual: Vec<_> = object.keys().map(String::as_str).collect(); + actual.sort_unstable(); + let mut expected = expected_fields.to_vec(); + expected.sort_unstable(); + if actual != expected { + failures.push(format!( + "{case}: fields changed: expected {expected:?}, got {actual:?}" + )); + } +} + +/// Pin the common success envelope plus the legacy ingest payload and stats. +fn record_ingest_response( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_database: &str, + expected_rows: u64, + expected_table: &str, + expected_operation: &str, + expected_format: &str, + expected_schema_columns: usize, + schema_changed: bool, +) -> Option { + let payload = record_object_response( + failures, + case, + result, + expected_database, + &["resolved_database", "rows", "schema", "stats"], + )?; + if payload["rows"] != serde_json::json!(expected_rows) { + failures.push(format!( + "{case}: rows changed: expected {expected_rows}, got {:?}", + payload.get("rows") + )); + } + let schema = payload["schema"].as_array(); + if schema.map(Vec::len) != Some(expected_schema_columns) { + failures.push(format!( + "{case}: schema column count changed: expected {expected_schema_columns}, got {:?}", + schema.map(Vec::len) + )); + } + if let Some(schema) = schema { + for (index, column) in schema.iter().enumerate() { + record_fields( + failures, + &format!("{case} schema column {index}"), + column, + &["name", "nullable", "type"], + ); + } + } + + let mut expected_stats = vec![ + "bytes_read", + "bytes_stored", + "compression_ratio", + "elapsed_ms", + "file_format", + "ingest_throughput_mb_sec", + "operation", + "rows", + "rows_per_sec", + "schema_inference_ms", + "table", + ]; + if schema_changed { + expected_stats.push("schema_changed"); + } + record_fields( + failures, + &format!("{case} stats"), + &payload["stats"], + &expected_stats, + ); + let stats = &payload["stats"]; + if stats["rows"] != serde_json::json!(expected_rows) + || stats["table"] != serde_json::json!(expected_table) + || stats["operation"] != serde_json::json!(expected_operation) + || stats["file_format"] != serde_json::json!(expected_format) + || stats["schema_changed"] + != if schema_changed { + serde_json::json!(true) + } else { + serde_json::Value::Null + } + { + failures.push(format!( + "{case}: legacy ingest stats changed: expected rows={expected_rows}, table={expected_table:?}, operation={expected_operation:?}, format={expected_format:?}, schema_changed={schema_changed}; got {stats}" + )); + } + Some(payload) +} + +fn record_copy_response( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_database: &str, + expected_table: &str, + expected_mode: &str, + expected_rows: i64, +) { + if let Some(payload) = record_object_response( + failures, + case, + result, + expected_database, + &[ + "mode", + "resolved_database", + "row_count", + "stats", + "target_database", + "target_table", + ], + ) { + if payload["target_database"] != serde_json::json!(expected_database) + || payload["target_database"] != payload["resolved_database"] + || payload["target_table"] != serde_json::json!(expected_table) + || payload["mode"] != serde_json::json!(expected_mode) + || payload["row_count"] != serde_json::json!(expected_rows) + { + failures.push(format!( + "{case}: legacy copy result changed or target_database disagrees with resolved_database: {payload}" + )); + } + record_fields( + failures, + &format!("{case} stats"), + &payload["stats"], + &["elapsed_ms", "operation"], + ); + if payload["stats"]["operation"] != serde_json::json!("copy_query") { + failures.push(format!("{case}: stats.operation must remain copy_query")); + } + } +} + +async fn record_notifications( + failures: &mut Vec, + case: &str, + receiver: &mut tokio::sync::mpsc::UnboundedReceiver, + expected: &[NotificationEvent], +) { + let mut actual = Vec::with_capacity(expected.len()); + for _ in 0..expected.len() { + match tokio::time::timeout(Duration::from_secs(2), receiver.recv()).await { + Ok(Some(event)) => actual.push(event), + Ok(None) => { + failures.push(format!("{case}: notification channel closed early")); + break; + } + Err(_) => { + failures.push(format!( + "{case}: timed out waiting for {} notification(s); received {actual:?}", + expected.len() + )); + break; + } + } + } + actual.sort_unstable(); + let mut expected = expected.to_vec(); + expected.sort_unstable(); + if actual != expected { + failures.push(format!( + "{case}: resource notifications changed: expected {expected:?}, got {actual:?}" + )); + } +} + +/// Record the query tool's intentionally non-standard two-text-block result. +/// Query has no structuredContent, and its JSON payload is specifically the +/// second block after formatted SQL. +fn record_query_response( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_database: &str, + expected_sql: &str, + expected_rows: &serde_json::Value, +) { + if is_error(result) { + failures.push(format!( + "{case}: tool returned an error: {:?}", + first_text(result) + )); + } + if result.structured_content.is_some() { + failures.push(format!("{case}: query must not add structuredContent")); + } + if result.content.len() != 2 { + failures.push(format!( + "{case}: query must preserve SQL-text then JSON-text content order; got {} blocks", + result.content.len() + )); + } + + let sql_text = result + .content + .first() + .and_then(|content| content.raw.as_text()) + .map(|content| content.text.as_str()); + let expected_sql_block = format!("```sql\n{expected_sql}\n```"); + if sql_text != Some(expected_sql_block.as_str()) { + failures.push(format!( + "{case}: formatted SQL block changed: expected {expected_sql_block:?}, got {sql_text:?}" + )); + } + + let Some(json_text) = result + .content + .get(1) + .and_then(|content| content.raw.as_text()) + .map(|content| content.text.as_str()) + else { + failures.push(format!("{case}: second query block must be JSON text")); + return; + }; + let payload = match serde_json::from_str::(json_text) { + Ok(payload) => payload, + Err(error) => { + failures.push(format!("{case}: second query block is not JSON: {error}")); + return; + } + }; + let Some(object) = payload.as_object() else { + failures.push(format!("{case}: query JSON payload must be an object")); + return; + }; + let mut actual_fields: Vec<_> = object.keys().map(String::as_str).collect(); + actual_fields.sort_unstable(); + let mut expected_fields = vec!["result", "resolved_database", "stats"]; + expected_fields.sort_unstable(); + if actual_fields != expected_fields { + failures.push(format!( + "{case}: query top-level fields changed: expected {expected_fields:?}, got {actual_fields:?}" + )); + } + if object.get("result") != Some(expected_rows) { + failures.push(format!( + "{case}: query result changed: expected {expected_rows}, got {:?}", + object.get("result") + )); + } + if object.get("resolved_database") != Some(&serde_json::json!(expected_database)) { + failures.push(format!( + "{case}: resolved_database must be {expected_database:?}, got {:?}", + object.get("resolved_database") + )); + } + let stats = object.get("stats").and_then(serde_json::Value::as_object); + let expected_stats = [ + "elapsed_ms", + "operation", + "result_size_bytes", + "rows_returned", + "rows_scanned", + "scan_rate_rows_sec", + "tables_touched", + ]; + let mut actual_stats = stats + .map(|stats| stats.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + actual_stats.sort_unstable(); + if actual_stats != expected_stats { + failures.push(format!( + "{case}: query stats fields changed: expected {expected_stats:?}, got {actual_stats:?}" + )); + } + if object["stats"]["operation"] != serde_json::json!("query") { + failures.push(format!("{case}: query stats.operation must remain query")); + } + if object["stats"]["rows_returned"] + != serde_json::json!(expected_rows.as_array().map_or(0, Vec::len)) + { + failures.push(format!( + "{case}: query stats.rows_returned must mirror result length" + )); + } +} + /// Did the tool return an `is_error: true` content block? fn is_error(result: &CallToolResult) -> bool { result.is_error.unwrap_or(false) } +/// Pin the MCP error envelope, not just prose: `isError`, structuredContent, +/// and the compatibility text block must all carry the requested code. +fn record_error_contract( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_code: &str, +) { + if !is_error(result) { + failures.push(format!("{case}: expected an MCP tool error, got success")); + return; + } + if result.content.len() != 1 { + failures.push(format!( + "{case}: error must contain exactly one compatibility text block, got {}", + result.content.len() + )); + } + let Some(structured) = result.structured_content.as_ref() else { + failures.push(format!("{case}: error is missing structuredContent")); + return; + }; + if structured + .pointer("/error/code") + .and_then(|value| value.as_str()) + != Some(expected_code) + { + failures.push(format!( + "{case}: expected structured error code {expected_code}, got {structured}" + )); + } + let text_payload = first_text(result) + .as_deref() + .and_then(|text| serde_json::from_str::(text).ok()); + if text_payload.as_ref() != Some(structured) { + failures.push(format!( + "{case}: text error payload must mirror structuredContent exactly" + )); + } +} + +fn schema_allows_type(schema: &serde_json::Value, expected: &str) -> bool { + match schema.get("type") { + Some(serde_json::Value::String(actual)) => actual == expected, + Some(serde_json::Value::Array(actual)) => actual.iter().any(|value| value == expected), + _ => false, + } +} + +fn svg_text_opening_tag<'a>(svg: &'a str, exact_text: &str) -> Option<&'a str> { + let lines: Vec<_> = svg.lines().collect(); + lines.windows(2).find_map(|window| { + (window[0].starts_with(", + case: &str, + result: &CallToolResult, +) -> Option<(String, Vec)> { + if is_error(result) { + failures.push(format!( + "{case}: expected chart success, got {:?}", + first_text(result) + )); + return None; + } + if result.structured_content.is_some() + || result.content.len() != 2 + || result + .content + .get(1) + .and_then(|content| content.raw.as_text()) + .is_none() + { + failures.push(format!( + "{case}: chart content must remain image first, stats text second, with no structuredContent" + )); + } + let Some(image) = result + .content + .first() + .and_then(|content| content.raw.as_image()) + else { + failures.push(format!("{case}: first content block is not an image")); + return None; + }; + match base64::engine::general_purpose::STANDARD.decode(&image.data) { + Ok(bytes) => Some((image.mime_type.clone(), bytes)), + Err(error) => { + failures.push(format!("{case}: inline image is not valid base64: {error}")); + None + } + } +} + +fn svg_i32_attr(line: &str, name: &str) -> Option { + let marker = format!("{name}=\""); + line.split_once(&marker)?.1.split_once('"')?.0.parse().ok() +} + +fn svg_primary_blue_rects(svg: &str) -> Vec<(i32, i32, i32, i32)> { + svg.lines() + .filter(|line| line.starts_with(" 20 && rect.3 > 0).then_some(rect) + }) + .collect() +} + +/// Parse the sole JSON text block emitted by the `status` tool. +fn status_json(result: &CallToolResult) -> serde_json::Value { + serde_json::from_str(&first_text(result).expect("status must return a text payload")) + .expect("status payload must be JSON") +} + +/// Caller-invalid chart ranges must be rejected before Plotters and retain the +/// structured MCP `INVALID_ARGUMENT` mapping on both axes, including ranges a +/// chart type would otherwise ignore. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chart_mcp_rejects_invalid_ranges() -> TestResult { + let h = TestHarness::start(false, true).await?; + let mut failures = Vec::new(); + let sql = "SELECT 1 AS category, 2 AS value UNION ALL SELECT 2, 3"; + + for (case, args) in [ + ( + "bar reversed ignored x range", + serde_json::json!({ + "sql": sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "x_range": [2.0, 1.0], + "format": "svg" + }), + ), + ( + "bar equal y range", + serde_json::json!({ + "sql": sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "y_range": [2.0, 2.0], + "format": "svg" + }), + ), + ] { + let result = call_tool(&h.client, "chart", args).await?; + record_error_contract(&mut failures, case, &result, "INVALID_ARGUMENT"); + } + + h.shutdown().await?; + assert!( + failures.is_empty(), + "invalid chart range MCP failures:\n{}", + failures.join("\n") + ); + Ok(()) +} + +/// A non-finite value returned by a real SQL DOUBLE expression is numeric +/// input that the renderer cannot plot. It must retain the caller-invalid +/// `INVALID_ARGUMENT` envelope rather than being converted to JSON null and +/// misreported as a column schema problem. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chart_mcp_rejects_sql_non_finite_double() -> TestResult { + let h = TestHarness::start(false, true).await?; + let mut failures = Vec::new(); + let result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": "SELECT 'not-a-number' AS category, CAST('NaN' AS DOUBLE PRECISION) AS value", + "chart_type": "bar", + "x": "category", + "y": "value", + "format": "svg" + }), + ) + .await?; + record_error_contract( + &mut failures, + "SQL non-finite DOUBLE", + &result, + "INVALID_ARGUMENT", + ); + + let histogram_result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": "SELECT CAST(1.0 AS DOUBLE PRECISION) AS value UNION ALL SELECT CAST('NaN' AS DOUBLE PRECISION)", + "chart_type": "histogram", + "x": "value", + "format": "svg" + }), + ) + .await?; + record_error_contract( + &mut failures, + "mixed finite/non-finite DOUBLE histogram", + &histogram_result, + "INVALID_ARGUMENT", + ); + + h.shutdown().await?; + assert!( + failures.is_empty(), + "SQL non-finite DOUBLE chart failures:\n{}", + failures.join("\n") + ); + Ok(()) +} + +/// Presentation controls are a private renderer extension surfaced only via +/// MCP. This pins their generated schema, omission defaults, structured invalid +/// combinations, image/stats ordering, and both SVG semantics and PNG delivery. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chart_mcp_presentation_options_contract() -> TestResult { + let h = TestHarness::start(false, true).await?; + let mut failures = Vec::new(); + + let tools = h.client.list_all_tools().await?; + let chart_tool = tools.iter().find(|tool| tool.name.as_ref() == "chart"); + match chart_tool { + Some(tool) => { + let properties = tool + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object); + let required = tool + .input_schema + .get("required") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + for (name, expected_type) in [ + ("bar_orientation", "string"), + ("label_values", "boolean"), + ("show_legend", "boolean"), + ] { + match properties.and_then(|properties| properties.get(name)) { + Some(schema) if schema_allows_type(schema, expected_type) => {} + Some(schema) => failures.push(format!( + "chart schema property {name} must allow {expected_type}, got {schema}" + )), + None => { + failures.push(format!("chart schema is missing optional property {name}")); + } + } + if required.iter().any(|value| value == name) { + failures.push(format!("chart schema property {name} must remain optional")); + } + } + let orientation_enum = properties + .and_then(|properties| properties.get("bar_orientation")) + .and_then(|schema| schema.get("enum")) + .and_then(serde_json::Value::as_array); + let accepts_vertical = orientation_enum + .is_some_and(|values| values.iter().any(|value| value == "vertical")); + let accepts_horizontal = orientation_enum + .is_some_and(|values| values.iter().any(|value| value == "horizontal")); + if !accepts_vertical || !accepts_horizontal { + failures.push(format!( + "bar_orientation schema must enumerate vertical and horizontal, got {orientation_enum:?}" + )); + } + } + None => failures.push("generated catalog is missing chart tool".into()), + } + + let sql = "SELECT 'North' AS category, 137 AS value, 'Legend alpha' AS series \ + UNION ALL SELECT 'South', 251, 'Legend beta'"; + let default_result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "series": "series", + "format": "svg", + "width": 520, + "height": 360 + }), + ) + .await?; + if let Some((mime, bytes)) = inline_image_bytes( + &mut failures, + "omitted presentation defaults", + &default_result, + ) { + if mime != "image/svg+xml" { + failures.push(format!("default SVG MIME changed: {mime}")); + } + match String::from_utf8(bytes) { + Ok(svg) => { + let category_tag = svg_text_opening_tag(&svg, "category"); + let value_tag = svg_text_opening_tag(&svg, "value"); + let category_axis_invalid = match category_tag { + Some(tag) => tag.contains("rotate(270"), + None => true, + }; + let value_axis_invalid = match value_tag { + Some(tag) => !tag.contains("rotate(270"), + None => true, + }; + if category_axis_invalid || value_axis_invalid { + failures.push( + "omitted bar_orientation must retain vertical category-x/value-y axes" + .into(), + ); + } + if !svg.contains("Legend alpha") || !svg.contains("Legend beta") { + failures.push("omitted show_legend must default to true".into()); + } + if svg.lines().any(|line| line.trim() == "137") + || svg.lines().any(|line| line.trim() == "251") + { + failures.push("omitted label_values must default to false".into()); + } + } + Err(error) => failures.push(format!("default SVG is not UTF-8: {error}")), + } + } + + let horizontal_result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "series": "series", + "format": "svg", + "bar_orientation": "horizontal", + "label_values": true, + "show_legend": false, + "width": 520, + "height": 360 + }), + ) + .await?; + if let Some((_, bytes)) = inline_image_bytes( + &mut failures, + "explicit horizontal presentation", + &horizontal_result, + ) { + match String::from_utf8(bytes) { + Ok(svg) => { + let category_tag = svg_text_opening_tag(&svg, "category"); + let value_tag = svg_text_opening_tag(&svg, "value"); + let category_axis_invalid = match category_tag { + Some(tag) => !tag.contains("rotate(270"), + None => true, + }; + let value_axis_invalid = match value_tag { + Some(tag) => tag.contains("rotate(270"), + None => true, + }; + if category_axis_invalid || value_axis_invalid { + failures.push( + "horizontal bars must swap to category-y/value-x axis descriptions".into(), + ); + } + for exact in ["137", "251"] { + if !svg.lines().any(|line| line.trim() == exact) { + failures.push(format!( + "label_values:true must render exact scalar {exact}" + )); + } + } + if svg.contains("Legend alpha") || svg.contains("Legend beta") { + failures.push("show_legend:false must remove series legend text".into()); + } + } + Err(error) => failures.push(format!("horizontal SVG is not UTF-8: {error}")), + } + } + + let png_result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "series": "series", + "format": "png", + "bar_orientation": "horizontal", + "label_values": true, + "show_legend": false + }), + ) + .await?; + if let Some((mime, bytes)) = inline_image_bytes(&mut failures, "horizontal PNG", &png_result) { + if mime != "image/png" + || !bytes.starts_with(&[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + { + failures.push(format!( + "horizontal PNG must carry PNG MIME/magic, got {mime} and {:?}", + bytes.get(..8) + )); + } + } + + for (case, args) in [ + ( + "label_values on line", + serde_json::json!({ + "sql": sql, + "chart_type": "line", + "x": "value", + "y": "value", + "label_values": true, + "format": "svg" + }), + ), + ( + "explicit vertical bar orientation on scatter", + serde_json::json!({ + "sql": sql, + "chart_type": "scatter", + "x": "value", + "y": "value", + "bar_orientation": "vertical", + "format": "svg" + }), + ), + ( + "unknown bar orientation", + serde_json::json!({ + "sql": sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "bar_orientation": "diagonal", + "format": "svg" + }), + ), + ] { + let result = call_tool(&h.client, "chart", args).await?; + record_error_contract(&mut failures, case, &result, "INVALID_ARGUMENT"); + } + + h.shutdown().await?; + assert!( + failures.is_empty(), + "chart presentation MCP failures:\n{}", + failures.join("\n") + ); + Ok(()) +} + +/// NUMERIC values beyond f64's exact-integer boundary must retain their SQL +/// scalar spelling all the way through the MCP query materializer and the +/// renderer's value-label path. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chart_mcp_preserves_high_precision_numeric_value_label() -> TestResult { + const EXACT_VALUE: &str = "9007199254740993"; + + let h = TestHarness::start(false, true).await?; + let mut failures = Vec::new(); + let result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": "SELECT 'precise' AS category, CAST(9007199254740993 AS NUMERIC(16,0)) AS value", + "chart_type": "bar", + "x": "category", + "y": "value", + "format": "svg", + "label_values": true, + "show_legend": false, + "width": 520, + "height": 360 + }), + ) + .await?; + + if let Some((mime, bytes)) = + inline_image_bytes(&mut failures, "high-precision NUMERIC label", &result) + { + if mime != "image/svg+xml" { + failures.push(format!("high-precision chart MIME changed: {mime}")); + } + match String::from_utf8(bytes) { + Ok(svg) if svg_text_opening_tag(&svg, EXACT_VALUE).is_some() => {} + Ok(svg) => { + let lines: Vec<_> = svg.lines().collect(); + let numeric_text: Vec<_> = lines + .windows(2) + .filter(|window| window[0].starts_with(" failures.push(format!( + "high-precision NUMERIC chart SVG is not UTF-8: {error}" + )), + } + } + + h.shutdown().await?; + assert!( + failures.is_empty(), + "high-precision NUMERIC chart failures:\n{}", + failures.join("\n") + ); + Ok(()) +} + +/// The logarithmic measure scale remains MCP-only and defaults to linear. Pin +/// its schema/parser, positive-domain validation, real log geometry, structured +/// errors, and unchanged inline SVG/PNG content ordering. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chart_mcp_log_scale_contract() -> TestResult { + let h = TestHarness::start(false, true).await?; + let mut failures = Vec::new(); + + let tools = h.client.list_all_tools().await?; + let y_scale_schema = tools + .iter() + .find(|tool| tool.name.as_ref() == "chart") + .and_then(|tool| tool.input_schema.get("properties")) + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get("y_scale")); + match y_scale_schema { + Some(schema) if schema_allows_type(schema, "string") => { + let values = schema.get("enum").and_then(serde_json::Value::as_array); + if !values.is_some_and(|values| { + values.iter().any(|value| value == "linear") + && values.iter().any(|value| value == "log") + }) { + failures.push(format!( + "y_scale schema must enumerate linear and log, got {schema}" + )); + } + } + Some(schema) => failures.push(format!( + "optional y_scale schema must allow string, got {schema}" + )), + None => failures.push("chart schema is missing optional y_scale".into()), + } + let y_scale_required = tools + .iter() + .find(|tool| tool.name.as_ref() == "chart") + .and_then(|tool| tool.input_schema.get("required")) + .and_then(serde_json::Value::as_array) + .is_some_and(|required| required.iter().any(|value| value == "y_scale")); + if y_scale_required { + failures.push("y_scale must remain optional so omission defaults to linear".into()); + } + + let linear_default = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": "SELECT 'negative' AS category, -10 AS value UNION ALL SELECT 'zero', 0 UNION ALL SELECT 'positive', 10", + "chart_type": "bar", + "x": "category", + "y": "value", + "format": "svg" + }), + ) + .await?; + if inline_image_bytes( + &mut failures, + "omitted y_scale linear default", + &linear_default, + ) + .is_none() + { + failures.push("omitted y_scale must continue accepting zero/negative linear data".into()); + } + + let positive_sql = "SELECT 'ten' AS category, 10 AS value UNION ALL SELECT 'hundred', 100"; + let log_svg_result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": positive_sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "format": "svg", + "y_scale": "log", + "y_range": [1.0, 1000.0], + "width": 520, + "height": 360 + }), + ) + .await?; + if let Some((mime, bytes)) = + inline_image_bytes(&mut failures, "positive log SVG", &log_svg_result) + { + if mime != "image/svg+xml" { + failures.push(format!("log SVG MIME changed: {mime}")); + } + match String::from_utf8(bytes) { + Ok(svg) => { + let mut heights: Vec<_> = svg_primary_blue_rects(&svg) + .into_iter() + .map(|rect| rect.3) + .collect(); + heights.sort_unstable(); + match heights.as_slice() { + [short, tall] if *short > 0 => { + let ratio = f64::from(*tall) / f64::from(*short); + if !(1.7..=2.3).contains(&ratio) { + failures.push(format!( + "10 and 100 over log range 1..1000 must occupy one and two decades (about 2x heights), got {heights:?} ratio={ratio}" + )); + } + } + _ => failures.push(format!( + "positive log SVG must contain two visible primary bars, got heights {heights:?}" + )), + } + } + Err(error) => failures.push(format!("log SVG is not UTF-8: {error}")), + } + } + + let log_png_result = call_tool( + &h.client, + "chart", + serde_json::json!({ + "sql": positive_sql, + "chart_type": "bar", + "x": "category", + "y": "value", + "format": "png", + "y_scale": "log", + "y_range": [1.0, 1000.0] + }), + ) + .await?; + if let Some((mime, bytes)) = + inline_image_bytes(&mut failures, "positive log PNG", &log_png_result) + { + if mime != "image/png" + || !bytes.starts_with(&[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + { + failures.push(format!( + "log PNG must carry PNG MIME/magic, got {mime} and {:?}", + bytes.get(..8) + )); + } + } + + for (case, args) in [ + ( + "unknown scale", + serde_json::json!({ + "sql": positive_sql, "chart_type": "bar", "x": "category", "y": "value", + "y_scale": "symlog", "format": "svg" + }), + ), + ( + "zero log value", + serde_json::json!({ + "sql": "SELECT 'zero' AS category, 0 AS value", "chart_type": "bar", + "x": "category", "y": "value", "y_scale": "log", "format": "svg" + }), + ), + ( + "negative log value", + serde_json::json!({ + "sql": "SELECT 1 AS category, -1 AS value", "chart_type": "line", + "x": "category", "y": "value", "y_scale": "log", "format": "svg" + }), + ), + ( + "mixed-sign log values", + serde_json::json!({ + "sql": "SELECT 1 AS category, -1 AS value UNION ALL SELECT 2, 1", + "chart_type": "scatter", "x": "category", "y": "value", + "y_scale": "log", "format": "svg" + }), + ), + ( + "log histogram", + serde_json::json!({ + "sql": "SELECT 10 AS value", "chart_type": "histogram", "x": "value", + "y_scale": "log", "format": "svg" + }), + ), + ( + "log range excludes plotted value", + serde_json::json!({ + "sql": positive_sql, "chart_type": "bar", "x": "category", "y": "value", + "y_scale": "log", "y_range": [20.0, 200.0], "format": "svg" + }), + ), + ] { + let result = call_tool(&h.client, "chart", args).await?; + record_error_contract(&mut failures, case, &result, "INVALID_ARGUMENT"); + } + + h.shutdown().await?; + assert!( + failures.is_empty(), + "chart log-scale MCP failures:\n{}", + failures.join("\n") + ); + Ok(()) +} + +/// Persistent contention must be reported by the first persistent-routed +/// operation without making the MCP status endpoint unavailable. The whole +/// potentially blocking scenario runs in an exact self-child owned by the +/// parent, which kills and waits on every timeout/error path. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn persistent_lock_keeps_mcp_available() -> TestResult { + if let Some(workspace) = std::env::var_os(PERSISTENT_LOCK_MCP_CHILD_ENV) { + return run_persistent_lock_mcp_child(PathBuf::from(workspace)).await; + } + + let temp_dir = TempDir::new()?; + let workspace = temp_dir.path().join("contended-mcp-persistent.hyper"); + run_contained_mcp_child( + "persistent_lock_keeps_mcp_available", + PERSISTENT_LOCK_MCP_CHILD_ENV, + &workspace, + ); + Ok(()) +} + +async fn run_persistent_lock_mcp_child(workspace: PathBuf) -> TestResult { + let effective_path = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.clone()); + let owner = Engine::new_no_daemon(Some(workspace.to_string_lossy().into_owned()))?; + let h = TestHarness::start_at_persistent(workspace).await?; + + let status = tokio::time::timeout( + Duration::from_secs(2), + call_tool(&h.client, "status", serde_json::json!({})), + ) + .await + .expect("status must remain promptly available while persistent attachment is contended")?; + assert!( + !is_error(&status), + "status must stay available despite persistent contention: {}", + all_text(&status) + ); + + let query = tokio::time::timeout( + Duration::from_secs(2), + call_tool( + &h.client, + "query", + serde_json::json!({ "sql": "SELECT 1", "database": "persistent" }), + ), + ) + .await + .expect("persistent-routed query must return before the child bound")?; + assert!(is_error(&query), "contended persistent query must fail"); + let diagnostic = all_text(&query); + assert!( + diagnostic.contains("RESOURCE_BUSY"), + "must return structured RESOURCE_BUSY: {diagnostic}" + ); + assert!( + diagnostic.contains("55006"), + "must retain SQLSTATE evidence: {diagnostic}" + ); + // `diagnostic` is the JSON-serialized error text, so on Windows the + // path's backslashes are JSON-escaped (`\` -> `\\`) and a raw `contains` + // against the un-escaped path misses. Match the JSON-escaped form; on + // Unix the path has no backslashes, so `escaped_path` equals the raw path + // and the check is unchanged there. + let escaped_path = effective_path.to_str().unwrap().replace('\\', "\\\\"); + assert!( + diagnostic.contains(&escaped_path), + "must name exact effective persistent path {} (JSON-escaped: {escaped_path}): {diagnostic}", + effective_path.display() + ); + let lower = diagnostic.to_lowercase(); + assert!( + lower.contains("doctor"), + "must include doctor guidance: {diagnostic}" + ); + assert!( + lower.contains("possible") && (lower.contains("owner") || lower.contains("process")), + "must describe a possible owner without accusation: {diagnostic}" + ); + + h.shutdown().await?; + drop(owner); + Ok(()) +} + +fn run_contained_mcp_child(test_name: &str, child_env: &str, workspace: &Path) { + let mut child = + Command::new(std::env::current_exe().expect("integration test executable path")) + .args(["--exact", test_name, "--nocapture"]) + .env(child_env, workspace) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("parent must spawn exact MCP lock helper child"); + + let deadline = Instant::now() + Duration::from_secs(15); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let output = child + .wait_with_output() + .expect("parent must collect completed MCP helper output"); + assert!( + status.success(), + "MCP lock helper failed with {status}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let kill_error = child.kill().err(); + let output = child + .wait_with_output() + .expect("parent must wait for timed-out MCP helper child"); + panic!( + "MCP lock helper exceeded its 15s bound and was killed ({kill_error:?})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + Err(error) => { + let _ = child.kill(); + let output = child + .wait_with_output() + .expect("parent must wait after MCP helper status error"); + panic!( + "MCP lock helper status check failed: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + } +} + +/// A normal response and the lock-contended fallback have one installation +/// identity contract. Only engine-dependent statistics may be absent when the +/// fallback says `engine_busy: true`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn status_full_and_degraded_share_identity_contract() -> TestResult { + let h = TestHarness::start(false, false).await?; + + // Ensure the server's eager warm-up has completed before observing the + // uncontended branch; otherwise startup itself legitimately uses the + // degraded response while the engine is still absent. + let warm_up = call_tool( + &h.client, + "query", + serde_json::json!({ "sql": "SELECT 1 AS ready" }), + ) + .await?; + assert!( + !is_error(&warm_up), + "query must initialize the engine: {:?}", + first_text(&warm_up) + ); + + let full_result = call_tool(&h.client, "status", serde_json::json!({})).await?; + assert!(!is_error(&full_result), "full status must succeed"); + let full = status_json(&full_result); + assert_eq!(full["engine_busy"], false, "uncontended status is full"); + + let engine_lock_holder = hold_engine_lock(Arc::clone(&h.engine_handle)); + let degraded_result = call_tool(&h.client, "status", serde_json::json!({})).await?; + assert!(!is_error(°raded_result), "degraded status must succeed"); + let degraded = status_json(°raded_result); + assert_eq!(degraded["engine_busy"], true, "lock contention is explicit"); + + for key in [ + "mcp_version", + "hyper_rust_api_version", + "installation", + "default_database", + ] { + assert!( + full.get(key).is_some(), + "full status missing `{key}`: {full}" + ); + assert!( + degraded.get(key).is_some(), + "degraded status missing `{key}`: {degraded}" + ); + assert_eq!( + full[key], degraded[key], + "full and degraded status disagree on `{key}`" + ); + } + + assert_eq!( + full["mcp_version"], + hyperdb_mcp::version::mcp_version_string() + ); + assert_eq!( + full["hyper_rust_api_version"], + hyperdb_mcp::version::hyper_api_version_string() + ); + assert!( + full["installation"].is_object(), + "installation must be a structured identity: {}", + full["installation"] + ); + assert_eq!(full["default_database"], "local"); + + drop(engine_lock_holder); + h.shutdown().await +} + +/// The status fast path must not wait behind an in-flight data-plane lock and +/// must honestly omit fields that require that lock. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn status_degraded_returns_promptly_while_engine_locked() -> TestResult { + let h = TestHarness::start(false, false).await?; + let engine_lock_holder = hold_engine_lock(Arc::clone(&h.engine_handle)); + + let started = Instant::now(); + let result = tokio::time::timeout( + Duration::from_secs(1), + call_tool(&h.client, "status", serde_json::json!({})), + ) + .await + .expect("status must return before the explicit one-second bound")?; + assert!( + started.elapsed() < Duration::from_secs(1), + "status exceeded its one-second prompt-return bound" + ); + assert!(!is_error(&result), "degraded status must succeed"); + let status = status_json(&result); + + assert_eq!(status["engine_busy"], true); + assert_eq!( + status["mcp_version"], + hyperdb_mcp::version::mcp_version_string(), + "degraded status must retain the MCP identity" + ); + assert_eq!( + status["hyper_rust_api_version"], + hyperdb_mcp::version::hyper_api_version_string(), + "degraded status must retain the underlying API identity" + ); + assert!( + status["installation"].is_object(), + "degraded status must retain installation identity" + ); + assert_eq!(status["default_database"], "local"); + for omitted in [ + "table_count", + "total_rows", + "disk_usage_bytes", + "ephemeral_path", + "logs", + ] { + assert!( + status.get(omitted).is_none(), + "degraded status must omit `{omitted}`: {status}" + ); + } + + drop(engine_lock_holder); + h.shutdown().await +} + // ===================================================================== // Four "now works" happy paths — PR #31 rejections lifted by PR #32. // ===================================================================== @@ -865,3 +2352,1666 @@ async fn tool_execute_singleton_uses_auto_commit_path() -> TestResult { ); h.shutdown().await } + +/// All query-oriented tools expose the canonical database they actually used, +/// without changing their established payloads or MCP content layouts. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resolved_database_query_success_shapes() -> TestResult { + let h = TestHarness::start(false, false).await?; + let attached_dir = TempDir::new()?; + let attached_path = attached_dir.path().join("attached.hyper"); + let mut failures = Vec::new(); + + // The primary starts with no user tables (the durable catalog lives in + // the separate persistent attachment), so this is the successful empty + // listing branch before the remaining fixture tables are created. + match call_tool(&h.client, "describe", serde_json::json!({})).await { + Ok(result) => { + if let Some(payload) = record_object_response( + &mut failures, + "describe empty local listing", + &result, + "local", + &["resolved_database", "tables"], + ) { + if payload["tables"] != serde_json::json!([]) { + failures.push(format!( + "describe empty local listing: tables must remain an empty array, got {:?}", + payload.get("tables") + )); + } + } + } + Err(error) => failures.push(format!( + "describe empty local listing: MCP call failed: {error}" + )), + } + + // Fixture setup intentionally uses the normal tool surface so each routed + // call below observes the same search-path and attachment behavior users + // get. The calls under test are aggregated later, rather than stopping at + // the first missing resolved_database field. + for (case, tool, args) in [ + ( + "setup local table", + "execute", + serde_json::json!({ "sql": ["CREATE TABLE local_rows (x INT, label TEXT)"] }), + ), + ( + "setup persistent empty table", + "execute", + serde_json::json!({ + "sql": ["CREATE TABLE persistent_empty (x INT)"], + "database": "PERSISTENT" + }), + ), + ( + "setup truncation table", + "execute", + serde_json::json!({ + "sql": [ + "CREATE TABLE truncation_rows AS SELECT i FROM generate_series(1, 10001) s(i)" + ] + }), + ), + ( + "setup mixed-case attachment", + "attach_database", + serde_json::json!({ + "alias": "MiXeD_Attached", + "kind": "local_file", + "path": attached_path.to_string_lossy(), + "writable": true, + "on_missing": "create" + }), + ), + ( + "setup attached table", + "execute", + serde_json::json!({ + "sql": ["CREATE TABLE attached_rows (x INT)"], + "database": "MIXED_ATTACHED" + }), + ), + ] { + let result = call_tool(&h.client, tool, args).await?; + if is_error(&result) { + return Err(format!("{case} failed: {:?}", first_text(&result)).into()); + } + } + + macro_rules! call_case { + ($case:expr, $tool:expr, $args:expr) => { + match call_tool(&h.client, $tool, $args).await { + Ok(result) => Some(result), + Err(error) => { + failures.push(format!("{}: MCP call failed: {error}", $case)); + None + } + } + }; + } + + // Execute preserves both singleton and transaction response shapes while + // covering the default primary and the canonicalized attached alias. + if let Some(result) = call_case!( + "execute local transaction", + "execute", + serde_json::json!({ + "sql": [ + "INSERT INTO local_rows VALUES (1, 'local')", + "INSERT INTO local_rows VALUES (2, 'second')" + ] + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "execute local transaction", + &result, + "local", + &[ + "affected_rows", + "per_statement", + "resolved_database", + "statements", + "stats", + ], + ) { + if payload["statements"] != serde_json::json!(2) + || payload["affected_rows"] != serde_json::json!(2) + || payload["stats"]["operation"] != serde_json::json!("transaction") + || payload["per_statement"].as_array().map_or(0, Vec::len) != 2 + { + failures.push( + "execute local transaction: legacy transaction counters or operation changed" + .into(), + ); + } + } + } + if let Some(result) = call_case!( + "execute attached command", + "execute", + serde_json::json!({ + "sql": ["INSERT INTO attached_rows VALUES (7)"], + "database": "MiXeD_AtTaChEd" + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "execute attached command", + &result, + "mixed_attached", + &[ + "affected_rows", + "per_statement", + "resolved_database", + "statements", + "stats", + ], + ) { + if payload["statements"] != serde_json::json!(1) + || payload["affected_rows"] != serde_json::json!(1) + || payload["stats"]["operation"] != serde_json::json!("command") + || payload["per_statement"].as_array().map_or(0, Vec::len) != 1 + { + failures.push( + "execute attached command: legacy command counters or operation changed".into(), + ); + } + } + } + + // Query's JSON is deliberately the *second* text block. Exercise both a + // normal result and the existing successful zero-row result. + if let Some(result) = call_case!( + "query local rows", + "query", + serde_json::json!({ "sql": "SELECT x FROM local_rows WHERE x = 1" }) + ) { + record_query_response( + &mut failures, + "query local rows", + &result, + "local", + "SELECT\n x\nFROM\n local_rows\nWHERE\n x = 1", + &serde_json::json!([{ "x": 1 }]), + ); + } + if let Some(result) = call_case!( + "query persistent zero rows", + "query", + serde_json::json!({ + "sql": "SELECT x FROM persistent_empty", + "database": "PERSISTENT" + }) + ) { + record_query_response( + &mut failures, + "query persistent zero rows", + &result, + "persistent", + "SELECT\n x\nFROM\n persistent_empty", + &serde_json::json!([]), + ); + } + if let Some(result) = call_case!( + "query attached mixed case", + "query", + serde_json::json!({ + "sql": "SELECT x FROM attached_rows", + "database": "MiXeD_AtTaChEd" + }) + ) { + record_query_response( + &mut failures, + "query attached mixed case", + &result, + "mixed_attached", + "SELECT\n x\nFROM\n attached_rows", + &serde_json::json!([{ "x": 7 }]), + ); + } + if let Some(result) = call_case!( + "query local truncation", + "query", + serde_json::json!({ "sql": "SELECT i FROM truncation_rows" }) + ) { + if is_error(&result) { + failures.push(format!( + "query local truncation: tool returned an error: {:?}", + first_text(&result) + )); + } + if result.structured_content.is_some() { + failures.push("query local truncation: query must not add structuredContent".into()); + } + if result.content.len() != 2 + || result + .content + .first() + .and_then(|content| content.raw.as_text()) + .map(|content| content.text.as_str()) + != Some("```sql\nSELECT\n i\nFROM\n truncation_rows\n```") + || result + .content + .get(1) + .and_then(|content| content.raw.as_text()) + .is_none() + { + failures.push( + "query local truncation: content must remain formatted SQL then JSON text".into(), + ); + } + let payload = result + .content + .get(1) + .and_then(|content| content.raw.as_text()) + .and_then(|content| serde_json::from_str::(&content.text).ok()); + let expected_fields = [ + "hint", + "resolved_database", + "result", + "rows_returned", + "stats", + "total_rows", + "truncated", + ]; + let mut fields = payload + .as_ref() + .and_then(serde_json::Value::as_object) + .map(|object| object.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + fields.sort_unstable(); + let rows = payload + .as_ref() + .and_then(|payload| payload.get("result")) + .and_then(serde_json::Value::as_array); + let expected_hint = "Result set has 10001 rows; only the first 10000 are shown. Add a LIMIT clause, aggregate with GROUP BY, or use the `export` tool to write the full result to a file."; + if fields != expected_fields + || rows.map_or(0, Vec::len) != 10_000 + || rows + .and_then(|rows| rows.first()) + .and_then(|row| row.get("i")) + != Some(&serde_json::json!(1)) + || rows + .and_then(|rows| rows.last()) + .and_then(|row| row.get("i")) + != Some(&serde_json::json!(10_000)) + || payload + .as_ref() + .and_then(|payload| payload.get("truncated")) + != Some(&serde_json::json!(true)) + || payload + .as_ref() + .and_then(|payload| payload.get("total_rows")) + != Some(&serde_json::json!(10_001)) + || payload + .as_ref() + .and_then(|payload| payload.get("rows_returned")) + != Some(&serde_json::json!(10_000)) + || payload.as_ref().and_then(|payload| payload.get("hint")) + != Some(&serde_json::json!(expected_hint)) + || payload + .as_ref() + .and_then(|payload| payload.get("resolved_database")) + != Some(&serde_json::json!("local")) + || payload + .as_ref() + .and_then(|payload| payload.get("stats")) + .and_then(|stats| stats.get("rows_returned")) + != Some(&serde_json::json!(10_000)) + { + failures.push(format!( + "query local truncation: legacy truncation fields or resolved database changed: {payload:?}" + )); + } + } + + // Sample has normal and empty successful payloads, each retaining the + // single text/structured mirror that older clients consume. + for (case, args, database, table, row_count, sample_size) in [ + ( + "sample local rows", + serde_json::json!({ "table": "local_rows", "n": 5 }), + "local", + "local_rows", + 2, + 2, + ), + ( + "sample persistent empty", + serde_json::json!({ + "table": "persistent_empty", + "n": 5, + "database": "PERSISTENT" + }), + "persistent", + "persistent_empty", + 0, + 0, + ), + ( + "sample attached mixed case", + serde_json::json!({ + "table": "attached_rows", + "n": 5, + "database": "MiXeD_AtTaChEd" + }), + "mixed_attached", + "attached_rows", + 1, + 1, + ), + ] { + if let Some(result) = call_case!(case, "sample", args) { + if let Some(payload) = record_object_response( + &mut failures, + case, + &result, + database, + &[ + "resolved_database", + "row_count", + "rows", + "sample_size", + "schema", + "stats", + "table", + ], + ) { + if payload["table"] != serde_json::json!(table) + || payload["row_count"] != serde_json::json!(row_count) + || payload["sample_size"] != serde_json::json!(sample_size) + || payload["rows"].as_array().map_or(usize::MAX, Vec::len) + != usize::try_from(sample_size).expect("sample size is non-negative") + || payload["schema"].as_array().map_or(0, Vec::len) == 0 + || payload["stats"]["operation"] != serde_json::json!("sample") + { + failures.push(format!("{case}: legacy sample fields changed")); + } + } + } + } + + // Describe's table-specific and populated-listing variants are both + // successes; the empty listing was pinned above before fixture setup. + for (case, args, database, expected_table, expected_count) in [ + ( + "describe local listing", + serde_json::json!({}), + "local", + None, + Some(2), + ), + ( + "describe persistent table", + serde_json::json!({ "table": "persistent_empty", "database": "PERSISTENT" }), + "persistent", + Some("persistent_empty"), + Some(1), + ), + ( + "describe attached mixed case", + serde_json::json!({ "table": "attached_rows", "database": "MiXeD_AtTaChEd" }), + "mixed_attached", + Some("attached_rows"), + Some(1), + ), + ] { + if let Some(result) = call_case!(case, "describe", args) { + if let Some(payload) = record_object_response( + &mut failures, + case, + &result, + database, + &["resolved_database", "tables"], + ) { + let tables = payload["tables"].as_array(); + if tables.is_some_and(|tables| tables.len() != expected_count.unwrap_or(0)) { + failures.push(format!( + "{case}: describe table count changed: got {tables:?}" + )); + } + if let Some(expected_table) = expected_table { + if tables + .and_then(|tables| tables.first()) + .and_then(|table| table.get("name")) + != Some(&serde_json::json!(expected_table)) + { + failures.push(format!( + "{case}: describe must preserve table name {expected_table}" + )); + } + } + } + } + } + + // Chart is the other custom response: inline keeps image first and stats + // second; disk-only returns just stats. Neither gains structuredContent. + if let Some(result) = call_case!( + "chart local inline", + "chart", + serde_json::json!({ + "sql": "SELECT label, x FROM local_rows", + "chart_type": "bar", + "x": "label", + "y": "x" + }) + ) { + if result.structured_content.is_some() { + failures.push("chart local inline: chart must not add structuredContent".into()); + } + if result.content.len() != 2 + || result + .content + .first() + .and_then(|content| content.raw.as_image()) + .is_none() + || result + .content + .get(1) + .and_then(|content| content.raw.as_text()) + .is_none() + { + failures.push( + "chart local inline: content must remain image first, stats text second".into(), + ); + } + let stats = result + .content + .get(1) + .and_then(|content| content.raw.as_text()) + .and_then(|content| serde_json::from_str::(&content.text).ok()); + let expected_fields = [ + "bytes", + "elapsed_ms", + "format", + "height", + "inline", + "operation", + "resolved_database", + "rows_plotted", + "width", + ]; + let mut fields = stats + .as_ref() + .and_then(serde_json::Value::as_object) + .map(|object| object.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + fields.sort_unstable(); + if fields != expected_fields + || stats.as_ref().and_then(|stats| stats.get("operation")) + != Some(&serde_json::json!("chart")) + || stats.as_ref().and_then(|stats| stats.get("inline")) + != Some(&serde_json::json!(true)) + || stats + .as_ref() + .and_then(|stats| stats.get("resolved_database")) + != Some(&serde_json::json!("local")) + { + failures.push(format!( + "chart local inline: legacy stats or resolved database changed: {stats:?}" + )); + } + } + if let Some(result) = call_case!( + "chart persistent disk only", + "chart", + serde_json::json!({ + "sql": "SELECT 1 AS x, 2 AS y", + "chart_type": "bar", + "x": "x", + "y": "y", + "format": "svg", + "inline": false, + "database": "PERSISTENT" + }) + ) { + if result.structured_content.is_some() + || result.content.len() != 1 + || result + .content + .first() + .and_then(|content| content.raw.as_text()) + .is_none() + { + failures.push( + "chart persistent disk only: content must remain one stats text block".into(), + ); + } + let stats = result + .content + .first() + .and_then(|content| content.raw.as_text()) + .and_then(|content| serde_json::from_str::(&content.text).ok()); + let expected_fields = [ + "bytes", + "elapsed_ms", + "format", + "height", + "inline", + "operation", + "output_path", + "resolved_database", + "rows_plotted", + "width", + ]; + let mut fields = stats + .as_ref() + .and_then(serde_json::Value::as_object) + .map(|object| object.keys().map(String::as_str).collect::>()) + .unwrap_or_default(); + fields.sort_unstable(); + if fields != expected_fields + || stats.as_ref().and_then(|stats| stats.get("operation")) + != Some(&serde_json::json!("chart")) + || stats.as_ref().and_then(|stats| stats.get("format")) + != Some(&serde_json::json!("svg")) + || stats.as_ref().and_then(|stats| stats.get("inline")) + != Some(&serde_json::json!(false)) + || stats + .as_ref() + .and_then(|stats| stats.get("resolved_database")) + != Some(&serde_json::json!("persistent")) + || stats + .as_ref() + .and_then(|stats| stats.get("output_path")) + .and_then(serde_json::Value::as_str) + .is_none() + { + failures.push(format!( + "chart persistent disk only: legacy stats or resolved database changed: {stats:?}" + )); + } + } + + if let Err(error) = h.shutdown().await { + failures.push(format!("test harness shutdown failed: {error}")); + } + if failures.is_empty() { + Ok(()) + } else { + Err(format!( + "resolved_database query success-shape regressions:\n- {}", + failures.join("\n- ") + ) + .into()) + } +} + +/// Every successful ingest/export/watch/catalog response keeps its legacy +/// shape while reporting the canonical database selected by routing. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn resolved_database_data_success_shapes() -> TestResult { + let mut h = TestHarness::start(false, false).await?; + let temp = TempDir::new()?; + let watch_dir = TempDir::new()?; + let attached_path = temp.path().join("mixed-data.hyper"); + let base_csv = temp.path().join("base.csv"); + let merge_add_csv = temp.path().join("merge-add.csv"); + let merge_same_csv = temp.path().join("merge-same.csv"); + let two_row_batch_csv = temp.path().join("batch-a.csv"); + let single_row_batch_csv = temp.path().join("batch-b.csv"); + std::fs::write(&base_csv, b"id,name\n1,alice\n2,bob\n")?; + std::fs::write(&merge_add_csv, b"id,name,extra\n1,alicia,new\n")?; + std::fs::write(&merge_same_csv, b"id,name,extra\n2,robert,same\n")?; + std::fs::write(&two_row_batch_csv, b"id,value\n1,a\n2,b\n")?; + std::fs::write(&single_row_batch_csv, b"id,value\n3,c\n")?; + let mut failures = Vec::new(); + + macro_rules! call_case { + ($case:expr, $tool:expr, $args:expr) => { + match call_tool(&h.client, $tool, $args).await { + Ok(result) => Some(result), + Err(error) => { + failures.push(format!("{}: MCP call failed: {error}", $case)); + None + } + } + }; + } + + if let Some(result) = call_case!( + "setup mixed-case data attachment", + "attach_database", + serde_json::json!({ + "alias": "MiXeD_Data", + "kind": "local_file", + "path": attached_path.to_string_lossy(), + "writable": true, + "on_missing": "create" + }) + ) { + if is_error(&result) { + failures.push(format!( + "setup mixed-case data attachment: {:?}", + first_text(&result) + )); + } + } + + if let Err(error) = h + .client + .subscribe(SubscribeRequestParams::new("hyper://workspace")) + .await + { + failures.push(format!( + "setup workspace resource subscription failed: {error}" + )); + } + + if let Some(result) = call_case!( + "load_data local replace", + "load_data", + serde_json::json!({ + "table": "inline_local", + "format": "json", + "data": r#"[{"id":1,"name":"one"},{"id":2,"name":"two"}]"# + }) + ) { + record_ingest_response( + &mut failures, + "load_data local replace", + &result, + "local", + 2, + "inline_local", + "load_data", + "json", + 2, + false, + ); + record_notifications( + &mut failures, + "load_data local replace", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "load_data explicit local wins over persist", + "load_data", + serde_json::json!({ + "table": "inline_local", + "format": "json", + "mode": "append", + "database": "LoCaL", + "persist": true, + "data": r#"[{"id":3,"name":"three"}]"# + }) + ) { + record_ingest_response( + &mut failures, + "load_data explicit local wins over persist", + &result, + "local", + 1, + "inline_local", + "load_data", + "json", + 2, + false, + ); + record_notifications( + &mut failures, + "load_data explicit local wins over persist", + &mut h.notification_rx, + &[NotificationEvent::ResourceUpdated( + "hyper://workspace".into(), + )], + ) + .await; + } + + if let Some(result) = call_case!( + "load_file persist true replace", + "load_file", + serde_json::json!({ + "path": base_csv.to_string_lossy(), + "table": "file_persistent", + "format": "csv", + "persist": true + }) + ) { + record_ingest_response( + &mut failures, + "load_file persist true replace", + &result, + "persistent", + 2, + "file_persistent", + "load_file", + "csv", + 2, + false, + ); + record_notifications( + &mut failures, + "load_file persist true replace", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "load_file attached replace", + "load_file", + serde_json::json!({ + "path": base_csv.to_string_lossy(), + "table": "file_attached", + "format": "csv", + "database": "MiXeD_DaTa" + }) + ) { + record_ingest_response( + &mut failures, + "load_file attached replace", + &result, + "mixed_data", + 2, + "file_attached", + "load_file", + "csv", + 2, + false, + ); + record_notifications( + &mut failures, + "load_file attached replace", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "load_file attached merge adds schema", + "load_file", + serde_json::json!({ + "path": merge_add_csv.to_string_lossy(), + "table": "file_attached", + "format": "csv", + "mode": "merge", + "merge_key": ["id"], + "database": "MIXED_DATA" + }) + ) { + record_ingest_response( + &mut failures, + "load_file attached merge adds schema", + &result, + "mixed_data", + 1, + "file_attached", + "load_file", + "csv", + 3, + true, + ); + record_notifications( + &mut failures, + "load_file attached merge adds schema", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "load_file attached merge preserves schema", + "load_file", + serde_json::json!({ + "path": merge_same_csv.to_string_lossy(), + "table": "file_attached", + "format": "csv", + "mode": "merge", + "merge_key": ["id"], + "database": "mixed_data" + }) + ) { + record_ingest_response( + &mut failures, + "load_file attached merge preserves schema", + &result, + "mixed_data", + 1, + "file_attached", + "load_file", + "csv", + 3, + false, + ); + record_notifications( + &mut failures, + "load_file attached merge preserves schema", + &mut h.notification_rx, + &[NotificationEvent::ResourceUpdated( + "hyper://workspace".into(), + )], + ) + .await; + } + + if let Some(result) = call_case!( + "load_files all success local precedence", + "load_files", + serde_json::json!({ + "files": [ + {"path": two_row_batch_csv.to_string_lossy(), "table": "batch_local_a", "format": "csv"}, + {"path": single_row_batch_csv.to_string_lossy(), "table": "batch_local_b", "format": "csv"} + ], + "concurrency": 2, + "database": "LOCAL", + "persist": true + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "load_files all success local precedence", + &result, + "local", + &["resolved_database", "results", "summary"], + ) { + record_fields( + &mut failures, + "load_files all success local precedence summary", + &payload["summary"], + &["concurrency", "failed", "succeeded", "total"], + ); + if payload["summary"] + != serde_json::json!({"total": 2, "succeeded": 2, "failed": 0, "concurrency": 2}) + { + failures.push(format!( + "load_files all success local precedence: summary changed: {}", + payload["summary"] + )); + } + let results = payload["results"].as_array(); + if results.map(Vec::len) != Some(2) { + failures.push(format!( + "load_files all success local precedence: expected two results, got {results:?}" + )); + } + if let Some(results) = results { + for (index, (table, rows)) in [("batch_local_a", 2), ("batch_local_b", 1)] + .into_iter() + .enumerate() + { + if let Some(entry) = results.get(index) { + record_fields( + &mut failures, + &format!("load_files all success local precedence result {index}"), + entry, + &["rows", "schema", "stats", "table"], + ); + if entry["table"] != serde_json::json!(table) + || entry["rows"] != serde_json::json!(rows) + || entry.get("resolved_database").is_some() + { + failures.push(format!( + "load_files all success local precedence result {index}: legacy entry changed or duplicated resolved_database: {entry}" + )); + } + } + } + } + } + record_notifications( + &mut failures, + "load_files all success local precedence", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "load_files partial per-file success", + "load_files", + serde_json::json!({ + "files": [ + {"path": single_row_batch_csv.to_string_lossy(), "table": "batch_partial_ok", "format": "csv"}, + {"path": two_row_batch_csv.to_string_lossy(), "table": "batch_partial_bad", "format": "csv", "schema": 42} + ], + "concurrency": 2, + "persist": true + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "load_files partial per-file success", + &result, + "persistent", + &["resolved_database", "results", "summary"], + ) { + if payload["summary"] + != serde_json::json!({"total": 2, "succeeded": 1, "failed": 1, "concurrency": 2}) + { + failures.push(format!( + "load_files partial per-file success: summary changed: {}", + payload["summary"] + )); + } + let results = payload["results"].as_array(); + if let Some(results) = results { + if let Some(success) = results.first() { + record_fields( + &mut failures, + "load_files partial per-file success result", + success, + &["rows", "schema", "stats", "table"], + ); + if success["table"] != serde_json::json!("batch_partial_ok") + || success["rows"] != serde_json::json!(1) + || success.get("resolved_database").is_some() + { + failures.push(format!( + "load_files partial per-file success: successful entry changed: {success}" + )); + } + } + if let Some(failed) = results.get(1) { + record_fields( + &mut failures, + "load_files partial per-file failure result", + failed, + &["error", "table"], + ); + record_fields( + &mut failures, + "load_files partial per-file failure error", + &failed["error"], + &["code", "message"], + ); + if failed["table"] != serde_json::json!("batch_partial_bad") + || failed["error"]["code"] != serde_json::json!("SchemaMismatch") + || failed.get("resolved_database").is_some() + { + failures.push(format!( + "load_files partial per-file success: failed entry changed: {failed}" + )); + } + } + } else { + failures.push(format!( + "load_files partial per-file success: results must be an array: {}", + payload["results"] + )); + } + } + record_notifications( + &mut failures, + "load_files partial per-file success", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "load_files all entries failed remains top-level success", + "load_files", + serde_json::json!({ + "files": [ + {"path": two_row_batch_csv.to_string_lossy(), "table": "batch_all_bad", "format": "csv", "schema": 42} + ], + "concurrency": 1, + "database": "MiXeD_DaTa" + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "load_files all entries failed remains top-level success", + &result, + "mixed_data", + &["resolved_database", "results", "summary"], + ) { + if payload["summary"] + != serde_json::json!({"total": 1, "succeeded": 0, "failed": 1, "concurrency": 1}) + || payload["results"].as_array().map(Vec::len) != Some(1) + || payload["results"][0]["error"]["code"] != serde_json::json!("SchemaMismatch") + { + failures.push(format!( + "load_files all entries failed remains top-level success: legacy batch shape changed: {payload}" + )); + } + } + } + + let canonical_watch_dir = match watch_dir.path().canonicalize() { + Ok(path) => Some(path), + Err(error) => { + failures.push(format!("watch directory canonicalization failed: {error}")); + None + } + }; + if let Some(result) = call_case!( + "watch_directory attached empty initial sweep", + "watch_directory", + serde_json::json!({ + "path": watch_dir.path().to_string_lossy(), + "table": "file_attached", + "database": "MiXeD_DaTa", + "max_concurrent": 1 + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "watch_directory attached empty initial sweep", + &result, + "mixed_data", + &[ + "directory", + "initial_sweep", + "max_concurrent", + "resolved_database", + "status", + "table", + ], + ) { + record_fields( + &mut failures, + "watch_directory attached empty initial sweep stats", + &payload["initial_sweep"], + &["files_failed", "files_ingested"], + ); + if payload["directory"] + != serde_json::json!(canonical_watch_dir + .as_deref() + .unwrap_or_else(|| watch_dir.path()) + .to_string_lossy()) + || payload["table"] != serde_json::json!("file_attached") + || payload["status"] != serde_json::json!("watching") + || payload["max_concurrent"] != serde_json::json!(1) + || payload["initial_sweep"] + != serde_json::json!({"files_ingested": 0, "files_failed": 0}) + { + failures.push(format!( + "watch_directory attached empty initial sweep: watcher handle changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "watch_directory registry status", + "status", + serde_json::json!({}) + ) { + let payload = first_text(&result) + .as_deref() + .and_then(|text| serde_json::from_str::(text).ok()); + let watcher = payload + .as_ref() + .and_then(|body| body["watchers"].as_array()) + .and_then(|watchers| watchers.first()); + if let Some(watcher) = watcher { + record_fields( + &mut failures, + "watch_directory registry status entry", + watcher, + &[ + "directory", + "files_failed", + "files_ingested", + "in_flight", + "last_error", + "last_event_ms_ago", + "max_concurrent", + "table", + "target_db", + ], + ); + if watcher["target_db"] != serde_json::json!("mixed_data") + || watcher["table"] != serde_json::json!("file_attached") + || watcher["files_ingested"] != serde_json::json!(0) + || watcher["files_failed"] != serde_json::json!(0) + || watcher["in_flight"] != serde_json::json!(0) + { + failures.push(format!( + "watch_directory registry status: active watcher state changed: {watcher}" + )); + } + } else { + failures.push(format!( + "watch_directory registry status: expected one active watcher, got {payload:?}" + )); + } + } + + if let Some(path) = canonical_watch_dir.as_ref() { + if let Some(result) = call_case!( + "watch_directory teardown", + "unwatch_directory", + serde_json::json!({"path": path.to_string_lossy()}) + ) { + if let Some(payload) = record_legacy_object_response( + &mut failures, + "watch_directory teardown", + &result, + &[ + "directory", + "files_failed", + "files_ingested", + "last_error", + "status", + "table", + ], + ) { + if payload["status"] != serde_json::json!("stopped") + || payload["table"] != serde_json::json!("file_attached") + || payload["files_ingested"] != serde_json::json!(0) + || payload["files_failed"] != serde_json::json!(0) + { + failures.push(format!( + "watch_directory teardown: legacy stop summary changed: {payload}" + )); + } + } + } + } + + if let Some(result) = call_case!( + "watch_directory registry empty after teardown", + "status", + serde_json::json!({}) + ) { + let payload = first_text(&result) + .as_deref() + .and_then(|text| serde_json::from_str::(text).ok()); + if match payload + .as_ref() + .and_then(|body| body["watchers"].as_array()) + { + Some(watchers) => !watchers.is_empty(), + None => true, + } { + failures.push(format!( + "watch_directory registry empty after teardown: watcher handle leaked: {payload:?}" + )); + } + } + + let csv_export_path = temp.path().join("persistent.csv"); + if let Some(result) = call_case!( + "export persistent table to csv", + "export", + serde_json::json!({ + "table": "file_persistent", + "path": csv_export_path.to_string_lossy(), + "format": "csv", + "database": "PERSISTENT" + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "export persistent table to csv", + &result, + "persistent", + &[ + "file_size_bytes", + "output_path", + "resolved_database", + "rows", + "stats", + ], + ) { + record_fields( + &mut failures, + "export persistent table to csv stats", + &payload["stats"], + &[ + "elapsed_ms", + "file_size_bytes", + "format", + "operation", + "output_path", + "rows", + "rows_per_sec", + ], + ); + if payload["rows"] != serde_json::json!(2) + || payload["output_path"] != serde_json::json!(csv_export_path.to_string_lossy()) + || match payload["file_size_bytes"].as_u64() { + Some(bytes) => bytes == 0, + None => true, + } + || payload["stats"]["operation"] != serde_json::json!("export") + || payload["stats"]["format"] != serde_json::json!("csv") + { + failures.push(format!( + "export persistent table to csv: legacy export payload changed: {payload}" + )); + } + } + if !csv_export_path.is_file() { + failures.push("export persistent table to csv: output file was not created".into()); + } + } + + let hyper_export_path = temp.path().join("local.hyper"); + if let Some(result) = call_case!( + "export bare local hyper snapshot", + "export", + serde_json::json!({ + "path": hyper_export_path.to_string_lossy(), + "format": "hyper", + "database": "LOCAL" + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "export bare local hyper snapshot", + &result, + "local", + &[ + "file_size_bytes", + "output_path", + "resolved_database", + "rows", + "stats", + ], + ) { + if payload["rows"] != serde_json::json!(0) + || payload["stats"]["format"] != serde_json::json!("hyper") + || payload["output_path"] != serde_json::json!(hyper_export_path.to_string_lossy()) + { + failures.push(format!( + "export bare local hyper snapshot: legacy snapshot payload changed: {payload}" + )); + } + } + if !hyper_export_path.is_file() { + failures.push("export bare local hyper snapshot: output file was not created".into()); + } + } + + if let Some(result) = call_case!( + "set_table_metadata attached catalog entry", + "set_table_metadata", + serde_json::json!({ + "table": "file_attached", + "database": "MiXeD_DaTa", + "purpose": "resolved database regression", + "license": "CC0", + "notes": "legacy fields stay intact" + }) + ) { + if let Some(payload) = record_object_response( + &mut failures, + "set_table_metadata attached catalog entry", + &result, + "mixed_data", + &[ + "created_by", + "data_url", + "last_modified_by", + "last_refreshed_at", + "license", + "load_params", + "load_tool", + "loaded_at", + "notes", + "purpose", + "resolved_database", + "row_count", + "source_description", + "source_url", + "table_name", + ], + ) { + if payload["table_name"] != serde_json::json!("file_attached") + || payload["purpose"] != serde_json::json!("resolved database regression") + || payload["license"] != serde_json::json!("CC0") + || payload["notes"] != serde_json::json!("legacy fields stay intact") + || payload["load_tool"] != serde_json::json!("load_file") + || payload["row_count"] != serde_json::json!(1) + || payload["loaded_at"].as_str().is_none() + || payload["last_refreshed_at"].as_str().is_none() + { + failures.push(format!( + "set_table_metadata attached catalog entry: legacy catalog entry changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "catalog routing side effects", + "query", + serde_json::json!({ + "sql": "SELECT table_name, load_tool FROM _table_catalog WHERE table_name = 'file_attached'", + "database": "mixed_data" + }) + ) { + let text = all_text(&result); + if is_error(&result) || !text.contains("file_attached") || !text.contains("load_file") { + failures.push(format!( + "catalog routing side effects: attached catalog stub missing or changed: {text}" + )); + } + } + + let mut unexpected_notifications = Vec::new(); + while let Ok(notification) = h.notification_rx.try_recv() { + unexpected_notifications.push(notification); + } + if !unexpected_notifications.is_empty() { + failures.push(format!( + "non-mutating data cases emitted unexpected resource notifications: {unexpected_notifications:?}" + )); + } + + if let Err(error) = h.shutdown().await { + failures.push(format!("test harness shutdown failed: {error}")); + } + assert!( + failures.is_empty(), + "resolved_database data success-shape regressions:\n- {}", + failures.join("\n- ") + ); + Ok(()) +} + +/// `copy_query` keeps its legacy `target_database` compatibility field and +/// makes it identical to the common canonical routing metadata for every mode. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_query_preserves_target_and_resolved_database() -> TestResult { + let mut h = TestHarness::start(false, false).await?; + let temp = TempDir::new()?; + let attached_path = temp.path().join("copy-target.hyper"); + let mut failures = Vec::new(); + + macro_rules! call_case { + ($case:expr, $tool:expr, $args:expr) => { + match call_tool(&h.client, $tool, $args).await { + Ok(result) => Some(result), + Err(error) => { + failures.push(format!("{}: MCP call failed: {error}", $case)); + None + } + } + }; + } + + if let Some(result) = call_case!( + "setup mixed-case copy target", + "attach_database", + serde_json::json!({ + "alias": "MiXeD_Copy", + "kind": "local_file", + "path": attached_path.to_string_lossy(), + "writable": true, + "on_missing": "create" + }) + ) { + if is_error(&result) { + failures.push(format!( + "setup mixed-case copy target: {:?}", + first_text(&result) + )); + } + } + if let Err(error) = h + .client + .subscribe(SubscribeRequestParams::new("hyper://workspace")) + .await + { + failures.push(format!("setup copy resource subscription failed: {error}")); + } + + if let Some(result) = call_case!( + "copy create local default", + "copy_query", + serde_json::json!({ + "mode": "create", + "target_table": "copy_local", + "sql": "SELECT 1 AS x" + }) + ) { + record_copy_response( + &mut failures, + "copy create local default", + &result, + "local", + "copy_local", + "create", + 1, + ); + record_notifications( + &mut failures, + "copy create local default", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "copy append explicit local", + "copy_query", + serde_json::json!({ + "mode": "append", + "target_database": "LoCaL", + "target_table": "copy_local", + "sql": "SELECT 2 AS x" + }) + ) { + record_copy_response( + &mut failures, + "copy append explicit local", + &result, + "local", + "copy_local", + "append", + 2, + ); + record_notifications( + &mut failures, + "copy append explicit local", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + ], + ) + .await; + } + + if let Some(result) = call_case!( + "copy replace local", + "copy_query", + serde_json::json!({ + "mode": "replace", + "target_database": "LOCAL", + "target_table": "copy_local", + "sql": "SELECT 3 AS x UNION ALL SELECT 4 AS x" + }) + ) { + record_copy_response( + &mut failures, + "copy replace local", + &result, + "local", + "copy_local", + "replace", + 2, + ); + record_notifications( + &mut failures, + "copy replace local", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "copy create attached canonical alias", + "copy_query", + serde_json::json!({ + "mode": "create", + "target_database": "MiXeD_CoPy", + "target_table": "copy_attached", + "sql": "SELECT 10 AS x" + }) + ) { + record_copy_response( + &mut failures, + "copy create attached canonical alias", + &result, + "mixed_copy", + "copy_attached", + "create", + 1, + ); + record_notifications( + &mut failures, + "copy create attached canonical alias", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + if let Some(result) = call_case!( + "copy append attached canonical alias", + "copy_query", + serde_json::json!({ + "mode": "append", + "target_database": "MIXED_COPY", + "target_table": "copy_attached", + "sql": "SELECT 11 AS x" + }) + ) { + record_copy_response( + &mut failures, + "copy append attached canonical alias", + &result, + "mixed_copy", + "copy_attached", + "append", + 2, + ); + record_notifications( + &mut failures, + "copy append attached canonical alias", + &mut h.notification_rx, + &[NotificationEvent::ResourceUpdated( + "hyper://workspace".into(), + )], + ) + .await; + } + + if let Some(result) = call_case!( + "copy replace attached canonical alias", + "copy_query", + serde_json::json!({ + "mode": "replace", + "target_database": "mixed_copy", + "target_table": "copy_attached", + "sql": "SELECT 12 AS x UNION ALL SELECT 13 AS x" + }) + ) { + record_copy_response( + &mut failures, + "copy replace attached canonical alias", + &result, + "mixed_copy", + "copy_attached", + "replace", + 2, + ); + record_notifications( + &mut failures, + "copy replace attached canonical alias", + &mut h.notification_rx, + &[ + NotificationEvent::ResourceUpdated("hyper://workspace".into()), + NotificationEvent::ResourceListChanged, + ], + ) + .await; + } + + for (case, sql, database) in [ + ( + "copy local target contents", + "SELECT COUNT(*) AS n FROM copy_local", + None, + ), + ( + "copy attached target contents", + "SELECT COUNT(*) AS n FROM copy_attached", + Some("mixed_copy"), + ), + ] { + let args = match database { + Some(database) => serde_json::json!({"sql": sql, "database": database}), + None => serde_json::json!({"sql": sql}), + }; + if let Some(result) = call_case!(case, "query", args) { + let text = all_text(&result); + if is_error(&result) || (!text.contains("\"n\":2") && !text.contains("\"n\": 2")) { + failures.push(format!( + "{case}: copied table must contain the two replace rows; got {text}" + )); + } + } + } + + let mut unexpected_notifications = Vec::new(); + while let Ok(notification) = h.notification_rx.try_recv() { + unexpected_notifications.push(notification); + } + if !unexpected_notifications.is_empty() { + failures.push(format!( + "copy_query emitted unexpected extra resource notifications: {unexpected_notifications:?}" + )); + } + if let Err(error) = h.shutdown().await { + failures.push(format!("test harness shutdown failed: {error}")); + } + assert!( + failures.is_empty(), + "copy_query resolved-database compatibility regressions:\n- {}", + failures.join("\n- ") + ); + Ok(()) +} diff --git a/hyperdb-mcp/tests/engine_tests.rs b/hyperdb-mcp/tests/engine_tests.rs index 3f5f66b..c0d7ec0 100644 --- a/hyperdb-mcp/tests/engine_tests.rs +++ b/hyperdb-mcp/tests/engine_tests.rs @@ -7,7 +7,15 @@ mod common; use common::TestEngine; +use hyperdb_mcp::engine::Engine; use hyperdb_mcp::error::ErrorCode; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +const PERSISTENT_LOCK_CHILD_ENV: &str = "HYPERDB_MCP_PERSISTENT_LOCK_CHILD"; /// Verify that creating an Engine successfully starts hyperd and establishes /// a live connection. @@ -17,6 +25,122 @@ fn engine_starts_and_connects() { assert!(te.engine.is_running()); } +/// A second private engine must diagnose a locked reserved persistent +/// attachment without risking a permanently blocked integration-test worker. +/// The parent owns the exact helper child and always kills + waits on timeout. +#[test] +fn real_persistent_lock_reproduces_resource_busy() { + if let Some(workspace) = std::env::var_os(PERSISTENT_LOCK_CHILD_ENV) { + run_real_persistent_lock_child(Path::new(&workspace)); + return; + } + + let temp_dir = TempDir::new().expect("parent must own RAII workspace directory"); + let workspace = temp_dir.path().join("contended-persistent.hyper"); + run_contained_child( + "real_persistent_lock_reproduces_resource_busy", + PERSISTENT_LOCK_CHILD_ENV, + &workspace, + ); +} + +fn run_real_persistent_lock_child(workspace: &Path) { + let effective_path = workspace + .canonicalize() + .unwrap_or_else(|_| workspace.to_path_buf()); + let owner = Engine::new_no_daemon(Some(workspace.to_string_lossy().into_owned())) + .expect("owner engine must attach the persistent workspace first"); + let error = Engine::new_no_daemon(Some(workspace.to_string_lossy().into_owned())) + .expect_err("second private engine must reject the contended persistent workspace"); + + assert_eq!(error.code, ErrorCode::ResourceBusy); + assert!( + error.message.contains("55006"), + "must retain the lock SQLSTATE: {}", + error.message + ); + assert!( + error.message.to_lowercase().contains("attached") + || error.message.to_lowercase().contains("lock"), + "must retain Hyper's raw lock diagnostic: {}", + error.message + ); + assert!( + error.message.contains(effective_path.to_str().unwrap()), + "must report the exact effective persistent path {}: {}", + effective_path.display(), + error.message + ); + let guidance = error + .suggestion + .expect("persistent lock needs recovery guidance"); + let guidance_lower = guidance.to_lowercase(); + assert!( + guidance_lower.contains("doctor"), + "guidance must point to doctor: {guidance}" + ); + assert!( + guidance_lower.contains("possible") + && (guidance_lower.contains("owner") || guidance_lower.contains("process")), + "guidance must describe a possible owner without accusation: {guidance}" + ); + + drop(owner); +} + +fn run_contained_child(test_name: &str, child_env: &str, workspace: &Path) { + let mut child = + Command::new(std::env::current_exe().expect("integration test executable path")) + .args(["--exact", test_name, "--nocapture"]) + .env(child_env, workspace) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("parent must spawn the exact lock helper child"); + + let deadline = Instant::now() + Duration::from_secs(15); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let output = child + .wait_with_output() + .expect("parent must collect completed helper output"); + assert!( + status.success(), + "lock helper failed with {status}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + return; + } + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let kill_error = child.kill().err(); + let output = child + .wait_with_output() + .expect("parent must wait for timed-out helper child"); + panic!( + "lock helper exceeded its 15s bound and was killed ({kill_error:?})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + Err(error) => { + let _ = child.kill(); + let output = child + .wait_with_output() + .expect("parent must wait after helper status error"); + panic!( + "lock helper status check failed: {error}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + } + } +} + /// Tables whose names begin with `_hyperdb_` are `HyperDB` infrastructure /// (saved-queries meta-table today, future watcher/audit state /// tomorrow) and must be hidden from both `describe_tables` and the diff --git a/hyperdb-mcp/tests/error_tests.rs b/hyperdb-mcp/tests/error_tests.rs index 40b8f63..2c77854 100644 --- a/hyperdb-mcp/tests/error_tests.rs +++ b/hyperdb-mcp/tests/error_tests.rs @@ -126,6 +126,40 @@ fn maps_22003_to_schema_mismatch_with_override_suggestion() { ); } +/// SQLSTATE 55006 is not globally synonymous with a file lock. Only the +/// reserved persistent-attachment boundary has enough context to present it +/// as `RESOURCE_BUSY`; unrelated server operations must retain their existing +/// generic mapping. +#[test] +fn non_attach_55006_preserves_existing_mapping() { + let raw_message = "cannot drop database while another session uses it"; + let upstream = hyperdb_api::Error::server(Some("55006".to_string()), raw_message, None, None); + + let mapped: McpError = upstream.into(); + + assert_eq!(mapped.code, ErrorCode::SqlError); + assert!(mapped.message.contains("55006")); + assert!(mapped.message.contains(raw_message)); + assert_ne!(mapped.code, ErrorCode::ResourceBusy); +} + +/// Even a legacy lock-like phrase must not override the lack of persistent +/// attachment context. The global mapper sees only an arbitrary structured +/// server error, so this remains `SQL_ERROR`; the engine's reserved-attach +/// boundary is the sole place that may present it as `RESOURCE_BUSY`. +#[test] +fn non_attach_55006_with_legacy_phrase_preserves_existing_mapping() { + let raw_message = "database is already attached by another client connection"; + let upstream = hyperdb_api::Error::server(Some("55006".to_string()), raw_message, None, None); + + let mapped: McpError = upstream.into(); + + assert_eq!(mapped.code, ErrorCode::SqlError); + assert!(mapped.message.contains("55006")); + assert!(mapped.message.contains(raw_message)); + assert_ne!(mapped.code, ErrorCode::ResourceBusy); +} + /// The classifier must also fire on human-readable spellings, not just the /// raw SQLSTATE code, because different hyperd versions format the message /// differently. diff --git a/hyperdb-mcp/tests/kv_tools_tests.rs b/hyperdb-mcp/tests/kv_tools_tests.rs index b1b76e7..1018767 100644 --- a/hyperdb-mcp/tests/kv_tools_tests.rs +++ b/hyperdb-mcp/tests/kv_tools_tests.rs @@ -149,6 +149,70 @@ fn structured(result: &CallToolResult) -> serde_json::Value { serde_json::from_str(&text).unwrap_or(serde_json::Value::Null) } +/// Record one KV success without panicking so the aggregate routing test can +/// reach every later tool/branch before its single final assertion. +fn record_kv_response( + failures: &mut Vec, + case: &str, + result: &CallToolResult, + expected_database: &str, + expected_fields: &[&str], +) -> Option { + if is_error(result) { + failures.push(format!( + "{case}: tool returned an error: {:?}", + first_text(result) + )); + } + if result.content.len() != 1 { + failures.push(format!( + "{case}: expected one JSON text block, got {} content blocks", + result.content.len() + )); + } + let Some(text) = result + .content + .first() + .and_then(|content| content.raw.as_text()) + .map(|content| content.text.as_str()) + else { + failures.push(format!("{case}: first content block must be text JSON")); + return None; + }; + let payload = match serde_json::from_str::(text) { + Ok(payload) => payload, + Err(error) => { + failures.push(format!("{case}: text block is not JSON: {error}")); + return None; + } + }; + if result.structured_content.as_ref() != Some(&payload) { + failures.push(format!( + "{case}: structuredContent must exactly mirror the JSON text block" + )); + } + let Some(object) = payload.as_object() else { + failures.push(format!("{case}: JSON payload must be an object")); + return Some(payload); + }; + let mut actual_fields: Vec<_> = object.keys().map(String::as_str).collect(); + actual_fields.sort_unstable(); + let mut expected_fields = expected_fields.to_vec(); + expected_fields.sort_unstable(); + if actual_fields != expected_fields { + failures.push(format!( + "{case}: top-level fields changed: expected {expected_fields:?}, got {actual_fields:?}" + )); + } + if object.get("resolved_database") != Some(&serde_json::json!(expected_database)) { + failures.push(format!( + "{case}: resolved_database must be {expected_database:?}, got {:?}", + object.get("resolved_database") + )); + } + Some(payload) +} + // ===================================================================== // Core CRUD lifecycle (default / ephemeral database). // ===================================================================== @@ -966,3 +1030,679 @@ async fn kv_set_many_empty_batch_errors() -> TestResult { assert!(is_error(&empty), "empty entries must error"); h.shutdown().await } + +/// All nine routed KV tools preserve every legacy success branch while +/// reporting the canonical target selected by database/persist precedence. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resolved_database_kv_success_shapes() -> TestResult { + let h = TestHarness::start(false, false).await?; + let attached_dir = TempDir::new()?; + let attached_path = attached_dir.path().join("mixed-kv.hyper"); + let mut failures = Vec::new(); + + macro_rules! call_case { + ($case:expr, $tool:expr, $args:expr) => { + match call_tool(&h.client, $tool, $args).await { + Ok(result) => Some(result), + Err(error) => { + failures.push(format!("{}: MCP call failed: {error}", $case)); + None + } + } + }; + } + + if let Some(result) = call_case!( + "kv_get missing local key", + "kv_get", + serde_json::json!({"store": "empty_local", "key": "missing"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_get missing local key", + &result, + "local", + &["found", "resolved_database", "value"], + ) { + if payload["found"] != serde_json::json!(false) + || payload["value"] != serde_json::Value::Null + { + failures.push(format!( + "kv_get missing local key: legacy miss shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_list empty local keys", + "kv_list", + serde_json::json!({"store": "empty_local"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_list empty local keys", + &result, + "local", + &["count", "keys", "resolved_database", "store"], + ) { + if payload["store"] != serde_json::json!("empty_local") + || payload["count"] != serde_json::json!(0) + || payload["keys"] != serde_json::json!([]) + { + failures.push(format!( + "kv_list empty local keys: legacy empty shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_list empty local entries", + "kv_list", + serde_json::json!({"store": "empty_local", "values": true}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_list empty local entries", + &result, + "local", + &["entries", "resolved_database", "store"], + ) { + if payload["store"] != serde_json::json!("empty_local") + || payload["entries"] != serde_json::json!([]) + { + failures.push(format!( + "kv_list empty local entries: legacy empty values shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_list_stores empty local database", + "kv_list_stores", + serde_json::json!({}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_list_stores empty local database", + &result, + "local", + &["count", "resolved_database", "stores"], + ) { + if payload["count"] != serde_json::json!(0) + || payload["stores"] != serde_json::json!([]) + { + failures.push(format!( + "kv_list_stores empty local database: legacy empty shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_size empty local store", + "kv_size", + serde_json::json!({"store": "empty_local"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_size empty local store", + &result, + "local", + &["bytes", "resolved_database", "size", "store"], + ) { + if payload["store"] != serde_json::json!("empty_local") + || payload["size"] != serde_json::json!(0) + || payload["bytes"] != serde_json::json!(0) + { + failures.push(format!( + "kv_size empty local store: legacy empty size changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_pop empty local store", + "kv_pop", + serde_json::json!({"store": "empty_local"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_pop empty local store", + &result, + "local", + &["found", "resolved_database"], + ) { + if payload["found"] != serde_json::json!(false) { + failures.push(format!( + "kv_pop empty local store: legacy empty pop changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_delete missing local key", + "kv_delete", + serde_json::json!({"store": "empty_local", "key": "missing"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_delete missing local key", + &result, + "local", + &["deleted", "key", "resolved_database", "store"], + ) { + if payload["deleted"] != serde_json::json!(false) + || payload["store"] != serde_json::json!("empty_local") + || payload["key"] != serde_json::json!("missing") + { + failures.push(format!( + "kv_delete missing local key: legacy idempotent delete changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_clear explicit local wins over persist", + "kv_clear", + serde_json::json!({"store": "empty_local", "database": "LoCaL", "persist": true}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_clear explicit local wins over persist", + &result, + "local", + &["removed", "resolved_database", "store"], + ) { + if payload["removed"] != serde_json::json!(0) + || payload["store"] != serde_json::json!("empty_local") + { + failures.push(format!( + "kv_clear explicit local wins over persist: legacy idempotent clear changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_set persistent insert via persist", + "kv_set", + serde_json::json!({ + "store": "persistent_store", + "key": "p", + "value": "one", + "persist": true + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_set persistent insert via persist", + &result, + "persistent", + &[ + "created", + "key", + "resolved_database", + "store", + "stored", + "value_bytes", + ], + ) { + if payload["stored"] != serde_json::json!(true) + || payload["created"] != serde_json::json!(true) + || payload["store"] != serde_json::json!("persistent_store") + || payload["key"] != serde_json::json!("p") + || payload["value_bytes"] != serde_json::json!(3) + { + failures.push(format!( + "kv_set persistent insert via persist: legacy insert shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_get persistent found", + "kv_get", + serde_json::json!({ + "store": "persistent_store", + "key": "p", + "database": "PERSISTENT" + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_get persistent found", + &result, + "persistent", + &["found", "resolved_database", "value"], + ) { + if payload["found"] != serde_json::json!(true) + || payload["value"] != serde_json::json!("one") + { + failures.push(format!( + "kv_get persistent found: legacy hit shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_set persistent overwrite", + "kv_set", + serde_json::json!({ + "store": "persistent_store", + "key": "p", + "value": "two", + "database": "Persistent" + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_set persistent overwrite", + &result, + "persistent", + &[ + "created", + "key", + "resolved_database", + "store", + "stored", + "value_bytes", + ], + ) { + if payload["stored"] != serde_json::json!(true) + || payload["created"] != serde_json::json!(false) + || payload["value_bytes"] != serde_json::json!(3) + { + failures.push(format!( + "kv_set persistent overwrite: legacy overwrite shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_set persistent guard skip", + "kv_set", + serde_json::json!({ + "store": "persistent_store", + "key": "p", + "value": "three", + "overwrite": false, + "database": "persistent" + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_set persistent guard skip", + &result, + "persistent", + &[ + "created", + "existed", + "key", + "resolved_database", + "store", + "stored", + "value_bytes", + ], + ) { + if payload["stored"] != serde_json::json!(false) + || payload["created"] != serde_json::json!(false) + || payload["existed"] != serde_json::json!(true) + || payload["value_bytes"] != serde_json::json!(5) + { + failures.push(format!( + "kv_set persistent guard skip: legacy guard shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "setup mixed-case KV attachment", + "attach_database", + serde_json::json!({ + "alias": "MiXeD_Kv", + "kind": "local_file", + "path": attached_path.to_string_lossy(), + "writable": true, + "on_missing": "create" + }) + ) { + if is_error(&result) { + failures.push(format!( + "setup mixed-case KV attachment: {:?}", + first_text(&result) + )); + } + } + + if let Some(result) = call_case!( + "kv_set_many attached create batch", + "kv_set_many", + serde_json::json!({ + "store": "routed", + "entries": [ + {"key": "a", "value": "aa"}, + {"key": "b", "value": "bbb"} + ], + "database": "MiXeD_Kv" + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_set_many attached create batch", + &result, + "mixed_kv", + &[ + "created", + "overwritten", + "resolved_database", + "stored", + "total_bytes", + ], + ) { + if payload["stored"] != serde_json::json!(2) + || payload["created"] != serde_json::json!(2) + || payload["overwritten"] != serde_json::json!(0) + || payload["total_bytes"] != serde_json::json!(5) + { + failures.push(format!( + "kv_set_many attached create batch: legacy batch shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_set_many attached mixed overwrite batch", + "kv_set_many", + serde_json::json!({ + "store": "routed", + "entries": [ + {"key": "a", "value": "A"}, + {"key": "c", "value": "ccc"} + ], + "database": "MIXED_KV" + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_set_many attached mixed overwrite batch", + &result, + "mixed_kv", + &[ + "created", + "overwritten", + "resolved_database", + "stored", + "total_bytes", + ], + ) { + if payload["stored"] != serde_json::json!(2) + || payload["created"] != serde_json::json!(1) + || payload["overwritten"] != serde_json::json!(1) + || payload["total_bytes"] != serde_json::json!(4) + { + failures.push(format!( + "kv_set_many attached mixed overwrite batch: legacy batch shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_set_many attached guard batch", + "kv_set_many", + serde_json::json!({ + "store": "routed", + "entries": [ + {"key": "a", "value": "new"}, + {"key": "d", "value": "dddd"} + ], + "overwrite": false, + "database": "mixed_kv" + }) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_set_many attached guard batch", + &result, + "mixed_kv", + &[ + "created", + "resolved_database", + "skipped", + "stored", + "total_bytes", + ], + ) { + if payload["stored"] != serde_json::json!(1) + || payload["created"] != serde_json::json!(1) + || payload["skipped"] != serde_json::json!(1) + || payload["total_bytes"] != serde_json::json!(7) + { + failures.push(format!( + "kv_set_many attached guard batch: legacy guard shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_list attached populated keys", + "kv_list", + serde_json::json!({"store": "routed", "database": "MiXeD_Kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_list attached populated keys", + &result, + "mixed_kv", + &["count", "keys", "resolved_database", "store"], + ) { + if payload["count"] != serde_json::json!(4) + || payload["keys"] != serde_json::json!(["a", "b", "c", "d"]) + { + failures.push(format!( + "kv_list attached populated keys: sorted keys changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_list attached populated entries", + "kv_list", + serde_json::json!({"store": "routed", "values": true, "database": "MIXED_KV"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_list attached populated entries", + &result, + "mixed_kv", + &["entries", "resolved_database", "store"], + ) { + if payload["entries"] + != serde_json::json!([ + {"key": "a", "value": "A"}, + {"key": "b", "value": "bbb"}, + {"key": "c", "value": "ccc"}, + {"key": "d", "value": "dddd"} + ]) + { + failures.push(format!( + "kv_list attached populated entries: entry ordering/values changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_list_stores attached populated database", + "kv_list_stores", + serde_json::json!({"database": "mixed_kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_list_stores attached populated database", + &result, + "mixed_kv", + &["count", "resolved_database", "stores"], + ) { + if payload["count"] != serde_json::json!(1) + || payload["stores"] != serde_json::json!(["routed"]) + { + failures.push(format!( + "kv_list_stores attached populated database: legacy store list changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_size attached populated store", + "kv_size", + serde_json::json!({"store": "routed", "database": "MiXeD_Kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_size attached populated store", + &result, + "mixed_kv", + &["bytes", "resolved_database", "size", "store"], + ) { + if payload["size"] != serde_json::json!(4) || payload["bytes"] != serde_json::json!(11) + { + failures.push(format!( + "kv_size attached populated store: count/bytes changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_pop attached found", + "kv_pop", + serde_json::json!({"store": "routed", "database": "mixed_kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_pop attached found", + &result, + "mixed_kv", + &["found", "key", "resolved_database", "value"], + ) { + if payload["found"] != serde_json::json!(true) + || payload["key"] != serde_json::json!("a") + || payload["value"] != serde_json::json!("A") + { + failures.push(format!( + "kv_pop attached found: legacy pop ordering/value changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_delete attached found", + "kv_delete", + serde_json::json!({"store": "routed", "key": "b", "database": "MIXED_KV"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_delete attached found", + &result, + "mixed_kv", + &["deleted", "key", "resolved_database", "store"], + ) { + if payload["deleted"] != serde_json::json!(true) + || payload["store"] != serde_json::json!("routed") + || payload["key"] != serde_json::json!("b") + { + failures.push(format!( + "kv_delete attached found: legacy delete shape changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_clear attached populated store", + "kv_clear", + serde_json::json!({"store": "routed", "database": "mixed_kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_clear attached populated store", + &result, + "mixed_kv", + &["removed", "resolved_database", "store"], + ) { + if payload["removed"] != serde_json::json!(2) + || payload["store"] != serde_json::json!("routed") + { + failures.push(format!( + "kv_clear attached populated store: removed count changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_pop attached empty after clear", + "kv_pop", + serde_json::json!({"store": "routed", "database": "MiXeD_Kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_pop attached empty after clear", + &result, + "mixed_kv", + &["found", "resolved_database"], + ) { + if payload["found"] != serde_json::json!(false) { + failures.push(format!( + "kv_pop attached empty after clear: empty branch changed: {payload}" + )); + } + } + } + + if let Some(result) = call_case!( + "kv_clear attached idempotent empty", + "kv_clear", + serde_json::json!({"store": "routed", "database": "mixed_kv"}) + ) { + if let Some(payload) = record_kv_response( + &mut failures, + "kv_clear attached idempotent empty", + &result, + "mixed_kv", + &["removed", "resolved_database", "store"], + ) { + if payload["removed"] != serde_json::json!(0) { + failures.push(format!( + "kv_clear attached idempotent empty: idempotent branch changed: {payload}" + )); + } + } + } + + if let Err(error) = h.shutdown().await { + failures.push(format!("test harness shutdown failed: {error}")); + } + assert!( + failures.is_empty(), + "resolved_database KV success-shape regressions:\n- {}", + failures.join("\n- ") + ); + Ok(()) +} diff --git a/hyperdb-mcp/tests/readme_tests.rs b/hyperdb-mcp/tests/readme_tests.rs index cea64dd..f3f23ec 100644 --- a/hyperdb-mcp/tests/readme_tests.rs +++ b/hyperdb-mcp/tests/readme_tests.rs @@ -11,6 +11,59 @@ use hyperdb_mcp::readme::README; +const PUBLIC_README: &str = include_str!("../README.md"); +const SMOKE_TESTS: &str = include_str!("../SMOKE_TESTS.md"); +const DEMO: &str = include_str!("../examples/demo.rs"); +const CHANGELOG: &str = include_str!("../CHANGELOG.md"); +const DEVELOPMENT: &str = include_str!("../DEVELOPMENT.md"); +const LIB_SOURCE: &str = include_str!("../src/lib.rs"); +const SERVER_SOURCE: &str = include_str!("../src/server.rs"); + +fn markdown_section<'a>(text: &'a str, heading: &str, next_heading: &str) -> &'a str { + let Some((_, after_heading)) = text.split_once(heading) else { + return ""; + }; + let Some((section, _)) = after_heading.split_once(next_heading) else { + return after_heading; + }; + section +} + +fn contains_any(text: &str, alternatives: &[&str]) -> bool { + alternatives + .iter() + .any(|candidate| text.contains(candidate)) +} + +fn markdown_bullet(text: &str, needle: &str) -> String { + let mut lines = Vec::new(); + let mut capturing = false; + for line in text.lines() { + if line.trim_start().starts_with("- ") { + if capturing { + break; + } + capturing = line.contains(needle); + } + if capturing { + lines.push(line); + } + } + lines.join("\n") +} + +fn exact_smoke_response_lines<'a>(text: &'a str, tool: &str) -> Vec<&'a str> { + text.lines() + .filter_map(|line| { + let (command, response) = line.split_once('→')?; + (command.split_whitespace().next() == Some(tool) + && response.contains('{') + && !response.contains("...")) + .then_some(line) + }) + .collect() +} + #[test] fn readme_is_non_trivial() { assert!( @@ -84,3 +137,843 @@ fn readme_includes_sql_dialect_pointers() { "README should identify the underlying Hyper engine" ); } + +#[test] +fn doctor_readme_contract() { + fn command_on_line(line: &str) -> &str { + let line = line.trim().strip_prefix("$ ").unwrap_or(line.trim()); + line.split_once(" #") + .map_or(line, |(command, _comment)| command.trim_end()) + } + + fn has_exact_command(readme: &str, command: &str) -> bool { + readme.lines().any(|line| command_on_line(line) == command) + } + + assert!( + has_exact_command(PUBLIC_README, "hyperdb-mcp doctor"), + "public README must show the exact human-report command `hyperdb-mcp doctor`" + ); + assert!( + has_exact_command(PUBLIC_README, "hyperdb-mcp doctor --json"), + "public README must show the exact machine-report command `hyperdb-mcp doctor --json`" + ); + + let lines: Vec<_> = PUBLIC_README.lines().collect(); + let command_line = lines + .iter() + .position(|line| command_on_line(line) == "hyperdb-mcp doctor --json") + .expect("exact doctor --json command was asserted above"); + let start = command_line.saturating_sub(12); + let end = (command_line + 21).min(lines.len()); + let doctor_scope = lines[start..end].join("\n").to_lowercase(); + + assert!( + doctor_scope.contains("side-effect-free") || doctor_scope.contains("side effect free"), + "doctor documentation must state that collection is side-effect-free:\n{doctor_scope}" + ); + assert!( + [ + "does not start", + "doesn't start", + "without starting", + "never starts", + "will not start", + ] + .iter() + .any(|phrase| doctor_scope.contains(phrase)) + && (doctor_scope.contains("daemon") || doctor_scope.contains("hyperd")) + && doctor_scope.contains("database"), + "doctor documentation must say it starts neither a daemon nor a database:\n{doctor_scope}" + ); + assert!( + doctor_scope.contains("local paths") + && doctor_scope.contains("review") + && doctor_scope.contains("shar"), + "doctor documentation must warn users to review local paths before sharing:\n{doctor_scope}" + ); +} + +/// `status` can return a deliberately partial response while another tool +/// owns the engine mutex. The LLM-facing README must prevent clients from +/// treating that fallback as definitive and tell them how to obtain full data. +#[test] +fn readme_degraded_status_contract() { + assert!( + README.contains("engine_busy: true"), + "README must name the degraded-status signal" + ); + assert!( + README.contains("partial"), + "README must label engine_busy status as partial/non-definitive" + ); + assert!( + README.contains("hyperd_running: false"), + "README must document the degraded hyperd_running value" + ); + assert!( + README.contains("inconclusive") || README.contains("non-definitive"), + "README must say degraded hyperd_running: false is inconclusive" + ); + assert!( + README.contains("retry"), + "README must guide clients to retry status for full statistics" + ); +} + +/// The concise, LLM-facing README must be sufficient to choose the three chart +/// presentation controls without guessing at defaults or label sizing. +#[test] +fn readme_chart_presentation_contract() { + fn surrounding_lines(text: &str, needle: &str, radius: usize) -> String { + let lines: Vec<_> = text.lines().collect(); + let Some(index) = lines.iter().position(|line| line.contains(needle)) else { + return String::new(); + }; + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(lines.len()); + lines[start..end].join("\n").to_lowercase() + } + + let orientation = surrounding_lines(README, "bar_orientation", 4); + let values = surrounding_lines(README, "label_values", 4); + let legend = surrounding_lines(README, "show_legend", 4); + let chart = surrounding_lines(README, "`chart`", 20); + let mut failures: Vec = Vec::new(); + + if !(orientation.contains("bar_orientation") + && orientation.contains("vertical") + && orientation.contains("horizontal")) + { + failures.push( + "README must name bar_orientation and both vertical/horizontal choices".to_string(), + ); + } + if !(values.contains("label_values") + && values.contains("value") + && ["original", "exact", "verbatim"] + .iter() + .any(|word| values.contains(word))) + { + failures.push("README must say label_values uses the original/exact scalar".to_string()); + } + if !(legend.contains("show_legend") + && legend.contains("default") + && legend.contains("true") + && ["false", "suppress", "hide"] + .iter() + .any(|word| legend.contains(word))) + { + failures + .push("README must document show_legend=true by default and suppression".to_string()); + } + for required in [ + "long", "unicode", "truncat", "auto", "siz", "clip", "width", "height", + ] { + if !chart.contains(required) { + failures.push(format!( + "README chart layout caveat is missing semantic token {required:?}" + )); + } + } + + assert!( + failures.is_empty(), + "chart presentation README failures:\n{}", + failures.join("\n") + ); +} + +/// The LLM-facing README must make the positive-only logarithmic contract +/// actionable without inviting unsupported x-log/symlog/histogram guesses. +#[test] +fn readme_chart_log_contract() { + fn surrounding_lines(text: &str, needle: &str, radius: usize) -> String { + let lines: Vec<_> = text.lines().collect(); + let Some(index) = lines.iter().position(|line| line.contains(needle)) else { + return String::new(); + }; + let start = index.saturating_sub(radius); + let end = (index + radius + 1).min(lines.len()); + lines[start..end].join("\n").to_lowercase() + } + + let scale = surrounding_lines(README, "y_scale", 8); + let chart = surrounding_lines(README, "`chart`", 28); + let mut failures: Vec = Vec::new(); + + if !(scale.contains("y_scale") + && scale.contains("linear") + && scale.contains("log") + && scale.contains("default")) + { + failures.push("README must document y_scale with linear default and log choice".into()); + } + if !(scale.contains("positive") + && (scale.contains("zero") || scale.contains("> 0")) + && scale.contains("negative")) + { + failures.push("README must state that log values/ranges are strictly positive".into()); + } + if !(scale.contains("horizontal") && scale.contains('y') && scale.contains("measure")) { + failures.push( + "README must tie y_scale to the data-role y measure even for horizontal bars".into(), + ); + } + if !scale.contains("histogram") { + failures.push("README must say logarithmic histograms are unsupported".into()); + } + if !(scale.contains("range") + && ["contain", "enclos", "include"] + .iter() + .any(|word| scale.contains(word)) + && scale.contains("value")) + { + failures.push("README must require explicit log ranges to contain every value".into()); + } + if !(chart.contains("bar") + && chart.contains("lower") + && chart.contains("bound") + && ["never zero", "not zero", "instead of zero"] + .iter() + .any(|phrase| chart.contains(phrase))) + { + failures + .push("README must say log bars start at the positive lower bound, not zero".into()); + } + + assert!( + failures.is_empty(), + "chart log README failures:\n{}", + failures.join("\n") + ); +} + +/// The two user-facing READMEs must describe database routing and read-only +/// behavior using the same vocabulary and the tool handlers' actual guards. +/// This catches edits to the public/concise documentation, examples, and +/// static CLI reference, plus constructor rustdoc for removed workspace/bare +/// arguments, that drift from the generated tools or runtime. +#[test] +fn public_docs_database_and_read_only_contract() { + const GUARDED_TOOLS: &[&str] = &[ + "execute", + "load_data", + "load_file", + "load_files", + "load_iceberg", + "watch_directory", + "save_query", + "delete_query", + "set_table_metadata", + "copy_query", + "kv_set", + "kv_set_many", + "kv_delete", + "kv_pop", + "kv_clear", + ]; + + let public = PUBLIC_README.to_lowercase(); + let concise = README.to_lowercase(); + let public_read_only = markdown_section(&public, "## read-only mode", "\n---"); + let public_allowed = markdown_section(public_read_only, "**allowed:**", "- **blocked:**"); + let public_blocked = markdown_section(public_read_only, "**blocked:**", "- **resources"); + let concise_rules = markdown_section(&concise, "## parameter rules", "## sql dialect"); + let public_chart = markdown_section(&public, "#### `chart`", "### incremental ingest"); + let concise_chart = markdown_section( + &concise, + "### chart delivery and presentation", + "every successful database-routed", + ); + let concise_examples = markdown_section(&concise, "## examples", "## tips for picking"); + let attach_example = markdown_section( + concise_examples, + "// cross-database join via attachment", + "// read parquet", + ); + let chart_example = markdown_section(concise_examples, "// chart", "\n```"); + let public_kv = markdown_section(&public, "### key-value scratchpad", "### export tools"); + let concise_kv = markdown_section( + &concise, + "### key-value store (scratchpad)", + "**querying json", + ); + let public_export = markdown_section(&public, "#### `export`", "### visualization"); + let concise_export = markdown_section(&concise, "### export", "### saved queries"); + let public_cli = markdown_section(&public, "## cli reference", "\n---"); + let concise_status = markdown_bullet( + markdown_section(&concise, "### inspect", "### export"), + "`status`", + ); + let lib_source = LIB_SOURCE.to_lowercase(); + let engine_crate_doc = markdown_section(&lib_source, "- [`engine`]", "- [`ingest`]"); + let development = DEVELOPMENT.to_lowercase(); + let development_prerequisites = + markdown_section(&development, "### prerequisites", "### build"); + let mut failures = Vec::new(); + + if !concise_status.contains("daemon/hyper connection facts") { + failures.push( + "get_readme status guidance must describe daemon/Hyper connection facts".to_owned(), + ); + } + if concise_status.contains("daemon identity") { + failures.push( + "get_readme status guidance must not overclaim that status reports daemon identity" + .to_owned(), + ); + } + + if !(engine_crate_doc.contains("local database") + && engine_crate_doc.contains("persistent database")) + { + failures.push( + "crate-level engine documentation must use local and persistent database terminology" + .to_owned(), + ); + } + if engine_crate_doc.contains("persistent workspace modes") { + failures.push( + "crate-level engine documentation must not claim persistent workspace modes".to_owned(), + ); + } + + if !(development_prerequisites.contains("hyperd_path") + && development_prerequisites.contains(".hyperd/current") + && contains_any( + development_prerequisites, + &["walk upward", "search upward", "ancestor"], + )) + { + failures.push( + "DEVELOPMENT prerequisites must document HYPERD_PATH and upward .hyperd/current discovery" + .to_owned(), + ); + } + if contains_any( + development_prerequisites, + &["place on `path`", "searches path", "path fallback"], + ) { + failures.push("DEVELOPMENT prerequisites must not claim PATH lookup".to_owned()); + } + + for (name, document) in [ + ("public README", public.as_str()), + ("get_readme", concise.as_str()), + ] { + if !(document.contains("resource_busy") + && document.contains("hyperdb-mcp doctor") + && contains_any( + document, + &["possible owner", "holding process", "other process"], + ) + && document.contains("copy")) + { + failures.push(format!( + "{name} must explain contextual RESOURCE_BUSY recovery via doctor, the possible owner, and copying the file" + )); + } + if !(document.contains("resolved_database") + && contains_any(document, &["success", "successful"]) + && document.contains("local") + && document.contains("persistent") + && document.contains("attached")) + { + failures.push(format!( + "{name} must explain resolved_database on routed successes using local/persistent/attached terminology" + )); + } + + let stale_workspace_lines: Vec<_> = document + .lines() + .enumerate() + .filter_map(|(index, line)| { + if !line.contains("workspace") + || line.contains("--workspace") + || line.contains("workspace.hyper") + || line.contains("hyper://") + || line.contains("resource") + || line.contains(" uri") + || line.contains("histor") + { + None + } else { + Some(format!("{}: {}", index + 1, line.trim())) + } + }) + .collect(); + if !stale_workspace_lines.is_empty() { + failures.push(format!( + "{name} uses workspace as current database terminology outside compatibility/resource/history contexts:\n{}", + stale_workspace_lines.join("\n") + )); + } + } + + if !(attach_example.contains("attach_database({") + && attach_example.contains("\"kind\": \"local_file\"") + && attach_example.contains("lookup.public.dim_region")) + { + failures.push( + "get_readme attach example must supply kind=local_file and use the runnable lookup.public.dim_region qualification" + .to_owned(), + ); + } + if !(chart_example.contains("chart({") + && chart_example.contains("\"chart_type\": \"bar\"") + && chart_example.contains("\"x\":") + && chart_example.contains("\"y\":")) + { + failures.push( + "get_readme bar-chart example must supply the required x and y columns".to_owned(), + ); + } + + for tool in GUARDED_TOOLS { + if !public_blocked.contains(tool) { + failures.push(format!( + "public README blocked list is missing guarded tool {tool}" + )); + } + if !concise_rules.contains(tool) { + failures.push(format!( + "get_readme read-only rules are missing guarded tool {tool}" + )); + } + } + for (name, section) in [ + ("public README", public_read_only), + ("get_readme", concise_rules), + ] { + if !(section.contains("attach_database") && section.contains("writable")) { + failures.push(format!( + "{name} must say writable attach_database is guarded while read-only attachment remains available" + )); + } + } + if !(public_allowed.contains("unwatch_directory") + && public_allowed.contains("export") + && public_allowed.contains("hyper")) + { + failures.push( + "public README must list unwatch_directory and Hyper-format export as allowed" + .to_owned(), + ); + } + if public_blocked.contains("unwatch_directory") || public_blocked.contains("export") { + failures.push( + "public README must not list unwatch_directory or export among blocked tools" + .to_owned(), + ); + } + if !(contains_any( + concise_rules, + &[ + "unwatch_directory remains allowed", + "unwatch_directory stays allowed", + "unwatch_directory is allowed", + ], + ) && concise_rules.contains("export") + && concise_rules.contains("hyper") + && contains_any( + concise_rules, + &["remain allowed", "stays allowed", "always work"], + )) + { + failures.push( + "get_readme must explicitly keep unwatch_directory and Hyper-format export allowed" + .to_owned(), + ); + } + + for (name, document) in [ + ("public README", public.as_str()), + ("get_readme", concise.as_str()), + ] { + for token in [ + "quick diagnostic", + "output_path", + "inline", + "png", + "svg", + "bar_orientation", + "label_values", + "show_legend", + "y_scale", + "proportional", + "x_as_category", + ] { + if !document.contains(token) { + failures.push(format!("{name} chart guidance is missing {token:?}")); + } + } + } + + for parameter in ["database", "color_map", "label_points"] { + if !public_chart.contains(&format!("`{parameter}`")) { + failures.push(format!( + "public README chart parameter table is missing `{parameter}`" + )); + } + if !concise_chart.contains(parameter) { + failures.push(format!( + "get_readme chart guidance is missing `{parameter}`" + )); + } + } + for parameter in ["overwrite", "x_range", "y_range"] { + if !concise_chart.contains(parameter) { + failures.push(format!( + "get_readme chart delivery/range guidance is missing `{parameter}`" + )); + } + } + + for (name, kv_section) in [("public README", public_kv), ("get_readme", concise_kv)] { + if !(kv_section.contains("attached") + && kv_section.contains("writable") + && contains_any( + kv_section, + &["even for readers", "including readers", "all kv_"], + )) + { + failures.push(format!( + "{name} must say all attached KV targets require writable access, including readers" + )); + } + } + + for (name, export_section) in [ + ( + "public README", + format!("{public_export}\n{public_read_only}"), + ), + ("get_readme", format!("{concise_export}\n{concise_rules}")), + ] { + if !(export_section.contains("source") + && contains_any( + &export_section, + &[ + "not mutate", + "does not mutate", + "leaves the source", + "source unchanged", + ], + ) + && export_section.contains("destination") + && contains_any( + &export_section, + &["create", "replace", "materializ", "write"], + )) + { + failures.push(format!( + "{name} must explain that Hyper export leaves its source unchanged but creates/replaces a materialized destination" + )); + } + for false_claim in ["read-only file copy", "only read database contents"] { + if export_section.contains(false_claim) { + failures.push(format!( + "{name} must not describe Hyper export as {false_claim:?}" + )); + } + } + } + + let daemon_command = public_cli + .lines() + .find(|line| line.trim_start().starts_with("daemon ")) + .unwrap_or(""); + if !daemon_command.contains("foreground") || daemon_command.contains("background") { + failures.push( + "public CLI command summary must describe `daemon` as foreground, not background" + .to_owned(), + ); + } + if !(public_cli.contains("hyperdb_daemon_port") + && public_cli.contains("auto-spawn") + && public_cli.contains("configured/base") + && public_cli.contains("exact") + && contains_any(public_cli, &["pin", "candidate", "discovery"])) + { + failures.push( + "public CLI reference must distinguish HYPERDB_DAEMON_PORT auto-spawn discovery from the foreground configured/base exact bind" + .to_owned(), + ); + } + + let constructor_doc = SERVER_SOURCE + .split_once(" pub fn new(persistent_path: Option, read_only: bool) -> Self") + .and_then(|(before_signature, _)| { + before_signature + .rfind(" /// Create a server instance.") + .map(|start| &before_signature[start..]) + }) + .unwrap_or("") + .to_lowercase(); + if !(constructor_doc.contains("local database") + && constructor_doc.contains("persistent database")) + { + failures.push( + "HyperMcpServer::new rustdoc must describe the simultaneous local and optional persistent databases" + .to_owned(), + ); + } + for stale_term in [ + "persistent workspace", + "ephemeral workspace", + "workspace mode", + "`bare`", + "`workspace_path`", + ] { + if constructor_doc.contains(stale_term) { + failures.push(format!( + "HyperMcpServer::new rustdoc still mentions stale term {stale_term:?}" + )); + } + } + + assert!( + failures.is_empty(), + "public database/read-only documentation failures:\n- {}", + failures.join("\n- ") + ); +} + +/// The executable smoke guide, demo commentary, and unreleased changelog must +/// describe the surfaces added or corrected in this release candidate. +/// This catches mutations to the smoke sequence/result examples and release +/// note claims that omit mandatory KV response fields or no longer match KV +/// routing or Hyper-export side effects. +#[test] +fn smoke_demo_and_changelog_contract() { + let smoke = SMOKE_TESTS.to_lowercase(); + let demo = DEMO.to_lowercase(); + let unreleased = + markdown_section(&CHANGELOG.to_lowercase(), "## [unreleased]", "\n## [").to_owned(); + let batch_section = markdown_section( + &smoke, + "## 2. create / read / overwrite (upsert)", + "## 3. listing, size, store discovery", + ); + let listing_section = markdown_section( + &smoke, + "## 3. listing, size, store discovery", + "## 4. value fidelity", + ); + let routing_section = markdown_section( + &smoke, + "## 8. database routing + isolation", + "## 9. the `left join` enrichment pattern", + ); + let batch_dense: String = batch_section + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + let listing_dense: String = listing_section + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + let mut failures = Vec::new(); + + for token in ["kv_set_many", "resolved_database"] { + if !smoke.contains(token) { + failures.push(format!( + "smoke guide is missing current tool/result token {token:?}" + )); + } + } + let batch_store = batch_section + .lines() + .find(|line| line.contains("kv_set_many")) + .and_then(|line| { + line.split_whitespace() + .find_map(|word| word.strip_prefix("store=")) + }) + .unwrap_or(""); + if batch_store.is_empty() || batch_store == "smoke" { + failures.push( + "kv_set_many smoke example must use a dedicated store so later smoke counts remain exact" + .to_owned(), + ); + } else { + if !(batch_section.contains("kv_list") + && batch_section.contains(&format!("store={batch_store}")) + && batch_dense.contains("\"count\":2") + && batch_dense.contains("\"keys\":[\"batch_a\",\"batch_b\"]")) + { + failures.push( + "dedicated kv_set_many smoke store must be listed with count=2 and batch_a/b in lexicographic order" + .to_owned(), + ); + } + if !batch_section.contains(&format!("kv_clear store={batch_store}")) { + failures.push( + "dedicated kv_set_many smoke store must be cleared before later store-count checks" + .to_owned(), + ); + } + } + if !(batch_dense.contains("\"stored\":2") + && batch_dense.contains("\"created\":2") + && batch_dense.contains("\"overwritten\":0") + && listing_dense.contains("\"count\":4") + && listing_dense.contains("[\"alpha\",\"bravo\",\"charlie\",\"greeting\"]")) + { + failures.push( + "smoke batch/list examples must preserve exact counts and lexicographic key order" + .to_owned(), + ); + } + let exact_kv_set_responses = exact_smoke_response_lines(&smoke, "kv_set"); + if exact_kv_set_responses.len() != 1 { + failures.push(format!( + "smoke guide must have one unmarked exact kv_set response, found {}", + exact_kv_set_responses.len() + )); + } + for response in exact_kv_set_responses { + for field in ["\"created\"", "\"value_bytes\""] { + if !response.contains(field) { + failures.push(format!( + "unmarked exact kv_set response must include mandatory {field}: {}", + response.trim() + )); + } + } + } + let exact_kv_size_responses = exact_smoke_response_lines(&smoke, "kv_size"); + if exact_kv_size_responses.len() < 5 { + failures.push(format!( + "smoke guide must retain at least five unmarked exact kv_size responses, found {}", + exact_kv_size_responses.len() + )); + } + for response in exact_kv_size_responses { + if !response.contains("\"bytes\"") { + failures.push(format!( + "unmarked exact kv_size response must include mandatory bytes: {}", + response.trim() + )); + } + } + for line in routing_section.lines().filter(|line| line.contains("→ {")) { + if !line.contains("\"resolved_database\"") { + failures.push(format!( + "routed smoke expected JSON must include resolved_database or be labeled partial: {}", + line.trim() + )); + } + } + if !(smoke.contains("hyperd_path") + && smoke.contains(".hyperd/current") + && contains_any(&smoke, &["walk upward", "search upward", "ancestor"])) + { + failures.push( + "smoke guide must describe HYPERD_PATH executable/directory resolution and the upward .hyperd/current fallback" + .to_owned(), + ); + } + if contains_any( + &smoke, + &[ + "or on `path`", + "or on path", + "searches path", + "path fallback", + ], + ) { + failures.push("smoke guide must not claim the runtime searches PATH".to_owned()); + } + for token in [ + "hyperdb-mcp doctor", + "side-effect-free", + "engine_busy", + "inconclusive", + "resource_busy", + ] { + if !smoke.contains(token) { + failures.push(format!("smoke guide is missing diagnostic token {token:?}")); + } + } + + if demo.contains("instead of failing") && demo.contains("numeric parse") { + failures.push( + "demo still claims DATE chart axes need categorical mode to avoid numeric parsing" + .to_owned(), + ); + } + if !(demo.contains("date") + && demo.contains("proportional") + && contains_any(&demo, &["temporal axis", "time axis"])) + { + failures.push( + "demo must describe the proportional temporal-axis behavior for its DATE chart" + .to_owned(), + ); + } + + for heading in ["### added", "### fixed", "### changed"] { + let section = markdown_section(&unreleased, heading, "\n### "); + if section.is_empty() + || !section + .lines() + .any(|line| line.trim_start().starts_with("- ")) + { + failures.push(format!( + "crate ## [Unreleased] must contain at least one bullet under {heading}" + )); + } + } + for token in [ + "doctor", + "resolved_database", + "resource_busy", + "health port", + "engine_busy", + "bar_orientation", + "label_values", + "show_legend", + "y_scale", + ] { + if !unreleased.contains(token) { + failures.push(format!( + "crate ## [Unreleased] does not account for {token:?}" + )); + } + } + + let hyper_export = markdown_bullet(&unreleased, "hyper-format export"); + if !(hyper_export.contains("source") + && contains_any( + &hyper_export, + &[ + "not mutate", + "does not mutate", + "leaves the source", + "source unchanged", + ], + ) + && hyper_export.contains("destination") + && contains_any(&hyper_export, &["create", "replace", "materializ", "write"])) + { + failures.push( + "crate changelog Hyper-export note must distinguish unchanged source from created/replaced materialized destination" + .to_owned(), + ); + } + if contains_any( + &hyper_export, + &["read-only file copy", "harmless read-only file copy"], + ) { + failures.push( + "crate changelog must not call Hyper export a harmless read-only file copy".to_owned(), + ); + } + + assert!( + failures.is_empty(), + "smoke/demo/changelog documentation failures:\n- {}", + failures.join("\n- ") + ); +} diff --git a/hyperdb-mcp/tests/recovery_tests.rs b/hyperdb-mcp/tests/recovery_tests.rs index 74dcfee..fb573a0 100644 --- a/hyperdb-mcp/tests/recovery_tests.rs +++ b/hyperdb-mcp/tests/recovery_tests.rs @@ -7,8 +7,30 @@ mod common; +use std::io::{BufRead as _, BufReader, Write as _}; +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::sync::{mpsc, Arc, Mutex, OnceLock, TryLockError}; +use std::thread; +use std::time::{Duration, Instant}; + use common::TestEngine; +use hyperdb_api::{HyperProcess, Parameters, TransportMode}; +use hyperdb_mcp::daemon::discovery::{self, DaemonInfo}; +use hyperdb_mcp::daemon::health; use hyperdb_mcp::error::{is_connection_lost, ErrorCode}; +use hyperdb_mcp::server::HyperMcpServer; + +const SLOW_HEALTH_CHILD_ENV: &str = "HYPERDB_MCP_SLOW_HEALTH_CHILD"; +const SLOW_HEALTH_CHILD_MODE_ENV: &str = "HYPERDB_MCP_SLOW_HEALTH_CHILD_MODE"; +const SLOW_HEALTH_HYPER_PID_PATH_ENV: &str = "HYPERDB_MCP_SLOW_HEALTH_HYPER_PID_PATH"; +const SLOW_HEALTH_TEST_NAME: &str = "slow_health_report_does_not_hold_engine_mutex"; +const WATCHDOG_FAILURE_TEST_NAME: &str = "slow_health_watchdog_reaps_hyperd_after_child_failure"; +const WATCHDOG_TIMEOUT_TEST_NAME: &str = "slow_health_watchdog_reaps_hyperd_after_child_timeout"; +const CHILD_MODE_REGRESSION: &str = "regression"; +const CHILD_MODE_FAIL_AFTER_HYPER_START: &str = "fail_after_hyper_start"; +const CHILD_MODE_HANG_AFTER_HYPER_START: &str = "hang_after_hyper_start"; /// After creating a table and inserting rows, `sample_table` must return the /// rows — not `TABLE_NOT_FOUND`. The old implementation used `has_table` with @@ -72,3 +94,835 @@ fn connection_lost_classifier_ignores_sql_errors() { )); assert!(!is_connection_lost("")); } + +/// A slow daemon error report must not retain the server's engine mutex. +/// +/// This runs the real reconnect/error-report path in a bounded self-child: +/// a real TCP `HyperProcess` supplies the data plane while a controlled, +/// OS-assigned listener supplies the daemon health plane. The listener holds +/// the `REPORT_HYPERD_ERROR` response until the test releases it. Once that +/// report is observed, a competing caller must acquire the public engine +/// handle before the report is released. +#[test] +fn slow_health_report_does_not_hold_engine_mutex() { + match child_mode().as_deref() { + Some(CHILD_MODE_REGRESSION) => run_slow_health_mutex_child(), + Some(other) => panic!("unexpected child mode {other} for {SLOW_HEALTH_TEST_NAME}"), + None => { + let run = + run_exact_child_with_watchdog(SLOW_HEALTH_TEST_NAME, CHILD_MODE_REGRESSION, None) + .expect("run bounded slow-health regression child"); + assert_exact_child_executed_one_test(&run.output); + assert!( + !run.timed_out, + "slow-health regression child exceeded its 30s watchdog\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.output.stdout), + String::from_utf8_lossy(&run.output.stderr) + ); + assert!( + run.output.status.success(), + "slow-health child failed with {}; {}\nchild stdout:\n{}\nchild stderr:\n{}", + run.output.status, + run.cleanup, + String::from_utf8_lossy(&run.output.stdout), + String::from_utf8_lossy(&run.output.stderr) + ); + } + } +} + +#[test] +fn slow_health_watchdog_reaps_hyperd_after_child_failure() { + match child_mode().as_deref() { + Some(CHILD_MODE_FAIL_AFTER_HYPER_START) => { + run_fault_child(CHILD_MODE_FAIL_AFTER_HYPER_START) + } + Some(other) => panic!("unexpected child mode {other} for {WATCHDOG_FAILURE_TEST_NAME}"), + None => {} + } + let run = run_exact_child_with_watchdog( + WATCHDOG_FAILURE_TEST_NAME, + CHILD_MODE_FAIL_AFTER_HYPER_START, + None, + ) + .expect("run intentional failing child through watchdog"); + assert_exact_child_executed_one_test(&run.output); + assert!(!run.timed_out, "intentional failing child must exit itself"); + assert_eq!( + run.output.status.code(), + Some(23), + "intentional failing child must preserve its sentinel exit code; {}\nstdout:\n{}\nstderr:\n{}", + run.cleanup, + String::from_utf8_lossy(&run.output.stdout), + String::from_utf8_lossy(&run.output.stderr) + ); +} + +#[test] +fn slow_health_watchdog_reaps_hyperd_after_child_timeout() { + match child_mode().as_deref() { + Some(CHILD_MODE_HANG_AFTER_HYPER_START) => { + run_fault_child(CHILD_MODE_HANG_AFTER_HYPER_START) + } + Some(other) => panic!("unexpected child mode {other} for {WATCHDOG_TIMEOUT_TEST_NAME}"), + None => {} + } + let run = run_exact_child_with_watchdog( + WATCHDOG_TIMEOUT_TEST_NAME, + CHILD_MODE_HANG_AFTER_HYPER_START, + Some(Duration::from_millis(250)), + ) + .expect("run intentional hanging child through watchdog"); + assert_exact_child_executed_one_test(&run.output); + assert!( + run.timed_out, + "intentional hanging child must take the watchdog timeout branch; {}\nstdout:\n{}\nstderr:\n{}", + run.cleanup, + String::from_utf8_lossy(&run.output.stdout), + String::from_utf8_lossy(&run.output.stderr) + ); +} + +fn child_mode() -> Option { + std::env::var_os(SLOW_HEALTH_CHILD_ENV)?; + std::env::var(SLOW_HEALTH_CHILD_MODE_ENV).ok() +} + +struct ChildRun { + output: Output, + timed_out: bool, + cleanup: HyperCleanup, +} + +#[derive(Debug)] +struct HyperCleanup { + pid: u32, + actively_terminated: bool, +} + +impl std::fmt::Display for HyperCleanup { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "reported Hyper PID {} stopped (active termination: {})", + self.pid, self.actively_terminated + ) + } +} + +fn run_exact_child_with_watchdog( + test_name: &str, + mode: &str, + timeout_after_pid_report: Option, +) -> Result { + let temp = tempfile::TempDir::new().expect("create isolated child directory"); + let state_dir = temp.path().join("state"); + let process_temp_dir = temp.path().join("tmp"); + let hyper_pid_path = temp.path().join("hyperd.pid"); + std::fs::create_dir_all(&state_dir) + .map_err(|error| format!("create isolated child state: {error}"))?; + std::fs::create_dir_all(&process_temp_dir) + .map_err(|error| format!("create isolated child temp: {error}"))?; + + let mut command = Command::new( + std::env::current_exe().map_err(|error| format!("locate recovery test binary: {error}"))?, + ); + command + .arg("--exact") + .arg(test_name) + .arg("--nocapture") + .current_dir(temp.path()) + .env(SLOW_HEALTH_CHILD_ENV, "1") + .env(SLOW_HEALTH_CHILD_MODE_ENV, mode) + .env(SLOW_HEALTH_HYPER_PID_PATH_ENV, &hyper_pid_path) + .env("HOME", &state_dir) + .env("USERPROFILE", &state_dir) + .env("HYPERDB_STATE_DIR", &state_dir) + .env("TMPDIR", &process_temp_dir) + .env("TEMP", &process_temp_dir) + .env("TMP", &process_temp_dir) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_child_containment(&mut command); + let mut child = command + .spawn() + .map_err(|error| format!("spawn exact {test_name} child: {error}"))?; + + let overall_deadline = Instant::now() + Duration::from_secs(30); + let mut post_pid_deadline = None; + let timed_out = loop { + match child.try_wait() { + Ok(Some(_)) => break false, + Ok(None) => { + if post_pid_deadline.is_none() && hyper_pid_path.is_file() { + post_pid_deadline = + timeout_after_pid_report.map(|timeout| Instant::now() + timeout); + } + let deadline = post_pid_deadline.unwrap_or(overall_deadline); + if Instant::now() >= deadline { + break true; + } + thread::sleep(Duration::from_millis(10)); + } + Err(error) => { + let termination = terminate_child_tree(&mut child); + let output = child + .wait_with_output() + .map_err(|wait_error| format!("reap child after status error: {wait_error}"))?; + let cleanup = stop_reported_hyperd(&hyper_pid_path); + return Err(format!( + "{test_name} child status failed: {error}; termination={termination:?}; cleanup={cleanup:?}\nchild stdout:\n{}\nchild stderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )); + } + } + }; + + if timed_out { + terminate_child_tree(&mut child)?; + } + + let output = child + .wait_with_output() + .map_err(|error| format!("reap completed {test_name} child: {error}"))?; + // This runs for success, normal failure, and watchdog termination. A + // nonzero child can never bypass active cleanup of the exact reported PID. + let cleanup = stop_reported_hyperd(&hyper_pid_path)?; + Ok(ChildRun { + output, + timed_out, + cleanup, + }) +} + +fn assert_exact_child_executed_one_test(output: &Output) { + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("running 1 test"), + "exact child filter executed zero or multiple tests\nstdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn start_reported_hyper_process(state_dir: &Path, hyper_pid_path: &Path) -> (HyperProcess, String) { + let log_dir = state_dir.join("hyper-logs"); + std::fs::create_dir_all(&log_dir).expect("create isolated Hyper log directory"); + let mut parameters = Parameters::new(); + parameters.set_transport_mode(TransportMode::Tcp); + parameters.set("log_dir", log_dir.to_string_lossy().as_ref()); + let hyper = HyperProcess::new(None, Some(¶meters)).expect("start real TCP HyperProcess"); + assert_eq!(hyper.transport_mode(), TransportMode::Tcp); + let hyper_pid = hyper.pid().expect("HyperProcess must own a child PID"); + let endpoint = hyper + .require_endpoint() + .expect("TCP HyperProcess must publish an endpoint") + .to_string(); + std::fs::write(hyper_pid_path, hyper_pid.to_string()).expect("report exact Hyper PID"); + (hyper, endpoint) +} + +fn run_fault_child(mode: &str) -> ! { + let state_dir = std::env::var_os("HYPERDB_STATE_DIR") + .map(PathBuf::from) + .expect("parent must provide an isolated fault-child state directory"); + let hyper_pid_path = std::env::var_os(SLOW_HEALTH_HYPER_PID_PATH_ENV) + .map(PathBuf::from) + .expect("parent must provide a fault-child Hyper PID report path"); + let (hyper, _endpoint) = start_reported_hyper_process(&state_dir, &hyper_pid_path); + // Deliberately bypass RAII so these branches prove the parent watchdog's + // containment and exact-PID cleanup rather than HyperProcess::drop. + std::mem::forget(hyper); + match mode { + CHILD_MODE_FAIL_AFTER_HYPER_START => std::process::exit(23), + CHILD_MODE_HANG_AFTER_HYPER_START => loop { + thread::park_timeout(Duration::from_secs(60)); + }, + other => panic!("unsupported fault-child mode {other}"), + } +} + +#[cfg(unix)] +fn configure_child_containment(command: &mut Command) { + use std::os::unix::process::CommandExt as _; + + command.process_group(0); +} + +#[cfg(windows)] +fn configure_child_containment(command: &mut Command) { + use std::os::windows::process::CommandExt as _; + + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + command.creation_flags(CREATE_NEW_PROCESS_GROUP); +} + +#[cfg(not(any(unix, windows)))] +fn configure_child_containment(_command: &mut Command) {} + +#[cfg(unix)] +fn terminate_child_tree(child: &mut Child) -> Result<(), String> { + let process_group = i32::try_from(child.id()) + .map_err(|error| format!("child PID does not fit process-group ID: {error}"))?; + // SAFETY: the exact child was spawned into a new process group whose ID is + // its validated PID. A negative target addresses only that contained group. + let result = unsafe { libc::kill(-process_group, libc::SIGKILL) }; + if result != 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(libc::ESRCH) { + return Err(format!("kill contained child process group: {error}")); + } + } + let _ = child.kill(); + Ok(()) +} + +#[cfg(windows)] +fn terminate_child_tree(child: &mut Child) -> Result<(), String> { + let output = Command::new("taskkill") + .args(["/PID", &child.id().to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("run taskkill for contained child tree: {error}"))?; + if !output.status.success() { + return Err(format!( + "taskkill child tree exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + let _ = child.kill(); + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn terminate_child_tree(child: &mut Child) -> Result<(), String> { + child + .kill() + .map_err(|error| format!("kill timed-out child: {error}")) +} + +type EngineHandle = Arc>>; + +#[derive(Debug)] +struct ReportObservation { + sequence: usize, + engine_mutex_available: bool, +} + +fn run_slow_health_mutex_child() { + let state_dir = std::env::var_os("HYPERDB_STATE_DIR") + .map(PathBuf::from) + .expect("parent must provide an isolated daemon state directory"); + let hyper_pid_path = std::env::var_os(SLOW_HEALTH_HYPER_PID_PATH_ENV) + .map(PathBuf::from) + .expect("parent must provide a Hyper PID report path"); + let (hyper, endpoint) = start_reported_hyper_process(&state_dir, &hyper_pid_path); + + let listener = + TcpListener::bind(("127.0.0.1", 0)).expect("bind controlled OS-assigned health listener"); + let health_port = listener + .local_addr() + .expect("read controlled health listener address") + .port(); + + let daemon_info = DaemonInfo { + pid: std::process::id(), + hyperd_endpoint: endpoint, + health_port, + started_at: "2026-08-14T00:00:00Z".to_string(), + version: hyperdb_mcp::version::MCP_VERSION.to_string(), + }; + // Discovery is the only routing input: Task 5 must carry this effective + // health port through the engine and into the loss-report path. The child + // intentionally does not mutate the process-global daemon-port setting. + discovery::write_discovery_file(&daemon_info).expect("write isolated daemon discovery"); + + let engine_probe = Arc::new(OnceLock::::new()); + let (report_seen_tx, report_seen_rx) = mpsc::channel(); + let (report_release_tx, report_release_rx) = mpsc::channel(); + let peer_info = daemon_info.clone(); + let peer_engine_probe = Arc::clone(&engine_probe); + let peer = thread::spawn(move || { + run_controlled_health_peer( + &listener, + &peer_info, + &peer_engine_probe, + &report_seen_tx, + &report_release_rx, + ) + }); + + // A real protocol round-trip proves the listener is accepting and its + // STATUS response is usable; no timing sleep is needed for readiness. + let status = health::send_command_with_timeout( + health_port, + "STATUS", + Duration::from_secs(1), + Duration::from_secs(1), + ) + .expect("controlled health peer must answer STATUS"); + let status_info: DaemonInfo = + serde_json::from_str(status.trim()).expect("controlled STATUS must be DaemonInfo JSON"); + assert_eq!(status_info, daemon_info); + + let server = Arc::new(HyperMcpServer::with_no_daemon(None, false, false)); + server.warm_up_engine(); + assert!( + server + .resource_body_for_uri("hyper://workspace") + .expect("prime workspace resource through with_engine") + .is_some(), + "workspace resource must exist" + ); + let engine_handle = server.engine_handle(); + engine_probe + .set(Arc::clone(&engine_handle)) + .unwrap_or_else(|_| panic!("engine probe handle must be installed exactly once")); + assert_eq!( + engine_handle + .lock() + .expect("inspect warmed engine") + .as_ref() + .expect("warm-up must install an engine") + .daemon_health_port(), + Some(health_port), + "warmed engine must retain the discovered health port" + ); + + hyper + .shutdown_timeout(Duration::from_secs(5)) + .expect("shut down the real HyperProcess before inducing ConnectionLost"); + + let mut failures = Vec::new(); + let worker_server = Arc::clone(&server); + let loss_worker = thread::spawn(move || -> Result { + match worker_server.resource_body_for_uri("hyper://workspace") { + Err(error) => Ok(error.code), + Ok(value) => Err(format!( + "dead Hyper connection unexpectedly returned resource {value:?}" + )), + } + }); + + let first_report = + receive_and_release_report(&report_seen_rx, &report_release_tx, 1, &mut failures); + let loss_worker_result = loss_worker.join(); + if let Some(observation) = first_report { + if !observation.engine_mutex_available { + failures.push("engine mutex was unavailable at the first slow loss report".to_string()); + } + } + match loss_worker_result { + Ok(Ok(ErrorCode::ConnectionLost)) => {} + Ok(Ok(code)) => failures.push(format!( + "real workspace resource returned {code:?}, expected ConnectionLost" + )), + Ok(Err(error)) => failures.push(error), + Err(payload) => failures.push(format!("workspace loss worker panicked: {payload:?}")), + } + match engine_handle.try_lock() { + Ok(guard) if guard.is_none() => {} + Ok(guard) => failures.push(format!( + "connection loss must clear the engine before reinitialization; present={}", + guard.is_some() + )), + Err(error) => failures.push(format!( + "engine mutex unavailable after released first report: {error}" + )), + } + + // A second public call now takes the post-loss initialization path. The + // dead endpoint makes Engine::try_daemon_mode emit another slow report. + // The peer probes `try_lock` synchronously before releasing that response, + // so this cannot pass merely because a scheduler slept past the 200 ms I/O + // budget. Current production is red here because ensure_engine holds the + // engine mutex throughout Engine::new. + let reinit_server = Arc::clone(&server); + let reinit_worker = thread::spawn(move || -> Result { + match reinit_server.resource_body_for_uri("hyper://workspace") { + Err(error) => Ok(error.code), + Ok(value) => Err(format!( + "dead daemon endpoint unexpectedly reinitialized to resource {value:?}" + )), + } + }); + let second_report = + receive_and_release_report(&report_seen_rx, &report_release_tx, 2, &mut failures); + let reinit_worker_result = reinit_worker.join(); + if let Some(observation) = second_report { + if !observation.engine_mutex_available { + failures.push( + "engine mutex was held while post-loss Engine initialization waited on REPORT_HYPERD_ERROR" + .to_string(), + ); + } + } + match reinit_worker_result { + Ok(Ok(ErrorCode::InternalError)) => {} + Ok(Ok(code)) => failures.push(format!( + "post-loss initialization returned {code:?}, expected InternalError" + )), + Ok(Err(error)) => failures.push(error), + Err(payload) => failures.push(format!("workspace reinit worker panicked: {payload:?}")), + } + + let stop_result = health::send_command_with_timeout( + health_port, + "STOP", + Duration::from_secs(1), + Duration::from_secs(1), + ); + let peer_result = peer.join(); + + match stop_result { + Ok(response) if response.trim() == "STOPPING" => {} + Ok(response) => failures.push(format!( + "controlled health peer returned unexpected STOP response {response:?}" + )), + Err(error) => failures.push(format!("could not stop controlled health peer: {error}")), + } + match peer_result { + Ok(Ok(commands)) + if commands + .iter() + .filter(|command| command.as_str() == "REPORT_HYPERD_ERROR") + .count() + == 2 => {} + Ok(Ok(commands)) => failures.push(format!( + "controlled health peer must receive exactly two REPORT_HYPERD_ERROR commands: {commands:?}" + )), + Ok(Err(error)) => failures.push(error), + Err(payload) => failures.push(format!("controlled health peer panicked: {payload:?}")), + } + + assert!( + failures.is_empty(), + "slow health report mutex regression failures:\n{}", + failures.join("\n") + ); +} + +fn receive_and_release_report( + report_seen_rx: &mpsc::Receiver, + report_release_tx: &mpsc::Sender, + expected_sequence: usize, + failures: &mut Vec, +) -> Option { + let observation = match report_seen_rx.recv_timeout(Duration::from_secs(5)) { + Ok(observation) => Some(observation), + Err(error) => { + failures.push(format!( + "REPORT_HYPERD_ERROR #{expected_sequence} was not observed: {error}" + )); + None + } + }; + if let Some(observation) = &observation { + if observation.sequence != expected_sequence { + failures.push(format!( + "observed report sequence {}, expected {expected_sequence}", + observation.sequence + )); + } + if let Err(error) = report_release_tx.send(observation.sequence) { + failures.push(format!( + "could not release report #{} response: {error}", + observation.sequence + )); + } + } + observation +} + +fn run_controlled_health_peer( + listener: &TcpListener, + info: &DaemonInfo, + engine_probe: &OnceLock, + report_seen_tx: &mpsc::Sender, + report_release_rx: &mpsc::Receiver, +) -> Result, String> { + let mut commands = Vec::new(); + let mut report_sequence = 0_usize; + loop { + let (stream, _) = listener + .accept() + .map_err(|error| format!("accept controlled health connection: {error}"))?; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .map_err(|error| format!("bound controlled health read: {error}"))?; + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .map_err(|error| format!("bound controlled health write: {error}"))?; + + let mut reader = BufReader::new(&stream); + let mut command = String::new(); + reader + .read_line(&mut command) + .map_err(|error| format!("read controlled health command: {error}"))?; + let command = command.trim().to_string(); + commands.push(command.clone()); + + let (response, should_stop) = match command.as_str() { + "PING" => ( + format!( + "PONG {} {}\n", + health::PONG_TOKEN, + hyperdb_mcp::version::MCP_VERSION + ), + false, + ), + "STATUS" => ( + format!( + "{}\n", + serde_json::to_string(&info) + .map_err(|error| format!("serialize controlled STATUS: {error}"))? + ), + false, + ), + "HEARTBEAT" => ("OK\n".to_string(), false), + "REPORT_HYPERD_ERROR" => { + report_sequence += 1; + let engine_handle = engine_probe + .get() + .ok_or_else(|| "engine probe was not installed before report".to_string())?; + let engine_mutex_available = match engine_handle.try_lock() { + Ok(guard) => { + drop(guard); + true + } + Err(TryLockError::WouldBlock) => false, + Err(TryLockError::Poisoned(_)) => { + return Err("engine mutex was poisoned during report probe".to_string()); + } + }; + report_seen_tx + .send(ReportObservation { + sequence: report_sequence, + engine_mutex_available, + }) + .map_err(|error| format!("signal observed REPORT_HYPERD_ERROR: {error}"))?; + let released_sequence = report_release_rx + .recv_timeout(Duration::from_secs(5)) + .map_err(|error| format!("wait for controlled REPORT release: {error}"))?; + if released_sequence != report_sequence { + return Err(format!( + "released report #{released_sequence}, expected #{report_sequence}" + )); + } + ("OK\n".to_string(), false) + } + "STOP" => ("STOPPING\n".to_string(), true), + other => (format!("ERR unknown command {other}\n"), false), + }; + + (&stream) + .write_all(response.as_bytes()) + .map_err(|error| format!("write controlled health response: {error}"))?; + if should_stop { + return Ok(commands); + } + } +} + +fn stop_reported_hyperd(pid_path: &Path) -> Result { + let reported = std::fs::read_to_string(pid_path) + .map_err(|error| format!("Hyper PID was not reported: {error}"))?; + let pid = reported + .trim() + .parse::() + .map_err(|error| format!("reported Hyper PID {reported:?} was invalid: {error}"))?; + if pid == 0 || pid == std::process::id() { + return Err(format!("refusing unsafe reported Hyper PID {pid}")); + } + + let mut actively_terminated = false; + if process_is_alive(pid)? { + match validate_hyperd_process(pid) { + Ok(()) => { + terminate_reported_hyperd(pid)?; + actively_terminated = true; + } + Err(_identity_error) if !process_is_alive(pid)? => { + // The callback dead-man switch won the race between the first + // liveness check and process identity inspection. Nothing is + // left to validate or terminate. + } + Err(identity_error) => return Err(identity_error), + } + } + wait_for_process_exit(pid, Duration::from_secs(10))?; + Ok(HyperCleanup { + pid, + actively_terminated, + }) +} + +#[cfg(unix)] +fn process_is_alive(pid: u32) -> Result { + let native_pid = + i32::try_from(pid).map_err(|error| format!("PID does not fit pid_t: {error}"))?; + // SAFETY: signal 0 does not modify the process; it only checks existence + // and permission for the exact validated positive PID. + if unsafe { libc::kill(native_pid, 0) } != 0 { + let error = std::io::Error::last_os_error(); + return match error.raw_os_error() { + Some(libc::ESRCH) => Ok(false), + Some(libc::EPERM) => Ok(true), + _ => Err(format!("poll reported Hyper PID {pid}: {error}")), + }; + } + + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "stat="]) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("inspect state of reported Hyper PID {pid}: {error}"))?; + if !output.status.success() { + // The process may have exited between signal-0 and ps. Recheck once + // before treating an inspection failure as an actual cleanup defect. + // SAFETY: same exact, validated PID and non-mutating signal 0. + if unsafe { libc::kill(native_pid, 0) } != 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + return Ok(false); + } + return Err(format!("ps could not inspect reported Hyper PID {pid}")); + } + let state = String::from_utf8_lossy(&output.stdout); + // Zombies have terminated and cannot retain the database or consume CPU. + // They may remain visible until their current parent reaps them, so + // signal-0 alone is not a valid live-orphan check. + Ok(!state.trim_start().starts_with('Z')) +} + +#[cfg(windows)] +fn process_is_alive(pid: u32) -> Result { + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("run tasklist: {error}"))?; + if !output.status.success() { + return Err(format!("tasklist exited with {}", output.status)); + } + Ok(String::from_utf8_lossy(&output.stdout).contains(&format!("\"{pid}\""))) +} + +#[cfg(not(any(unix, windows)))] +fn process_is_alive(_pid: u32) -> Result { + Err("process liveness polling is unsupported on this platform".to_string()) +} + +#[cfg(unix)] +fn validate_hyperd_process(pid: u32) -> Result<(), String> { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("inspect reported Hyper PID {pid}: {error}"))?; + if !output.status.success() { + return Err(format!("ps could not inspect reported Hyper PID {pid}")); + } + let command = String::from_utf8_lossy(&output.stdout); + let executable = Path::new(command.trim()) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); + if executable != "hyperd" { + return Err(format!( + "refusing to terminate reported PID {pid}: process is {command:?}, not hyperd" + )); + } + Ok(()) +} + +#[cfg(windows)] +fn validate_hyperd_process(pid: u32) -> Result<(), String> { + let output = Command::new("tasklist") + .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"]) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("inspect reported Hyper PID {pid}: {error}"))?; + if !output.status.success() { + return Err(format!("tasklist exited with {}", output.status)); + } + let listing = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + if !listing.contains("hyperd.exe") || !listing.contains(&format!("\"{pid}\"")) { + return Err(format!( + "refusing to terminate reported PID {pid}: tasklist did not identify hyperd.exe" + )); + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn validate_hyperd_process(_pid: u32) -> Result<(), String> { + Err("Hyper process identity validation is unsupported on this platform".to_string()) +} + +#[cfg(unix)] +fn terminate_reported_hyperd(pid: u32) -> Result<(), String> { + signal_reported_pid(pid, libc::SIGTERM)?; + if wait_for_process_exit(pid, Duration::from_secs(1)).is_ok() { + return Ok(()); + } + signal_reported_pid(pid, libc::SIGKILL) +} + +#[cfg(unix)] +fn signal_reported_pid(pid: u32, signal: i32) -> Result<(), String> { + let pid = i32::try_from(pid).map_err(|error| format!("PID does not fit pid_t: {error}"))?; + // SAFETY: the PID was read from the private child report and its executable + // identity was validated immediately before this call. + if unsafe { libc::kill(pid, signal) } == 0 { + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(()) + } else { + Err(format!("signal reported Hyper PID {pid}: {error}")) + } +} + +#[cfg(windows)] +fn terminate_reported_hyperd(pid: u32) -> Result<(), String> { + let output = Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .stdin(Stdio::null()) + .output() + .map_err(|error| format!("terminate reported Hyper PID {pid}: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "taskkill reported Hyper PID {pid} exited {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )) + } +} + +#[cfg(not(any(unix, windows)))] +fn terminate_reported_hyperd(_pid: u32) -> Result<(), String> { + Err("Hyper process termination is unsupported on this platform".to_string()) +} + +fn wait_for_process_exit(pid: u32, timeout: Duration) -> Result<(), String> { + let deadline = Instant::now() + timeout; + loop { + if !process_is_alive(pid)? { + return Ok(()); + } + if Instant::now() >= deadline { + return Err(format!( + "reported Hyper PID {pid} remained alive after {timeout:?}" + )); + } + // Lifecycle cleanup polling only; behavior synchronization uses the + // report protocol and channels above. + thread::sleep(Duration::from_millis(20)); + } +} diff --git a/hyperdb-mcp/tests/resource_tests.rs b/hyperdb-mcp/tests/resource_tests.rs index 8eca170..3ff75ac 100644 --- a/hyperdb-mcp/tests/resource_tests.rs +++ b/hyperdb-mcp/tests/resource_tests.rs @@ -5,8 +5,94 @@ //! and content for workspace / tables / per-table schema resources. use hyperdb_mcp::server::HyperMcpServer; +use rmcp::model::{ + ClientInfo, ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, +}; +use rmcp::service::{RoleClient, RunningService}; +use rmcp::{ClientHandler, ServiceExt}; use tempfile::TempDir; +type TestResult = Result<(), Box>; + +#[derive(Debug, Clone)] +struct ResourceClientHandler; + +impl ClientHandler for ResourceClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +struct ResourceHarness { + client: RunningService, + server_handle: tokio::task::JoinHandle>>, +} + +impl ResourceHarness { + async fn start( + persistent_path: Option, + read_only: bool, + ) -> Result> { + let (server_io, client_io) = tokio::io::duplex(128 * 1024); + let server = HyperMcpServer::with_no_daemon(persistent_path, read_only, true); + let server_handle = + tokio::spawn(async move { + let running = server.serve(server_io).await.map_err( + |error| -> Box { Box::new(error) }, + )?; + running.waiting().await.map_err( + |error| -> Box { Box::new(error) }, + )?; + Ok(()) + }); + let client = ResourceClientHandler + .serve(client_io) + .await + .map_err(|error| -> Box { Box::new(error) })?; + Ok(Self { + client, + server_handle, + }) + } + + async fn shutdown(self) -> TestResult { + self.client + .cancel() + .await + .map_err(|error| -> Box { Box::new(error) })?; + self.server_handle.await??; + Ok(()) + } +} + +fn resource_metadata(resource: &Resource) -> String { + let mut fields = vec![resource.name.as_str()]; + if let Some(title) = resource.title.as_deref() { + fields.push(title); + } + if let Some(description) = resource.description.as_deref() { + fields.push(description); + } + fields.join(" ").to_lowercase() +} + +fn single_text_resource<'a>( + result: &'a ReadResourceResult, + expected_uri: &str, +) -> Result<(&'a str, Option<&'a str>), std::io::Error> { + match result.contents.as_slice() { + [ResourceContents::TextResourceContents { + uri, + mime_type, + text, + .. + }] if uri == expected_uri => Ok((text, mime_type.as_deref())), + contents => Err(std::io::Error::other(format!( + "expected one text resource for {expected_uri}, got {contents:?}" + ))), + } +} + /// Build a server with a fresh temp workspace, populate the engine's /// ephemeral primary with a test table, and return both the server and /// the temp dir. @@ -62,6 +148,160 @@ fn list_resources_includes_workspace_tables_readme_and_per_table() { assert!(uris.contains(&"hyper://tables/widgets/csv-sample".to_string())); } +/// This catches production mutations to the real MCP resource catalog and +/// resource bodies that collapse simultaneous local/persistent state back into +/// the legacy “workspace” concept or hide KV attachment writability. +#[tokio::test] +async fn resource_catalog_preserves_uri_and_database_model() -> TestResult { + let persistent_dir = TempDir::new()?; + let persistent_path = persistent_dir.path().join("persistent.hyper"); + let persistent = + ResourceHarness::start(Some(persistent_path.to_string_lossy().into_owned()), false).await?; + let resources = persistent.client.list_all_resources().await?; + let readme_result = persistent + .client + .read_resource(ReadResourceRequestParams::new("hyper://readme")) + .await?; + let kv_schema_result = persistent + .client + .read_resource(ReadResourceRequestParams::new("hyper://schema/kv")) + .await?; + let (persistent_readme, persistent_readme_mime) = + single_text_resource(&readme_result, "hyper://readme")?; + let persistent_readme = persistent_readme.to_lowercase(); + let (kv_schema, kv_schema_mime) = single_text_resource(&kv_schema_result, "hyper://schema/kv")?; + let kv_schema = kv_schema.to_lowercase(); + persistent.shutdown().await?; + + let disabled = ResourceHarness::start(None, false).await?; + let disabled_result = disabled + .client + .read_resource(ReadResourceRequestParams::new("hyper://readme")) + .await?; + let (disabled_readme, disabled_readme_mime) = + single_text_resource(&disabled_result, "hyper://readme")?; + let disabled_readme = disabled_readme.to_lowercase(); + disabled.shutdown().await?; + + let mut failures = Vec::new(); + if persistent_readme_mime != Some("text/markdown") { + failures.push(format!( + "attached hyper://readme must be markdown, got {persistent_readme_mime:?}: {persistent_readme}" + )); + } + if disabled_readme_mime != Some("text/markdown") { + failures.push(format!( + "ephemeral-only hyper://readme must be markdown, got {disabled_readme_mime:?}: {disabled_readme}" + )); + } + if kv_schema_mime != Some("text/plain") { + failures.push(format!( + "hyper://schema/kv must be plain text, got {kv_schema_mime:?}: {kv_schema}" + )); + } + let compatibility = resources + .iter() + .find(|resource| resource.uri == "hyper://workspace"); + let Some(compatibility) = compatibility else { + return Err(std::io::Error::other( + "resources/list removed compatibility URI hyper://workspace", + ) + .into()); + }; + for uri in ["hyper://workspace", "hyper://readme"] { + let Some(resource) = resources.iter().find(|resource| resource.uri == uri) else { + failures.push(format!("resources/list omitted {uri}")); + continue; + }; + let metadata = resource_metadata(resource); + if metadata.contains("workspace") { + failures.push(format!( + "{uri} name/title/description must use database terminology, not workspace: {metadata}" + )); + } + if !(metadata.contains("local") && metadata.contains("persistent")) { + failures.push(format!( + "{uri} metadata must distinguish local and persistent databases: {metadata}" + )); + } + } + if compatibility.uri != "hyper://workspace" { + failures.push("compatibility resource URI changed".to_owned()); + } + + let local_fact = persistent_readme + .lines() + .find(|line| line.trim_start().starts_with('-') && line.contains("local database")); + let persistent_fact = persistent_readme + .lines() + .find(|line| line.trim_start().starts_with('-') && line.contains("persistent database")); + if !matches!(local_fact, Some(line) if line.contains("default") && line.contains("ephemeral")) { + failures.push( + "hyper://readme must report the local database separately as ephemeral/default" + .to_owned(), + ); + } + if !matches!(persistent_fact, Some(line) if line.contains("attached")) { + failures.push( + "hyper://readme must report the configured persistent database separately as attached" + .to_owned(), + ); + } + if !persistent_readme.contains("persistent.hyper") { + failures.push("hyper://readme must report the attached persistent path".to_owned()); + } + if !(persistent_readme.contains("local tables") + || persistent_readme.contains("tables in the local database")) + { + failures.push( + "hyper://readme must label its table inventory as local-database tables".to_owned(), + ); + } + if persistent_readme.contains("# hyperdb workspace") + || persistent_readme.contains("- mode: **persistent**") + { + failures.push( + "hyper://readme must not collapse local and persistent state into a workspace mode" + .to_owned(), + ); + } + let disabled_persistent_fact = disabled_readme + .lines() + .find(|line| line.trim_start().starts_with('-') && line.contains("persistent database")); + if !matches!(disabled_persistent_fact, Some(line) if line.contains("disabled")) { + failures.push( + "ephemeral-only hyper://readme must report the persistent database as disabled" + .to_owned(), + ); + } + + if !(kv_schema.contains("attached") + && kv_schema.contains("writable") + && (kv_schema.contains("even for readers") || kv_schema.contains("including readers"))) + { + failures.push( + "hyper://schema/kv must say attached KV targets require writable access even for readers" + .to_owned(), + ); + } + if !(kv_schema.contains("--read-only") + && kv_schema.contains("mutator") + && kv_schema.contains("reader")) + { + failures.push( + "hyper://schema/kv must distinguish the global --read-only mutator guard from allowed readers" + .to_owned(), + ); + } + + assert!( + failures.is_empty(), + "resource database-model contract failures:\n- {}", + failures.join("\n- ") + ); + Ok(()) +} + /// Verify that reading returns the workspace status JSON /// including the `hyper_rust_api_version` field (`.r`-suffixed). #[test] @@ -88,6 +328,42 @@ fn read_workspace_resource_returns_status() { assert!(version.contains(".r")); } +/// This catches a renderer mutation that collapses the local and persistent +/// databases into one workspace mode or drops the server's read-only state. +#[test] +fn resource_status_renderer_uses_actual_engine_keys() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("readonly-persistent.hyper"); + let server = HyperMcpServer::with_no_daemon(Some(path.to_string_lossy().into()), true, true); + + let body = server + .resource_body_for_uri("hyper://readme") + .unwrap() + .expect("workspace readme resource should exist"); + let text = body.to_text(); + + assert!( + text.starts_with("# HyperDB databases"), + "README must identify the separate database model: {text}" + ); + assert!( + text.contains("- Local database: **ephemeral** (default) (read-only)"), + "README must render the local database and server read-only state: {text}" + ); + assert!( + text.contains("- Persistent database: **attached**"), + "README must render the persistent database attachment state: {text}" + ); + assert!( + text.contains(&format!("- Persistent path: `{}`", path.display())), + "README must render Engine::status persistent_path: {text}" + ); + assert!( + !text.contains("**unknown**"), + "README must not consult obsolete workspace_mode: {text}" + ); +} + /// Verify that reading returns the tables list with schemas /// and row counts. #[test] @@ -163,8 +439,9 @@ fn read_table_csv_sample_resource_emits_csv() { assert!(data[1].starts_with("2,") && data[1].contains("Beta")); } -/// Reading returns markdown listing every table, its row -/// count, and pointers to the per-table resources. +/// This catches a README renderer mutation that restores the legacy workspace +/// heading or stops identifying table data as local while preserving its row +/// count and per-table resource links. #[test] fn read_readme_resource_lists_tables_in_markdown() { let (server, _dir) = server_with_test_table(); @@ -174,8 +451,11 @@ fn read_readme_resource_lists_tables_in_markdown() { .expect("readme resource should exist"); assert_eq!(body.mime_type(), "text/markdown"); let text = body.to_text(); - assert!(text.starts_with("# HyperDB workspace")); - assert!(text.contains("`widgets`")); + assert!(text.starts_with("# HyperDB databases")); + assert!(text.contains("- Local database: **ephemeral** (default)")); + assert!(text.contains("- Persistent database: **attached**")); + assert!(text.contains("## Local tables")); + assert!(text.contains("| `widgets` | 2 |")); assert!(text.contains("hyper://tables/widgets/schema")); assert!(text.contains("hyper://tables/widgets/sample")); assert!(text.contains("hyper://tables/widgets/csv-sample")); diff --git a/hyperdb-mcp/tests/tool_schema_tests.rs b/hyperdb-mcp/tests/tool_schema_tests.rs new file mode 100644 index 0000000..aa15638 --- /dev/null +++ b/hyperdb-mcp/tests/tool_schema_tests.rs @@ -0,0 +1,543 @@ +// Copyright (c) 2026, Salesforce, Inc. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Compatibility and size contracts for the generated MCP tool catalog. +//! +//! The harness lists tools through the real in-memory rmcp client/server +//! boundary. `HyperMcpServer` remains un-warmed, so catalog inspection never +//! starts Hyper or opens a database. + +use hyperdb_mcp::readme::README; +use hyperdb_mcp::server::HyperMcpServer; +use rmcp::model::{CallToolRequestParams, CallToolResult, ClientInfo, Tool}; +use rmcp::service::{RoleClient, RunningService}; +use rmcp::{ClientHandler, ServiceExt}; +use serde::Serialize; +use std::collections::BTreeSet; + +type TestResult = Result<(), Box>; + +const CATALOG_BYTE_BUDGET: usize = 57_344; + +const LEGACY_TOOL_NAMES: [&str; 33] = [ + "attach_database", + "chart", + "copy_query", + "delete_query", + "describe", + "detach_database", + "execute", + "export", + "get_readme", + "inspect_file", + "kv_clear", + "kv_delete", + "kv_get", + "kv_list", + "kv_list_stores", + "kv_pop", + "kv_set", + "kv_set_many", + "kv_size", + "list_attached_databases", + "load_data", + "load_file", + "load_files", + "load_iceberg", + "query", + "query_data", + "query_file", + "sample", + "save_query", + "set_table_metadata", + "status", + "unwatch_directory", + "watch_directory", +]; + +const ROUTED_TOOL_ALLOWLIST: [&str; 21] = [ + "chart", + "copy_query", + "describe", + "execute", + "export", + "kv_clear", + "kv_delete", + "kv_get", + "kv_list", + "kv_list_stores", + "kv_pop", + "kv_set", + "kv_set_many", + "kv_size", + "load_data", + "load_file", + "load_files", + "query", + "sample", + "set_table_metadata", + "watch_directory", +]; + +#[derive(Debug, Clone)] +struct DummyClientHandler; + +impl ClientHandler for DummyClientHandler { + fn get_info(&self) -> ClientInfo { + ClientInfo::default() + } +} + +struct CatalogHarness { + client: RunningService, + server_handle: tokio::task::JoinHandle>>, +} + +impl CatalogHarness { + async fn start(read_only: bool) -> Result> { + let (server_io, client_io) = tokio::io::duplex(128 * 1024); + let server = HyperMcpServer::with_no_daemon(None, read_only, true); + + let server_handle = + tokio::spawn(async move { + let running = server.serve(server_io).await.map_err( + |error| -> Box { Box::new(error) }, + )?; + running.waiting().await.map_err( + |error| -> Box { Box::new(error) }, + )?; + Ok(()) + }); + + let client = DummyClientHandler + .serve(client_io) + .await + .map_err(|error| -> Box { Box::new(error) })?; + + Ok(Self { + client, + server_handle, + }) + } + + async fn list_all_tools(&self) -> Result, Box> { + self.client + .list_all_tools() + .await + .map_err(|error| -> Box { Box::new(error) }) + } + + fn initialization_instructions(&self) -> Result { + self.client + .peer_info() + .and_then(|info| info.instructions.clone()) + .ok_or_else(|| std::io::Error::other("server returned no initialization instructions")) + } + + async fn get_readme(&self) -> Result> { + self.client + .call_tool(CallToolRequestParams::new("get_readme")) + .await + .map_err(|error| -> Box { Box::new(error) }) + } + + async fn shutdown(self) -> TestResult { + self.client + .cancel() + .await + .map_err(|error| -> Box { Box::new(error) })?; + self.server_handle.await??; + Ok(()) + } +} + +fn sorted_names(tools: &[Tool]) -> Vec<&str> { + let mut names: Vec<_> = tools.iter().map(|tool| tool.name.as_ref()).collect(); + names.sort_unstable(); + names +} + +fn sorted_tools(mut tools: Vec) -> Vec { + tools.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + tools +} + +fn serialized_len(value: &T) -> usize { + serde_json::to_vec(value) + .expect("catalog values must serialize") + .len() +} + +fn property_description<'a>(tool: &'a Tool, property: &str) -> Option<&'a str> { + tool.input_schema + .get("properties")? + .as_object()? + .get(property)? + .get("description")? + .as_str() +} + +fn readme_text(result: &CallToolResult) -> Result<&str, std::io::Error> { + let mut text_blocks = result + .content + .iter() + .filter_map(|content| content.raw.as_text()); + let text = text_blocks + .next() + .ok_or_else(|| std::io::Error::other("get_readme returned no text content"))?; + if text_blocks.next().is_some() { + return Err(std::io::Error::other( + "get_readme returned more than one text content block", + )); + } + Ok(&text.text) +} + +#[tokio::test] +async fn generated_catalog_preserves_full_33_tool_contract() -> TestResult { + let writable_harness = CatalogHarness::start(false).await?; + let writable_tools = writable_harness.list_all_tools().await?; + writable_harness.shutdown().await?; + + assert_eq!( + sorted_names(&writable_tools), + LEGACY_TOOL_NAMES, + "the generated writable catalog must preserve the exact legacy surface" + ); + assert!( + writable_tools.iter().all(|tool| tool.name != "doctor"), + "doctor is a native CLI subcommand, not an MCP tool" + ); + + let read_only_harness = CatalogHarness::start(true).await?; + let read_only_tools = read_only_harness.list_all_tools().await?; + read_only_harness.shutdown().await?; + + assert_eq!( + sorted_names(&read_only_tools), + LEGACY_TOOL_NAMES, + "read-only mode must advertise the same complete legacy surface" + ); + assert_eq!( + sorted_tools(read_only_tools), + sorted_tools(writable_tools), + "read-only mode must preserve every generated tool field" + ); + Ok(()) +} + +#[tokio::test] +async fn generated_catalog_budget_and_metadata_contract() -> TestResult { + let harness = CatalogHarness::start(false).await?; + let tools = harness.list_all_tools().await?; + let instructions = harness.initialization_instructions()?; + let get_readme_result = harness.get_readme().await?; + harness.shutdown().await?; + + assert_eq!(tools.len(), LEGACY_TOOL_NAMES.len()); + + let canonical_payload = serde_json::to_vec(&tools)?; + let mut total_tool_bytes = 0; + let mut total_name_bytes = 0; + let mut total_description_bytes = 0; + let mut total_input_schema_bytes = 0; + let mut tool_metrics = Vec::with_capacity(tools.len()); + + for tool in &tools { + assert!( + tool.output_schema.is_none(), + "legacy tool `{}` unexpectedly gained an output schema", + tool.name + ); + assert!( + tool.annotations.is_none(), + "legacy tool `{}` unexpectedly gained annotations", + tool.name + ); + + let tool_bytes = serialized_len(tool); + // Human-readable strings are charged by their UTF-8 content bytes; + // JSON quoting/escaping and field punctuation belong to `other`. + // Object-valued schemas retain their canonical minified JSON size. + let name_bytes = tool.name.len(); + let description_bytes = tool.description.as_deref().map_or(0, str::len); + let input_schema_bytes = serialized_len(tool.input_schema.as_ref()); + let other_bytes = tool_bytes + .checked_sub(name_bytes + description_bytes + input_schema_bytes) + .expect("catalog byte categories must not exceed their tool object"); + + total_tool_bytes += tool_bytes; + total_name_bytes += name_bytes; + total_description_bytes += description_bytes; + total_input_schema_bytes += input_schema_bytes; + tool_metrics.push(( + tool.name.as_ref(), + tool_bytes, + name_bytes, + description_bytes, + input_schema_bytes, + other_bytes, + )); + } + + let vec_framing_bytes = if tools.is_empty() { 2 } else { tools.len() + 1 }; + assert_eq!( + canonical_payload.len(), + total_tool_bytes + vec_framing_bytes, + "canonical Vec bytes must equal tool objects plus array punctuation" + ); + let total_other_bytes = canonical_payload + .len() + .checked_sub(total_name_bytes + total_description_bytes + total_input_schema_bytes) + .expect("catalog byte categories must not exceed the canonical payload"); + + tool_metrics.sort_unstable_by_key(|metrics| metrics.0); + for (name, total, name_bytes, description, input_schema, other) in tool_metrics { + println!( + "catalog_tool name={name} total_bytes={total} name_bytes={name_bytes} \ + description_bytes={description} input_schema_bytes={input_schema} \ + other_bytes={other} output_schema=absent annotations=absent" + ); + } + + let get_readme_text = readme_text(&get_readme_result)?; + assert_eq!( + get_readme_text, README, + "the generated get_readme tool must return the canonical README" + ); + println!( + "catalog_total tools={} total_bytes={} name_bytes={} description_bytes={} \ + input_schema_bytes={} other_bytes={} budget_bytes={}", + tools.len(), + canonical_payload.len(), + total_name_bytes, + total_description_bytes, + total_input_schema_bytes, + total_other_bytes, + CATALOG_BYTE_BUDGET + ); + println!( + "initialization_instructions_utf8_bytes={}", + instructions.len() + ); + println!("get_readme_utf8_bytes={}", get_readme_text.len()); + + assert!( + canonical_payload.len() <= CATALOG_BYTE_BUDGET, + "canonical generated catalog is {} bytes, exceeding the reviewed {}-byte budget", + canonical_payload.len(), + CATALOG_BYTE_BUDGET + ); + Ok(()) +} + +#[tokio::test] +async fn generated_catalog_readme_coverage_contract() -> TestResult { + let harness = CatalogHarness::start(false).await?; + let tools = harness.list_all_tools().await?; + let get_readme_result = harness.get_readme().await?; + harness.shutdown().await?; + + let generated_readme = readme_text(&get_readme_result)?; + assert_eq!(generated_readme, README); + + let undocumented: Vec<_> = sorted_names(&tools) + .into_iter() + .filter(|name| !generated_readme.contains(&format!("`{name}`"))) + .collect(); + assert!( + undocumented.is_empty(), + "generated tools missing an exact backticked README mention: {undocumented:?}" + ); + Ok(()) +} + +/// This catches production mutations to generated parameter schemas that +/// misstate attachment errors, KV writability, catalog routing, or chart input +/// fields even when unrelated README text still contains the same tokens. +#[tokio::test] +async fn generated_guidance_matches_database_side_effect_contract() -> TestResult { + let harness = CatalogHarness::start(false).await?; + let tools = harness.list_all_tools().await?; + harness.shutdown().await?; + let mut failures = Vec::new(); + + let attach = tools + .iter() + .find(|tool| tool.name == "attach_database") + .ok_or_else(|| std::io::Error::other("generated catalog omitted attach_database"))?; + let attach_path = property_description(attach, "path") + .unwrap_or("") + .to_lowercase(); + if attach_path.contains("resource_busy") { + failures.push( + "attach_database.path must not promise reserved-persistent RESOURCE_BUSY classification for user attachments" + .to_owned(), + ); + } + + for tool_name in ["kv_get", "kv_delete"] { + let tool = tools + .iter() + .find(|tool| tool.name == tool_name) + .ok_or_else(|| { + std::io::Error::other(format!("generated catalog omitted {tool_name}")) + })?; + let database = property_description(tool, "database") + .unwrap_or("") + .to_lowercase(); + if !(database.contains("attached") && database.contains("writable")) { + failures.push(format!( + "{tool_name}.database must say attached KV targets require writable access even though {tool_name} itself may be a reader" + )); + } + } + + let metadata = tools + .iter() + .find(|tool| tool.name == "set_table_metadata") + .ok_or_else(|| std::io::Error::other("generated catalog omitted set_table_metadata"))?; + let metadata_table = property_description(metadata, "table") + .unwrap_or("") + .to_lowercase(); + let metadata_database = property_description(metadata, "database") + .unwrap_or("") + .to_lowercase(); + if !metadata_table.contains("catalog entry") + || metadata_table.contains("must already exist in the selected database") + { + failures.push( + "set_table_metadata.table must require an existing catalog entry, not table existence in a selected database" + .to_owned(), + ); + } + if !(metadata_database.contains("local") + && metadata_database.contains("persistent") + && metadata_database.contains("shared") + && metadata_database.contains("name-keyed") + && metadata_database.contains("persistent catalog") + && metadata_database.contains("user-attached") + && metadata_database.contains("writable") + && metadata_database.contains("per-database")) + { + failures.push( + "set_table_metadata.database must describe the shared name-keyed local/persistent catalog and writable user-alias per-database catalog" + .to_owned(), + ); + } + + let chart = tools + .iter() + .find(|tool| tool.name == "chart") + .ok_or_else(|| std::io::Error::other("generated catalog omitted chart"))?; + let chart_properties = chart + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| std::io::Error::other("chart schema omitted top-level properties"))?; + for property in ["database", "color_map", "label_points"] { + if !chart_properties.contains_key(property) { + failures.push(format!("chart schema omitted `{property}`")); + } + } + let chart_database = property_description(chart, "database") + .unwrap_or("") + .to_lowercase(); + let color_map = property_description(chart, "color_map") + .unwrap_or("") + .to_lowercase(); + let label_points = property_description(chart, "label_points") + .unwrap_or("") + .to_lowercase(); + if !(chart_database.contains("local") + && chart_database.contains("persistent") + && chart_database.contains("attached")) + { + failures.push("chart.database schema description must explain all routing choices".into()); + } + if !(color_map.contains("series") && color_map.contains("hex")) { + failures.push("chart.color_map schema description must map series to hex colors".into()); + } + if !(label_points.contains("line") + && label_points.contains("scatter") + && label_points.contains("legend")) + { + failures.push( + "chart.label_points schema description must cover line/scatter labels and legend suppression" + .into(), + ); + } + + assert!( + failures.is_empty(), + "generated guidance contract failures:\n- {}", + failures.join("\n- ") + ); + Ok(()) +} + +/// The semantic routed-tool inventory is explicit: generated parameter names +/// measure likely candidates, but only the reviewed design allowlist defines +/// which successful responses owe `resolved_database`. `copy_query` is the +/// deliberate exception because its compatibility input is target_database. +#[tokio::test] +async fn routed_tool_allowlist_matches_generated_schemas() -> TestResult { + let harness = CatalogHarness::start(false).await?; + let tools = harness.list_all_tools().await?; + harness.shutdown().await?; + + let routed_allowlist: BTreeSet<_> = ROUTED_TOOL_ALLOWLIST.into_iter().collect(); + assert_eq!( + routed_allowlist.len(), + ROUTED_TOOL_ALLOWLIST.len(), + "the explicit semantic allowlist must not contain duplicates" + ); + + let schema_candidates: BTreeSet<_> = tools + .iter() + .filter_map(|tool| { + let properties = tool + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object)?; + (properties.contains_key("database") || properties.contains_key("persist")) + .then_some(tool.name.as_ref()) + }) + .collect(); + + let copy_query = tools + .iter() + .find(|tool| tool.name == "copy_query") + .ok_or_else(|| std::io::Error::other("generated catalog omitted copy_query"))?; + let copy_properties = copy_query + .input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .ok_or_else(|| std::io::Error::other("copy_query schema omitted top-level properties"))?; + assert!( + copy_properties.contains_key("target_database"), + "copy_query must retain its semantic target_database routing input" + ); + assert!( + !copy_properties.contains_key("database") && !copy_properties.contains_key("persist"), + "copy_query is the named semantic exception, not a database/persist schema candidate" + ); + + let mut expected_schema_candidates = routed_allowlist.clone(); + assert!( + expected_schema_candidates.remove("copy_query"), + "the semantic allowlist must include the copy_query exception" + ); + assert_eq!( + schema_candidates, expected_schema_candidates, + "generated database/persist candidates drifted from the reviewed routed inventory" + ); + + let mut semantic_inventory = schema_candidates; + semantic_inventory.insert("copy_query"); + assert_eq!( + semantic_inventory, routed_allowlist, + "schema candidates plus the copy_query exception must equal the explicit 21-tool allowlist" + ); + Ok(()) +}