fix(arcade-mcp-server): make invalid tool input errors actionable and stop leaking internals - #913
Draft
HBX814 wants to merge 2 commits into
Draft
fix(arcade-mcp-server): make invalid tool input errors actionable and stop leaking internals#913HBX814 wants to merge 2 commits into
HBX814 wants to merge 2 commits into
Conversation
Codecov Report❌ Patch coverage is
🚀 New features to boost your workflow:
|
…errors Input validation failures are version-gated: 2025-11-25 clients receive a CallToolResult, 2025-06-18 clients a JSON-RPC -32602. The legacy branch built that response with `str(error)`, which renders the entire ToolCallError pydantic model, so `developer_message` and `stacktrace` were shipped to the client as `field=value` pairs. For an input-validation error the stacktrace is a Pydantic traceback, and Pydantic embeds `input_value=` in it — so the rejected argument was echoed back verbatim. `_serialize_input` deliberately keeps rejected values out of `message` and `developer_message` because they may hold secrets or PII, and `_debug_exposure` exists so stacktraces reach a client only behind an explicit opt-in env flag. This branch bypassed both. Use the curated `error.message`, keep `additional_prompt_content` (caller-facing guidance, not an internal), and route internals through `augment_error_message_for_debug`, matching the 2025-11-25 branch, so the debug flags remain the only way to expose internals. The test reads the flag names and activation acknowledgement from `_debug_exposure` instead of restating them, so the ack string stays confined to the allowlist in scripts/check_debug_leak_flags_off.py. Refs: ArcadeAI#703
An input validation failure reported only what was wrong ("age: Input should
be a valid integer") and left the caller to go re-read the schema to work out
what the tool actually wanted.
Append an "Expected:" block describing the declared shape of the parameters
that were rejected — type, required/optional, description, and the allowed
values for closed sets — followed by an explicit next step.
The block is built strictly from the tool's own ToolDefinition, never from the
submitted values, preserving the existing guarantee that rejected input never
reaches the surfaced message. Only rejected parameters are described: the
caller already has the full schema from tools/list, so repeating it on every
failure would bury the actionable part. The leading
"Invalid input: <field>: <reason>" summary is unchanged, so existing callers
matching on it keep working.
The helper degrades to an empty string whenever it cannot describe anything —
no definition, an unrecognized field, or a model-level error with no field
location — so enrichment can never turn a validation error into a crash.
Refs: ArcadeAI#703
HBX814
force-pushed
the
fix/actionable-invalid-input-errors
branch
from
August 24, 2026 20:18
71b4384 to
a609815
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.
Summary
Two changes to the invalid-tool-input path, found while auditing #703.
1. The legacy branch shipped error internals to the client. Input validation failures are version-gated: 2025-11-25 clients get a
CallToolResult, 2025-06-18 clients get a JSON-RPC-32602. The legacy branch built that response withstr(error)— which renders the wholeToolCallErrorpydantic model, sodeveloper_messageandstacktracewent out asfield=valuepairs.That matters more than cosmetics. For an input-validation error the stacktrace is a Pydantic traceback, and Pydantic embeds
input_value=in it, so the rejected argument was echoed back verbatim._serialize_inputcarries an explicit comment that rejected values must stay out of the surfaced fields because they may hold secrets or PII, and_debug_exposureexists so stacktraces reach a client only behind an opt-in env flag. This branch bypassed both.2. Invalid-input errors now say what the tool expects — the "Invalid tool input — show expected shape" bullet from #703.
Before:
After:
Refs: #703 — deliberately not
Resolves: this covers the one error category of that issuethat had not already been converted. See Scope for what remains.
Design decisions
The guidance is built from the
ToolDefinition, never from the submitted values. #703 phrases this category as "show expected shape vs received", but echoing received values is exactly what_serialize_inputis written to avoid — a naive reading of the issue would reintroduce the leak that change 1 above removes. So only the declared schema is rendered. Two tests pin this by passing a sentinel value and asserting it appears nowhere.Only the rejected parameters are described. The caller already received the full schema from
tools/list; repeating all of it on every failure buries the actionable part and grows unbounded with the tool's arity. The block is bounded by the number of errors instead.The leading
Invalid input: <field>: <reason>summary is unchanged, and guidance is appended after it. Existing tests (and any caller) match on that prefix, so it stays byte-identical; nothing is spliced into it._serialize_input's newdefinitionparameter is positional-only (/).**kwargsholds caller-supplied tool arguments, and a tool is free to declare a parameter nameddefinition— orinput_model. Positional-only placement keeps such an argument inkwargsinstead of raising "got multiple values for argument". This also closes the same latent collision that already existed forinput_model.definitionis optional and the helper returns""when it cannot describe anything. Validation still works without a definition, so this is enrichment rather than a new requirement, and callers can append unconditionally.Change 1 keeps
additional_prompt_contentand routes internals throughaugment_error_message_for_debugrather than dropping them, so the documented debug escape hatch still works on the legacy path — it just becomes the only way to expose internals there. Two tests assert the flags still surface[DEBUG] stacktrace:/[DEBUG] developer_message:.Scope
In scope: the "invalid tool input" category of #703, plus the internals leak found on the same path.
Not in scope:
✗ … To fix:shape the issue asked for. I only touched the category that hadn't been converted. Worth a maintainer confirming whether Audit error messages for actionable fix instructions #703 should stay open for the rest._check_and_warn_missing_secrets) still says only "declares secret(s) 'X' which is/are not set. It will return an error if called." — no fix instructions, while the runtime error for the same condition has them. Small consistency gap, deliberately left out to keep this diff reviewable.uvicorn.run()surfaces the rawOSError. That needs a different area of the code and reads better as its own PR._log_tool_call_error/_record_tool_error_span_attributes, which the 2025-11-25 branch calls. That looks like an oversight, but changing logging/telemetry behavior is outside a message fix, so I left it alone rather than widen the blast radius.Test plan
Two new files, 25 tests, written before the implementation, per the repo's TDD requirement.
message="[TOOL_RUNTIME_BAD_INPUT_VALUE] ToolInputError … Invalid input: tags…=str]\n For further information visit https://errors.pydantic.dev/2.13/v/list_type\n' status_code=400 extra=None, i.e. the full model repr with the Pydantic error inside.messageanddeveloper_messagebut present instacktrace, and therefore present instr(error)— which is what the legacy branch sent.libs/tests/arcade_mcp_server/test_invalid_input_legacy_protocol.py(8): rejected value not echoed; noTraceback; nokind=/developer_message=/can_retry=/status_code=model-repr fields; message still actionable; both debug flags still work; modern path unchanged.libs/tests/core/test_invalid_input_guidance.py(17): expected shape, description, enum allowed-values, array element type, only-rejected-fields, next-step line, two no-leak tests, three backward-compatibility tests, and five covering the helper's quiet-degradation paths (no definition, no rejected fields, a definition withoutparameters, an unrecognized field, and a model-level error with no field location).needs_a_list, but Arcade derivesNeedsAList, so the server answered "Unknown tool" and three absence-based assertions passed vacuously._legacy_error_textnow asserts the call actually reached input validation, so that failure mode cannot recur.libs/tests/run: 3225 passed, 532 skipped (3200 before, +25 new). No regressions — notablytest_executor.py'sstartswith("… Invalid input: inp:")assertions andtest_input_validation_error_does_not_leak_input_valuesstill pass.arcade_mcp_server/integration/test_end_to_end.py(test_stdio_e2e,test_http_e2e) are pre-existing onmain— verified by stashing this change and re-running — a local server-spawn/port issue on Windows.pre-commit run -a(what CI'squalityjob runs) fully clean, including thecheck-debug-leak-flagsguard;ruff checkandruff format --checkclean;mypyclean onarcade-core(33 files) andarcade-mcp-server(51 files).arcade-core4.11.0 → 4.12.0 (additive behavior change),arcade-mcp-server1.26.0 → 1.26.1 (defect fix).test_dependency_alignment.pypasses; the root constraints (arcade-core>=4.9.0,arcade-mcp-server>=1.23.0) already admit both, and no dependency floor needed raising since neither change is breaking.Note on the first CI run
The first push failed
qualityandDebug leak flag guard, both from one mistake of mine: the legacy test hardcoded the debug-flag activation acknowledgement string, whichscripts/check_debug_leak_flags_off.pyforbids outside a small allowlist. It passed locally only because that guard scansgit ls-filesand the file was still untracked when I ran the hooks.Rather than widen the allowlist, the test now reads
_DEBUG_LEAK_MAGIC,_ENV_EXPOSE_STACKTRACE, and_ENV_EXPOSE_DEVELOPER_MESSAGEfrom_debug_exposure, so the ack string stays confined to the files already permitted to contain it — and the constants have a single source of truth. Verified by running the guard script directly with the file staged, and bypre-commit run -a.Codecov also flagged 4 uncovered patch lines. All four were defensive branches in the new helper plus the
additional_prompt_contentbranch; they are now covered by the added tests. The lines still uncovered inexecutor.py(111, 115, 140-141) andserver.py(1627) are pre-existing, outside this diff.Risk note
Change 1 touches how tool errors are surfaced, which is reachable from
context.get_secret()-adjacent flows, so worth stating precisely:field=valuepairs out of that string would break — but that string was never a documented contract, and the sibling protocol version never emitted it.ARCADE_DEBUG_EXPOSE_*is set to the ack value, on both paths.ToolDefinitionis available, and no rejected value can enter it by construction.