fix(cua-driver): advertise refusals in the MCP outputSchema - #2968
Merged
Conversation
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
force-pushed
the
fix/mcp-refusal-output-schema-conformance
branch
from
August 7, 2026 10:33
3430ff9 to
6440594
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Action tools (
click,press_key,type_text,hotkey, and the rest of theACTION_RESULT_TOOLSset) declare anoutputSchemadescribing only the success shape — closed,required: ["effect","route"]. But MCP holds everystructuredContenta tool emits to that schema, including the payload that accompaniesisError: true.Refusals answer with a diagnostic envelope instead. Both shapes below are verbatim captures from a live
cua-driver mcpstdio 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-32602and 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:
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.ActionResultschema. 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.success_output_schemaunwrapped; only the live MCP surface advertises the wrapper. The field was already namedsuccess_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:
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:
cua-driver-contract --libcua-driver-core --libprotocol_schema_testschema_consistency_testprotocol_element_token_testcompatibility_contract_testembedded_host_sdk_mcp_testcargo fmtapplied;cargo clippysurfaces no new warnings.Docs
docs/content/docs/reference/cua-driver/contracts.mdxgains a What a tool returns section documenting both variants, the refusal marker keys, and a callout telling agent authors to branch onrefusal.codeand surface thecontenttext —stale_element_tokenmeans re-snapshot and retry, not that the route is dead.🤖 Generated with Claude Code