Skip to content

MCP: action tools wait for backend ack before returning OK#18

Merged
vdavid merged 5 commits into
mainfrom
feature/mcp-action-ack
May 19, 2026
Merged

MCP: action tools wait for backend ack before returning OK#18
vdavid merged 5 commits into
mainfrom
feature/mcp-action-ack

Conversation

@vdavid
Copy link
Copy Markdown
Owner

@vdavid vdavid commented May 18, 2026

Real QA paper-cut: MCP action tools returned OK the instant they dispatched an event to the FE. If the FE was stalled (modal blocking input, error pane up, race during startup), the action was silently dropped but MCP reported success. The whole point of MCP-driven automation is to trust those OKs; this broke that trust.

Fix: every fire-and-forget action tool now waits for a typed backend ack signal before returning. On timeout (default 1500 ms), the tool returns a typed ToolError::internal whose message names the missing signal and the elapsed budget. Input/output shapes for every tool are unchanged — OK just becomes a meaningful contract.

What changed:

  • New executor/ack.rs with AckSignal enum (GenerationAdvanced, SoftDialogAppeared, WindowAppeared, WindowDisappeared, Any) and a wait_for_ack helper. Polled at 250 ms for state-driven signals (matching the existing await tool), 100 ms for window-driven signals (windows show up faster than full pane state pushes).
  • 1500 ms default budget. Not exposed as a tool param — it's a backend latency budget, not a client knob. Tunable per-call via the Duration argument.
  • Wrapped every fire-and-forget action tool: copy, move, delete, mkdir, mkfile, refresh, select, toggle_hidden, set_view_mode, sort, tab, open_under_cursor, nav_to_parent, nav_back, nav_forward, and the dialog tool's open/close/focus/confirm actions.
  • copy/move/delete with autoConfirm: true ack via GenerationAdvanced; with autoConfirm: false they ack via the matching confirmation soft dialog appearing (transfer-confirmation, delete-confirmation).
  • mkdir / mkfile ack via mkdir-confirmation / new-file-confirmation soft dialogs.
  • open_under_cursor uses Any(GenerationAdvanced, WindowAppeared("viewer")) because directories bump pane state but files open a viewer window.
  • update_pane_tabs now bumps the generation counter too — without it the tab tool would time out on every call since tab pushes bypass set_left/set_right.
  • 11 new unit tests in mcp/tests/ack_system_tests.rs pin down the contract's primitives: generation gate flips on/off correctly, update_pane_tabs bumps generation, SoftDialogTracker ID semantics match what the ack helper checks. The doc-comment matrix at the top of that file explains what unit tests can and can't cover (signal-check primitives, yes; full wait_for_ack against a real Tauri runtime, no — that's the Playwright MCP E2E suite's job).
  • Docs: apps/desktop/src-tauri/src/mcp/CLAUDE.md gets a new "Action-tool ack contract" section, a Decision/Why entry, and an updated "Tool execution is async..." gotcha. docs/tooling/mcp.md explains the new contract for adopting agents.

Test plan

  • cargo check -p cmdr and cargo check -p cmdr --tests: clean.
  • cargo clippy -p cmdr --tests -- -D warnings: clean.
  • cd apps/desktop and pnpm check: svelte-check clean (specta auto-regenerated the doc-comment on updatePaneTabs; no API change).
  • scripts/check.sh --rust against ./apps/desktop/test/smb-servers/start.sh core: all 13 Rust checks pass, including 1878 unit tests (the 11 new ack tests among them) and 29 integration tests.
  • scripts/check.sh --check oxfmt: clean.
  • Manual verification deferred to the Playwright MCP E2E suite — that is the right place to exercise the "FE stalled vs FE responsive" matrix, and it runs against a live app.

vdavid added 3 commits May 18, 2026 22:08
- Real QA paper-cut: action tools returned `OK` the instant they dispatched an event, even when the FE was stalled (modal blocking input, error pane up, race during startup). The action was silently dropped but MCP reported success.
- Introduce `executor/ack.rs` with a typed `AckSignal` enum (`GenerationAdvanced`, `SoftDialogAppeared`, `WindowAppeared`, `WindowDisappeared`, `Any`) and a `wait_for_ack` helper. 1500 ms budget, polled at 250 ms (state) / 100 ms (windows). Not exposed as a tool param — it's a backend latency budget.
- Wrap every fire-and-forget action tool (`copy`, `move`, `delete`, `mkdir`, `mkfile`, `refresh`, `select`, `toggle_hidden`, `set_view_mode`, `sort`, `tab`, `open_under_cursor`, `nav_to_parent`, `nav_back`, `nav_forward`, and `dialog` open/close/focus/confirm). On timeout the tool returns a typed `ToolError::internal` whose message names the missing signal and the elapsed budget.
- `open_under_cursor` uses `Any(GenerationAdvanced, WindowAppeared("viewer"))` because directories bump pane state and files open a viewer window.
- Bump generation in `update_pane_tabs` too — without it the `tab` tool would time out on every call since tab pushes bypass `set_left`/`set_right`.
- Tests: 11 new unit tests pinning down the ack contract's primitives (generation gate flips, `update_pane_tabs` bumps, soft dialog ID matching, FE-stalled stays false). E2E behavior is covered by the existing Playwright MCP suite — only place a "FE actually stalled" scenario can be reproduced faithfully.
- Docs: `mcp/CLAUDE.md` gets a new "Action-tool ack contract" section and a Decision/Why entry; `docs/tooling/mcp.md` documents the new contract for adopting agents.
E2E flagged the right edge case: when the FE re-lists in response to `mcp-refresh` and the new listing is byte-identical to the cached state (common on MTP/SMB right after an operation already pushed fresh state), the FE skips the `update_*_pane_state` call to avoid a redundant generation bump. That left `refresh`'s ack helper waiting for a signal that never arrived, timing out at 1500 ms.

- Revert `execute_refresh` to fire-and-forget with a `// TODO(mcp-ack):` comment documenting the two acceptable follow-ups (round-trip ack, or always-bump-generation on re-list).
- Update both CLAUDE.md and the user-facing `docs/tooling/mcp.md` to call out that `refresh` is the one exception in the contract.
- Every other ack-wrapped tool stays as-is; the smb/mtp E2E suite exercises `copy`/`move`/`delete`/`open_under_cursor`/`move_cursor` and they all passed against the new contract.
Real-MCP testing of the ack contract caught two false negatives the
unit + Playwright coverage missed:

- `dialog close <confirmation>` (mkdir / mkfile / transfer / delete):
  the close path waited on `GenerationAdvanced`, but cancelling a
  confirmation dialog doesn't touch pane state, so the bump never
  arrives. Every cancel returned `ActionNotAcknowledged` even though
  the dialog actually closed.
- `dialog close settings`: the Rust side emitted `mcp-settings-close`
  to the settings webview but `routes/settings/+page.svelte` had no
  listener. Tool returned `ActionNotAcknowledged` AND the window
  stayed open — straight-up broken, not a false negative.

Add a new `AckSignal::SoftDialogDisappeared(id)` that waits for the
`SoftDialogTracker` membership to drop (the `notifyDialogClosed` IPC
from `ModalDialog.svelte` is the trustworthy signal). Wire the
soft-dialog-close path in `dialogs.rs` to it. Add the missing
`mcp-settings-close` listener with the same two-rAF defer as the
production Escape handler, so the close behaves consistently regardless
of trigger.

Verified end-to-end via the real MCP transport: settings open/close
in ~150 ms; mkdir/mkfile/transfer/delete confirmation cancels in
~130–170 ms; all 176 MCP unit tests + clippy + rustfmt + oxfmt +
svelte-check green.
@vdavid vdavid force-pushed the feature/mcp-action-ack branch from 7c87113 to 39f204d Compare May 18, 2026 20:08
vdavid added 2 commits May 19, 2026 00:27
Fixes a batch of issues found while reviewing the original ack-contract
PR end-to-end via real MCP traffic.

- `dialog close about` now waits for `SoftDialogDisappeared("about")`
  instead of `WindowDisappeared("about")`. The about dialog is a soft
  overlay, not a Tauri window, so the prior signal was always
  immediately true and the close was effectively fire-and-forget. Now
  it ack's on the tracker losing the id; absent → instant OK, present
  → wait for the FE to close it.
- `dialog close file-viewer` by path no longer hangs on the
  ALL-viewers-gone signal. Added `AckSignal::WindowCountBelow {
  prefix, threshold }` + `snapshot_window_count` helper, so the tool
  snapshots the viewer count, dispatches, and ack's when the count
  drops by one. Close-all uses `threshold: 1`. Fast-fail with
  `invalid_params` when zero viewers are open instead of timing out.
- `open_under_cursor` no longer false-times-out on files. The OS-open
  branch produces neither a state push nor a viewer window, so the
  previous `Any([GenerationAdvanced, WindowAppeared("viewer")])`
  signal would always time out at 5 s. Routed through
  `mcp_round_trip` instead: FE awaits `handleNavigate(entry)` and
  signals completion explicitly. Removed the now-unused `AckSignal::Any`
  variant; round-trip is the only honest ack for multi-mode commands.
- `nav_to_parent` / `nav_back` / `nav_forward` get a `NAV_ACK_TIMEOUT`
  of 5 s (up from 1500 ms) because a parent/back jump can land on an
  SMB or MTP path whose directory listing takes a few seconds even
  on success.
- `update_pane_tabs` now delegates to `PaneStateStore::set_tabs`, a
  new helper that owns both the tab-list write and the generation bump.
  Eliminates the inline `pane.write().unwrap().tabs = ...` +
  `generation.fetch_add(1, Relaxed)` pair that a future contributor
  would forget to keep in sync.
- Documented the `GenerationAdvanced` TOCTOU caveat (an unrelated
  state push between snapshot and dispatch can satisfy the signal) and
  the `SoftDialogDisappeared` test-context asymmetry (returns true
  when the tracker isn't registered, so close paths don't need a
  fixture).
- `mcp/CLAUDE.md`, `docs/tooling/mcp.md`, the ack-signal table, and
  the round-trip list updated. `bindings.ts` regenerated for the
  refreshed `update_pane_tabs` doc-comment.

End-to-end verified via the real MCP transport: dialog open/close for
settings/about/file-viewer (single and multi-viewer); mkdir/mkfile
cancel; copy/move/delete autoConfirm and manual; tab new/close;
toggle_hidden/set_view_mode/sort; nav_to_parent/back/forward;
open_under_cursor on both a .txt file (OS-opens) and a directory
(navigates). All Rust checks (1907 unit + 30 integration tests,
clippy, rustfmt, cargo-deny, cargo-audit) and Svelte (svelte-check,
1928 vitest tests, oxfmt) green.
oxfmt picked these up while running checks on a feature branch:
re-wraps long lines and aligns the `scripts/check/CLAUDE.md` table's
column padding. No semantic changes.
@vdavid vdavid merged commit 9aa13ad into main May 19, 2026
6 of 8 checks passed
@vdavid vdavid deleted the feature/mcp-action-ack branch May 19, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant