Skip to content

fix(cua-driver): advertise refusals in the MCP outputSchema - #2968

Merged
f-trycua merged 1 commit into
mainfrom
fix/mcp-refusal-output-schema-conformance
Aug 7, 2026
Merged

fix(cua-driver): advertise refusals in the MCP outputSchema#2968
f-trycua merged 1 commit into
mainfrom
fix/mcp-refusal-output-schema-conformance

Conversation

@f-trycua

@f-trycua f-trycua commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Problem

Action tools (click, press_key, type_text, hotkey, and the rest of the ACTION_RESULT_TOOLS set) declare an outputSchema describing only the success shape — closed, required: ["effect","route"]. But MCP holds every structuredContent a tool emits to that schema, including the payload that accompanies isError: true.

Refusals answer with a diagnostic envelope instead. Both shapes below are verbatim captures from a live cua-driver mcp stdio session:

{"refusal": {"code": "stale_element_token",
             "message": "element_token is stale; call get_window_state again to refresh"},
 "status": "refused"}
{"candidates": [], "code": "window_target_not_found", "effect": "refused", "pid": 999999}

Neither validates: missing effect/route, plus undeclared keys. Strict clients reject the entire response with -32602 and the actionable message never reaches the caller.

Impact

Found while dogfooding a local model driving Calculator through opencode + cua-driver. The driver did its job — it said "element_token is stale; call get_window_state again to refresh". The agent saw only:

MCP error -32602: Structured content does not match the tool's output schema:
  data must have required property 'effect', data must have required property 'route',
  data must NOT have additional properties, data must NOT have additional properties

With no signal to re-snapshot, it concluded the accessibility route was broken, abandoned it, burned ~15 steps on pixel-coordinate guessing, and finished with a confident and wrong root-cause analysis. This degrades every agent on the driver, not just local models — the better the error message we write, the more it costs us to have it discarded.

Fix

Advertise the success shape beside the refusal envelope as an anyOf.

  • No runtime behaviour changes. Not one response payload moves; only the declared schema widens to describe what the driver already sends.
  • Success stays strict. The success variant is byte-for-byte the existing closed ActionResult schema. An unknown key on a success payload is still a contract violation and cannot be laundered through the permissive refusal variant — asserted in the new test.
  • SDK generation untouched. The generated contract manifest keeps carrying success_output_schema unwrapped; only the live MCP surface advertises the wrapper. The field was already named success_output_schema, so the codebase always knew this schema described one half of the contract.

Verification

Wire-tested against a rebuilt daemon on a private socket:

advertised variants: 2
  [0] success  required: ['effect', 'route']  additionalProperties: false
  [1] refusal  markers: [['refusal'], ['status'], ['code']]

refusal isError: True
refusal text  : element_token is stale; call get_window_state again to refresh
refusal struct: {"refusal": {...}, "status": "refused"}

New regression test runs both live refusal captures and a success payload through a real JSON Schema validator, the way a strict MCP client does, and asserts a malformed success payload still fails.

Suites run locally, all green:

Suite Result
cua-driver-contract --lib 29 passed
cua-driver-core --lib 497 passed
protocol_schema_test 3 passed
schema_consistency_test 2 passed
protocol_element_token_test 4 passed
compatibility_contract_test 3 passed
embedded_host_sdk_mcp_test 5 passed

cargo fmt applied; cargo clippy surfaces no new warnings.

Docs

docs/content/docs/reference/cua-driver/contracts.mdx gains a What a tool returns section documenting both variants, the refusal marker keys, and a callout telling agent authors to branch on refusal.code and surface the content text — stale_element_token means re-snapshot and retry, not that the route is dead.

🤖 Generated with Claude Code

Action tools declared an `outputSchema` describing only the success shape,
but MCP holds every `structuredContent` a tool emits — including the payload
that accompanies `isError: true` — to that schema. Refusals answer with a
diagnostic envelope instead:

    {"status":"refused","refusal":{"code":"stale_element_token","message":…}}
    {"code":"window_target_not_found","effect":"refused","candidates":[],"pid":…}

Neither validates against the closed ActionResult schema, so strict clients
rejected the whole response with -32602 and the actionable message never
reached the caller. Observed against opencode: the driver correctly said
"element_token is stale; call get_window_state again to refresh" and the agent
saw only a schema-validation error. With no signal to re-snapshot it abandoned
the accessibility route entirely and fell back to blind pixel clicking.

Advertise the success shape beside the refusal envelope as an `anyOf`. Runtime
behaviour is unchanged — no response payload moves — and the success variant
stays closed, so unknown keys on a success payload are still a contract
violation and cannot be laundered through the permissive variant. The generated
contract manifest keeps carrying `success_output_schema` unwrapped, so SDK
generation is untouched.

Regression test validates both live refusal captures and a success payload
against the advertised schema with a real JSON Schema validator, the way a
strict MCP client does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@f-trycua
f-trycua force-pushed the fix/mcp-refusal-output-schema-conformance branch from 3430ff9 to 6440594 Compare August 7, 2026 10:33
@f-trycua
f-trycua merged commit dc6f32c into main Aug 7, 2026
25 checks passed
@f-trycua
f-trycua deleted the fix/mcp-refusal-output-schema-conformance branch August 7, 2026 10:51
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