Skip to content

fix(runtime-mcp): preserve tool annotations for parallel safety classification (PR-6/6)#3147

Merged
houko merged 4 commits into
mainfrom
feat/parallel-tool-calls-m6
Apr 25, 2026
Merged

fix(runtime-mcp): preserve tool annotations for parallel safety classification (PR-6/6)#3147
houko merged 4 commits into
mainfrom
feat/parallel-tool-calls-m6

Conversation

@houko

@houko houko commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a latent bug: LibreFang's MCP tools/list parsing was discarding the annotations field across all three tool-discovery paths (SSE, Streamable HTTP, stdio). MCP spec 0.5+ uses readOnlyHint / destructiveHint / idempotentHint to tell clients which tools are safe to fan out and which need to serialise. Without preserving them, every MCP tool ended up at ToolApprovalClass::UnknownParallelSafety::WriteShared — defeating the whole point of parallel-safe MCP tools.

This PR adds a single helper, inject_annotation_class, that maps the MCP annotations onto a metadata.tool_class field in the tool's input schema. The existing runtime/tool_classifier.rs::classify_tool already reads metadata.tool_class (its primary escape hatch), so the projection lights up automatically once the annotations land in the schema.

Mapping

readOnlyHint destructiveHint (default true) metadata.tool_class
true false "readonly_search"
any other combination "mutating"

Per spec, destructiveHint defaults to true when missing, so an absent annotation block is treated as "destructive" — exactly the existing pre-fix behaviour, just made explicit.

Three call sites updated

  • SSE (lib.rs:1050) — raw JSON tools/list response loop
  • Streamable HTTP (lib.rs:~1232) — same parse path
  • stdio (rmcp SDK) — the SDK's Tool struct exposes annotations. Serialise it with the tool's input_schema before injection so the same helper handles all three branches uniformly.

Companion fix

scheduler.rs:555 baseline clippy::manual_pattern_char_comparison lint (s.find(|c: char| c == '/' || c == '?' || c == '#')s.find(['/', '?', '#'])). The lint surfaces when running clippy on librefang-types via librefang-runtime-mcp's transitive compilation. Identical fix is also queued in PR #3144 (parallel-tool-calls PR-3) so whichever lands first, the other rebases cleanly.

Tests (6 new)

Test Asserts
inject_annotation_readonly_sets_readonly_search {readOnlyHint: true, destructiveHint: false}readonly_search
inject_annotation_destructive_sets_mutating {destructiveHint: true}mutating
inject_annotation_default_destructive_when_missing absent destructiveHint defaults to truemutating
inject_annotation_no_annotations_preserves_schema annotations: None → schema unchanged
inject_annotation_preserves_existing_metadata metadata.foo already present → preserved + new tool_class added
inject_annotation_existing_tool_class_overwritten existing metadata.tool_class overwritten by new annotation

Test plan

  • cargo test -p librefang-runtime-mcp --lib76 ok (70 baseline + 6 new)
  • cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings — clean

Stack

Independent, base main. Once this lands the parallel dispatcher (PR-4/5 of the parallel-tool-calls series) gets accurate ParallelSafety classification for every annotated MCP tool.

See .plans/parallel-tool-calls.md §4 for the full rationale.

…ification (PR-6/6)

LibreFang's MCP `tools/list` parsing was discarding the `annotations`
field across all three tool-discovery paths (SSE, Streamable HTTP,
stdio). MCP spec 0.5+ uses these annotations
(`readOnlyHint` / `destructiveHint` / `idempotentHint`) to tell
clients which tools are safe to fan out and which need to serialise.
Without preserving them, every MCP tool ended up at
`ToolApprovalClass::Unknown` → `ParallelSafety::WriteShared` —
defeating the whole point of parallel-safe MCP tools.

This PR adds a single helper, `inject_annotation_class`, that maps
the MCP annotations onto a `metadata.tool_class` field in the tool's
input schema. The existing `runtime/tool_classifier.rs::classify_tool`
already reads `metadata.tool_class` (its primary escape hatch) so
the projection lights up automatically once the annotations land in
the schema.

Mapping rule:
- `readOnlyHint=true && destructiveHint=false`  → `"readonly_search"`
- otherwise                                     → `"mutating"`

Per spec, `destructiveHint` defaults to `true` when missing, so an
absent annotation block is treated as "destructive" — exactly the
existing pre-fix behaviour, just made explicit.

Three call sites updated:
- SSE: `lib.rs:1050` (raw JSON `tools/list` response loop)
- Streamable HTTP: same parse path, around line 1232
- stdio (rmcp SDK): SDK's `Tool` struct exposes `annotations` —
  serialise it with the tool's input_schema before injection so
  the same helper handles all three branches uniformly.

Companion fix: `scheduler.rs:555` baseline
`clippy::manual_pattern_char_comparison` lint
(`s.find(|c: char| c == '/' || c == '?' || c == '#')` →
`s.find(['/', '?', '#'])`). The lint surfaces when running clippy
on librefang-types via librefang-runtime-mcp's transitive
compilation; identical fix is also queued in PR #3144 (parallel-tool-calls
PR-3) so whichever lands first the other rebases cleanly.

Tests (6 new in `runtime_mcp::tests`):
- inject_annotation_readonly_sets_readonly_search
- inject_annotation_destructive_sets_mutating
- inject_annotation_default_destructive_when_missing
- inject_annotation_no_annotations_preserves_schema
- inject_annotation_preserves_existing_metadata
- inject_annotation_existing_tool_class_overwritten

Verification:
- cargo test -p librefang-runtime-mcp --lib: 76 ok
- cargo clippy -p librefang-runtime-mcp --all-targets -- -D warnings: clean

Stack: independent, base main. Once this lands the parallel
dispatcher (PR-4/5 of the parallel-tool-calls series) gets accurate
ParallelSafety classification for every annotated MCP tool.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review size/M 50-249 lines changed labels Apr 25, 2026
houko added a commit that referenced this pull request Apr 25, 2026
… fix)

Closes a high-severity gap from PR #3145 review: `PreScript.env` was
unvalidated, so an attacker-controlled cron config could set
`LD_PRELOAD=/tmp/x.so` (or the Darwin equivalent
`DYLD_INSERT_LIBRARIES`, or `PATH=/tmp/evil:$PATH`, or `IFS=...`)
and fully bypass the path allowlist on `argv[0]`. The "scripts under
~/.librefang/scripts only" promise is moot if the spawned binary
gets hijacked at link / shell-parse time.

Changes:
- New `DANGEROUS_ENV_KEYS` const listing 8 dynamic-linker / PATH /
  IFS knobs that we refuse to honor in `PreScript.env`. Match is
  case-insensitive (Windows env keys are case-insensitive natively;
  defence-in-depth elsewhere).
- New `PreScriptValidationError::DangerousEnvKey { key }` variant.
- `validate_pre_script` rejects before any file IO so the cheap
  check fails first.
- Hooked into the `CronJob::validate_action` path so config-load
  rejects bad pre_scripts even before the dispatcher runs them
  (review medium issue: `validate_pre_script` was previously
  dead code outside its own unit tests).

Companion: same `scheduler.rs` host-parse `clippy::manual_pattern_char_comparison`
fix as in #3144 / #3147 / #3149 (the line moved from :555 to :785
once the denylist + tests landed; identical 3-char fix). Whichever
PR lands first the others rebase cleanly.

Tests (5 new in `scheduler::tests`):
- pre_script_validation_rejects_ld_preload
- pre_script_validation_rejects_path_override
- pre_script_validation_rejects_dyld_insert
- pre_script_validation_rejects_dangerous_env_case_insensitive
- pre_script_validation_accepts_safe_env_keys

Verification:
- cargo test -p librefang-types --lib scheduler: 81 ok
- cargo clippy -p librefang-types -p librefang-kernel
  --all-targets -- -D warnings: clean
@houko
houko enabled auto-merge (squash) April 25, 2026 15:04
Self-review caught that the existing tests verify
`inject_annotation_class` in isolation but never check that the literals
it emits ("readonly_search", "mutating") actually parse via
`ToolApprovalClass::from_snake_case` on the consumer side. A typo or a
future rename in either crate would silently fall back to Unknown →
WriteShared and the whole MCP-tool parallelisation fix becomes a no-op.

Add `injected_class_strings_parse_into_approval_class` to round-trip
both literals through `ToolApprovalClass::from_snake_case` so the
contract is enforced in CI.

Also extend the `inject_annotation_class` doc comment to record why
`idempotentHint` / `openWorldHint` are intentionally dropped at this
layer (no matching ParallelSafety variant today) so a future maintainer
doesn't think it's an oversight.
@houko
houko merged commit c234fa9 into main Apr 25, 2026
13 checks passed
@houko
houko deleted the feat/parallel-tool-calls-m6 branch April 25, 2026 15:18
@github-actions github-actions Bot removed the ready-for-review PR is ready for maintainer review label Apr 25, 2026
houko added a commit that referenced this pull request Apr 25, 2026
… fix)

Closes a high-severity gap from PR #3145 review: `PreScript.env` was
unvalidated, so an attacker-controlled cron config could set
`LD_PRELOAD=/tmp/x.so` (or the Darwin equivalent
`DYLD_INSERT_LIBRARIES`, or `PATH=/tmp/evil:$PATH`, or `IFS=...`)
and fully bypass the path allowlist on `argv[0]`. The "scripts under
~/.librefang/scripts only" promise is moot if the spawned binary
gets hijacked at link / shell-parse time.

Changes:
- New `DANGEROUS_ENV_KEYS` const listing 8 dynamic-linker / PATH /
  IFS knobs that we refuse to honor in `PreScript.env`. Match is
  case-insensitive (Windows env keys are case-insensitive natively;
  defence-in-depth elsewhere).
- New `PreScriptValidationError::DangerousEnvKey { key }` variant.
- `validate_pre_script` rejects before any file IO so the cheap
  check fails first.
- Hooked into the `CronJob::validate_action` path so config-load
  rejects bad pre_scripts even before the dispatcher runs them
  (review medium issue: `validate_pre_script` was previously
  dead code outside its own unit tests).

Companion: same `scheduler.rs` host-parse `clippy::manual_pattern_char_comparison`
fix as in #3144 / #3147 / #3149 (the line moved from :555 to :785
once the denylist + tests landed; identical 3-char fix). Whichever
PR lands first the others rebase cleanly.

Tests (5 new in `scheduler::tests`):
- pre_script_validation_rejects_ld_preload
- pre_script_validation_rejects_path_override
- pre_script_validation_rejects_dyld_insert
- pre_script_validation_rejects_dangerous_env_case_insensitive
- pre_script_validation_accepts_safe_env_keys

Verification:
- cargo test -p librefang-types --lib scheduler: 81 ok
- cargo clippy -p librefang-types -p librefang-kernel
  --all-targets -- -D warnings: clean
houko added a commit that referenced this pull request Apr 25, 2026
* feat(types): cron pre_script + silent_marker schema (PR-1/3)

Adds the configuration schema for cron pre-script injection +
silent-response markers. Pure schema work — no scheduler dispatch
code reads these fields yet (M2 will wire execution; M3 will add
the dashboard editor + optional TOTP approval).

`CronAction::AgentTurn` grows two optional fields:
- `pre_script: Option<PreScript>` — when set, the M2 dispatcher
  will run the argv before the scheduled prompt fires, capture
  stdout, and inject it into the LLM context. Distinct from the
  existing `pre_check_script` (which gates whether the agent runs
  at all via `{"wakeAgent": false}`); both can coexist.
- `silent_marker: Option<String>` — when present at the end of
  the response, the dispatcher suppresses delivery. Match is
  "last non-empty trimmed line == marker" — strict, doesn't
  trigger on mid-response mentions of `[SILENT]`. None means
  delivery always fires.

`PreScript` (new struct in `librefang-types::scheduler`):
- argv: Vec<String> — split form, no shell parsing.
- cwd: Option<String> — defaults to workspace root.
- env: HashMap<String, String> — added on top of daemon env;
  per-job secrets should still go through LIBREFANG_VAULT_KEY.
- `#[serde(deny_unknown_fields)]` so typos surface instead of
  being silently ignored.

`validate_pre_script(script, home_dir)` enforces a path allowlist:
argv[0] must canonicalize to a file under `<home_dir>/scripts/`.
Component-level prefix comparison via `Path::starts_with` (so
`/home/X-other` doesn't match `/home/X`). `..` escapes are
rejected after canonicalize. Errors typed via
`PreScriptValidationError` (thiserror): EmptyArgv,
OutsideAllowlist { path, home_dir }, NotFound { path }.

Companion changes (mechanical, fields propagated to existing
`CronAction::AgentTurn` construction sites):
- librefang-api/src/channel_bridge.rs: 2 lines (pre_script +
  silent_marker = None).
- librefang-api/src/routes/workflows.rs: 2 lines (same).
- librefang-types/Cargo.toml: 1 line (test-only addition).

Tests (8 new in `scheduler::tests`):
- pre_script_validation_accepts_path_under_scripts_dir
- pre_script_validation_rejects_outside_allowlist
- pre_script_validation_rejects_dot_dot_escape
- pre_script_validation_rejects_empty_argv
- pre_script_validation_rejects_nonexistent_path
- cron_action_agent_turn_serde_round_trip_with_pre_script
- cron_action_agent_turn_serde_compat_no_pre_script (legacy
  config without the field deserialises to None)
- silent_marker_default_via_serde_skip (None doesn't serialise)

Verification:
- cargo test -p librefang-types --lib: 628 ok
- cargo check --workspace --lib: clean
- cargo clippy -p librefang-types -p librefang-api --all-targets
  -- -D warnings: clean

Stack: independent, base main. PR-2 (next) will wire kernel
dispatcher: argv exec + stdout capture + prompt injection +
silent_marker check + wake-gate path-whitelist hardening.

* style: cargo fmt for scheduler.rs

* fix(types): reject dangerous env keys in PreScript validation (review fix)

Closes a high-severity gap from PR #3145 review: `PreScript.env` was
unvalidated, so an attacker-controlled cron config could set
`LD_PRELOAD=/tmp/x.so` (or the Darwin equivalent
`DYLD_INSERT_LIBRARIES`, or `PATH=/tmp/evil:$PATH`, or `IFS=...`)
and fully bypass the path allowlist on `argv[0]`. The "scripts under
~/.librefang/scripts only" promise is moot if the spawned binary
gets hijacked at link / shell-parse time.

Changes:
- New `DANGEROUS_ENV_KEYS` const listing 8 dynamic-linker / PATH /
  IFS knobs that we refuse to honor in `PreScript.env`. Match is
  case-insensitive (Windows env keys are case-insensitive natively;
  defence-in-depth elsewhere).
- New `PreScriptValidationError::DangerousEnvKey { key }` variant.
- `validate_pre_script` rejects before any file IO so the cheap
  check fails first.
- Hooked into the `CronJob::validate_action` path so config-load
  rejects bad pre_scripts even before the dispatcher runs them
  (review medium issue: `validate_pre_script` was previously
  dead code outside its own unit tests).

Companion: same `scheduler.rs` host-parse `clippy::manual_pattern_char_comparison`
fix as in #3144 / #3147 / #3149 (the line moved from :555 to :785
once the denylist + tests landed; identical 3-char fix). Whichever
PR lands first the others rebase cleanly.

Tests (5 new in `scheduler::tests`):
- pre_script_validation_rejects_ld_preload
- pre_script_validation_rejects_path_override
- pre_script_validation_rejects_dyld_insert
- pre_script_validation_rejects_dangerous_env_case_insensitive
- pre_script_validation_accepts_safe_env_keys

Verification:
- cargo test -p librefang-types --lib scheduler: 81 ok
- cargo clippy -p librefang-types -p librefang-kernel
  --all-targets -- -D warnings: clean

* style: post-rebase fmt drift
houko added a commit that referenced this pull request Apr 25, 2026
* feat(runtime): cache_hit_ratio metric + trajectory field (M2/2)

Builds on PR-1 (#3126, the system_and_3 stamping). Adds the
single-value observability metric the dashboard surfaces so users
can see at a glance whether prompt caching is paying off for their
workload. Pure observability — no behaviour change.

`cache_hit_ratio = cache_read / (cache_read + cache_creation)`,
in `[0.0, 1.0]`. Returns `0.0` when both numerators are zero
(caching not active for this turn / model doesn't support it).

Wire-up:
- `crates/librefang-runtime/src/agent_loop.rs`: new `cache_hit_ratio`
  helper alongside `accumulate_token_usage`. Per-turn `tracing::info!`
  on `librefang::cache` target with agent / hit_ratio / creation /
  read so existing log pipelines (Tempo, Grafana) pick it up
  without dashboard changes.
- `crates/librefang-kernel/src/trajectory/mod.rs`:
  `TrajectoryMetadata` grows `cache_hit_ratio: Option<f32>`
  (`#[serde(default, skip_serializing_if = "Option::is_none")]`)
  so legacy trajectories deserialise to None and replays don't
  need to know about caching to round-trip.

Tests (6 new):
- cache_hit_ratio_zero_when_no_caching (both 0)
- cache_hit_ratio_full_hit (read=100, creation=0 → 1.0)
- cache_hit_ratio_partial (read=70, creation=30 → 0.7)
- cache_hit_ratio_cold_start (read=0, creation=100 → 0.0)
- trajectory_metadata_cache_hit_ratio_serde_round_trip
- trajectory_metadata_cache_hit_ratio_legacy_compat (legacy JSON
  without the field deserialises to None, no panic)

Companion fix: `scheduler.rs:555` baseline
`clippy::manual_pattern_char_comparison` (`s.find(|c: char| c == '/'
|| c == '?' || c == '#')` → `s.find(['/', '?', '#'])`). Same one-line
fix is also queued in PR #3144 / #3147; whichever lands first the
others rebase cleanly (identical content).

Verification:
- cargo test -p librefang-runtime --lib agent_loop: 167 ok
- cargo test -p librefang-kernel --lib trajectory: 14 ok
- cargo clippy -p librefang-runtime -p librefang-kernel
  --all-targets -- -D warnings: clean

Stack: independent, base main. Closes the prompt-caching-system-and-3
plan — system_and_3 stamping (PR-1) plus this PR cover the full
"borrow hermes-agent's cache strategy" scope.

* refactor(types): consolidate cache_hit_ratio onto TokenUsage

Two parallel implementations of the same cache-ratio math (one in
agent_loop with f32/0.0-on-empty, one in trajectory with Option<f32>)
were drifting apart in semantics. Move the authoritative implementation
to TokenUsage::cache_hit_ratio() in librefang-types and have both
callers delegate:

- runtime: tracing log uses .unwrap_or(0.0) for the f32 field, with a
  comment explaining how to disambiguate "no caching" from "0% hit"
  via the creation/read totals.
- kernel: trajectory::compute_cache_hit_ratio() becomes a thin
  re-export; with_cache_hit_ratio builder doc clarified that the
  exporter never sees TokenUsage and call-site wiring is a follow-up.

Test coverage for the math is now centralized in librefang-types
(4 tests), runtime drops its 4 redundant tests, kernel keeps a smoke
test for the re-export plus the builder/serde/legacy-compat tests.
@houko houko mentioned this pull request Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant