Skip to content

fix(error): make a missing local runtime a terminal variant, not a malformed response - #7

Merged
M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/6-missing-runtime-terminal
Aug 25, 2026
Merged

fix(error): make a missing local runtime a terminal variant, not a malformed response#7
M3gA-Mind merged 2 commits into
tinyhumansai:mainfrom
M3gA-Mind:fix/6-missing-runtime-terminal

Conversation

@M3gA-Mind

@M3gA-Mind M3gA-Mind commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Phase 1 of #6. A missing local runtime stops being a formatted sentence inside MalformedResponse and becomes a variant a caller can branch on, and the supervisor stops retrying it.

Phase 2 (the openhuman-side status / install hint) is deliberately not here — it depends on a packaging decision that has not been made.

What changed

Error::MissingRuntime { command, runtime } + is_missing_runtime() (error/mod.rs), mirroring the Unauthorized carve-out this module already argues for. The module note now covers both, because they are the same argument pointed at opposite conclusions: a 401 says try again with credentials, a missing uvx says stop.

runtime is CommandKind — the vocabulary the install record and the store already persist — rather than a new string or a new enum. It is classified by a new spawn_env::required_runtime(command) -> CommandKind, which missing_command_error now switches on as well, so the sentence a user reads and the value a caller branches on cannot drift apart about which runtime is missing.

command is kept verbatim rather than normalised: an absolute path that is wrong for this machine reads very differently from a bare uvx, and a user needs to see the string that was actually looked up.

The transport returns it (transport/stdio/mod.rs:135) instead of Error::malformed(...).

The supervisor parks it (registry/supervisor/types.rs). A new Err(error) if error.is_missing_runtime() arm inserts into a terminal: HashSet<String> and — importantly — removes any backoff entry rather than adding one, because a backoff is a promise that waiting helps. Parked servers are skipped before the backoff gate on later ticks. Disabling the server clears the verdict, so installing the runtime and toggling the server off/on is the way back — the same gesture that already clears a penalty.

terminally_failed_count() is added alongside backed_off_count() so the two states are separately observable.

npx/Node is covered, and it was already the same code path

The issue asks to confirm rather than assume. missing_command_error already classified three ways — npx|npm|node, uvx|uv, everything else — so Node and uv reach the identical branch in the identical function. Extracting required_runtime from that existing match covers both by construction; nothing had to be forced. The new stdio test asserts all five recognised launchers plus an unrecognised one.

Scope note

This makes the failure honest and stops the pointless retry loop. It does not make an MCP server work on a host without its runtime — installing or bundling uv/Node is a separate decision for the consuming app. Refs tinyhumansai/openhuman#5600, which stays open for exactly that reason.

Tests, and the revert-check

Two new tests, both proven to fail with their own fix reverted rather than assumed to.

transport/stdio/test.rsa_missing_runtime_is_a_variant_a_caller_can_branch_on asserts the variant and the runtime field, not the message, which is the whole point. With the transport change reverted:

test transport::stdio::test::a_missing_runtime_is_a_variant_a_caller_can_branch_on ... FAILED
`uvx` produced MalformedResponse { detail: "`uvx` was not found. This MCP server needs uv (Python), …" },
which no caller can act on

registry/supervisor/test.rsa_missing_runtime_is_terminal_and_earns_no_backoff_penalty installs a Transport::Stdio server with PATH set to a directory that does not exist, then asserts no penalty, a parked verdict, no second attempt at BACKOFF_MAX * 2, and that disabling clears it. With only the supervisor's Err arm reverted (transport fix in place, so the variant is produced correctly):

test registry::supervisor::test::a_missing_runtime_is_terminal_and_earns_no_backoff_penalty ... FAILED
assertion `left == right` failed: a backoff promises that waiting helps, and here it cannot
  left: 1
 right: 0

Full suite after restoring both: 659 + 4 + 149 + 12 + 18 passed, 0 failed. cargo fmt --all clean; cargo clippy --all-targets --all-features adds nothing — the one unknown lint: clippy::unused_async_trait_impl warning is present on a clean tree too.

The existing missing_command_error tests still pass unchanged, which is what verifies the user-facing text did not move: the function keeps its signature and its public doctest and now delegates to the variant's own rendering, so there is one copy of the sentence instead of two.

API compatibility

Adding the variant is not a breaking change, and openhuman needs no paired edit. This is the fact that makes the PR safe to merge on its own, so it is stated here rather than left to be rediscovered. Re-verified 2026-08-24 against openhuman main @ 0b8b02cbd.

1. The guarantee is structural, and it predates this PR. Error is #[non_exhaustive] on the base branchcrates/tinymcp/src/error/mod.rs:30, above pub enum Error, untouched by this change. A downstream crate therefore cannot match it without a wildcard arm today, so adding a variant cannot break one by construction. Nothing here relies on the diff being reviewed for that.

Note the attribute is on the enum, not on individual variants, so existing variants stay constructible from outside — which is why observability.rs can build an Unauthorized in a test, and why phase 2 will be able to build a MissingRuntime in one.

2. openhuman does not match on the type at all. Every reference on main @ 0b8b02cbd:

Site What it is
src/openhuman/mcp/mod.rs:144 pub use tinymcp::Error as McpError; — a re-export
src/openhuman/mcp/mod.rs:133 a doc comment naming McpError::Unauthorized
src/core/observability.rs:347, :1087, :3775 doc comments
src/core/observability.rs:3784, :3788 tinymcp::Error::Unauthorized { .. } constructed in two tests

Zero match arms and zero matches! over the type; everywhere else it is carried as an opaque Display/anyhow value. src/api/models/socket.rs:63,69's pub struct McpError is a separate wire struct that happens to share the name, and the two app/src/lib/mcp/*.ts hits are frontend.

3. The earlier check has not gone stale. It was first made at e1c332bf0; main has since moved to 0b8b02cbd, and GET /compare/e1c332bf0...0b8b02cbd lists 28 changed paths, none of which touches src/openhuman/mcp/, src/core/observability.rs, or src/api/models/socket.rs (the run is memory/config/CI work plus a vendor/tinymemory bump). The line numbers above were re-read from 0b8b02cbd directly, not carried forward.

4. The message text is unchanged, and nothing matched it anyway. Grepping src/ and app/src/ for "was not found. This MCP server needs", "needs Node.js" and "docs.astral.sh/uv" returns nothing — so even the substring contract this PR replaces had no consumer in openhuman.

So the submodule bump can land on its own. The openhuman follow-up is phase 2 — new behaviour, not a repair.

Two notes for the reviewer

  1. The issue points at registry/supervisor/types.rs:93 and :149 as the two Err arms. Only :149 is a connect failure; :93 is the store.list_servers() arm, which cannot produce a MissingRuntime. One site changed, not two.
  2. missing_runtime_guidance matches CommandKind with a _ fallback rather than naming Binary explicitly — CommandKind is itself #[non_exhaustive], and a future variant should degrade to the generic "install it, or its runtime" sentence rather than fail to compile. The comment says so.

Closes #6

…lformed response

A stdio server whose launcher is not installed was reported as
`Error::malformed(missing_command_error(..))` — wrong twice over, since
nothing was malformed and no response ever arrived. The condition existed
only inside a formatted English sentence, so the supervisor could not tell
it from a transient transport failure and scheduled reconnects forever on a
five-minute ceiling against a binary that was never going to appear.

Add `Error::MissingRuntime { command, runtime }` and `is_missing_runtime()`,
mirroring the `Unauthorized` carve-out the module already argues for. The
two are the same argument pointed at opposite conclusions: a 401 says try
again with credentials, a missing `uvx` says stop.

`runtime` is `CommandKind`, the vocabulary the install record and the store
already use, rather than a new one. It is classified by the new
`spawn_env::required_runtime`, which `missing_command_error` now switches on
too, so the sentence a user reads and the value a caller branches on cannot
disagree about which runtime is missing. `npx`/`npm`/`node` and `uvx`/`uv`
are the same code path and are both covered.

The supervisor parks such a server instead of penalising it: no backoff
entry, because a backoff is a promise that waiting helps. Disabling clears
the verdict, so installing the runtime and toggling the server is the way
back — the same gesture that already clears a penalty.

The guidance text is unchanged, and `missing_command_error` keeps its
signature and its tests; it delegates to the variant's own rendering.

Adding a variant is not a breaking change here: `Error` is
`#[non_exhaustive]`, so no downstream crate can match it without a wildcard.

Refs tinyhumansai/openhuman#5600

Closes tinyhumansai#6
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f77c2070-cddf-459d-bc3e-29088409941e

Warning

Your free Security trial is over. An organization admin can activate billing to continue.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tinysweeper found nothing blocking. Approving.

$0.0000 · 0 in / 0 out · 686 embedded · openrouter/openai/text-embedding-3-small

@tinysweeper

tinysweeper Bot commented Aug 24, 2026

Copy link
Copy Markdown

How this change flows

4 changed behaviours across 16 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 36 further behaviours left out to keep the diagram readable.

flowchart LR
  n0["a_failed_reconnect_earns_a_backoff_penalty<br/>changed"]:::changed
  n1["Supervisor<br/>changed"]:::changed
  n2["McpStdioClient<br/>changed"]:::changed
  n3["a_missing_uv_runtime_says_so_by_name<br/>changed"]:::changed
  n4["new"]:::impacted
  n5["tick"]:::impacted
  n6["connected_to"]:::impacted
  n7["install"]:::impacted
  n8["supervisor"]:::impacted
  n9["client_for"]:::impacted
  n0 -->|calls| n4
  n0 -->|tests| n4
  n0 -->|calls| n5
  n0 -->|tests| n5
  n0 -->|calls| n7
  n0 -->|tests| n7
  n0 -->|calls| n8
  n0 -->|tests| n8
  n3 -->|calls| n9
  n3 -->|tests| n9
  n6 -->|calls| n4
  n6 -->|calls| n7
  n7 -->|calls| n4
  n8 -->|uses| n1
  n8 -->|calls| n4
  n9 -->|uses| n2
  classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
  classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
  classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
  classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Loading

Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge.

tinysweeper 0.1.0

tinyhumansai#5 landed after this branch was cut and touched the same supervisor cycle,
for a different reason: it made the liveness probe report what it observed
(`ProbeOutcome`, a consecutive-timeout streak, `judge_probe`) instead of
asserting a drop on the first slow answer.

Both behaviours have to survive, so every one of the five conflicts is
"keep both", not "pick a side":

- the `timeouts` streak and the `terminal` set are separate fields, and the
  new doc note says why they are not the same thing — a live session going
  quiet is a reason to wait longer, a missing launcher is a reason to stop;
- the constructor initialises both;
- the disable branch forgets backoff, streak and terminal verdict alike;
- the `Ok` arm clears all three;
- both accessors are kept.

The guarded `Err(error) if error.is_missing_runtime()` arm stays ahead of
the generic one, so a missing runtime cannot fall through into the retry
path tinyhumansai#5 left untouched. The terminal skip stays after the liveness block, so
a still-connected server is probed and torn down normally and only the
pointless reconnect is skipped.

`test.rs` merged cleanly: 58 insertions, no deletions, so tinyhumansai#5's tests are
unmodified.
@M3gA-Mind
M3gA-Mind merged commit 5909bd1 into tinyhumansai:main Aug 25, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A missing local runtime is reported as a malformed response and retried forever

2 participants