feat(server): pre-validation request hook for spec-default injection (#614)#629
feat(server): pre-validation request hook for spec-default injection (#614)#629
Conversation
Adopter confirmation (filed #614)Read the diff. This kills our core/middleware/spec_defaults.py (273 LOC of JSON-RPC body bytes-rewriting). Migration is straightforward — every default we apply maps to a single hook callable. Migration sketch on our side```python Where each callable is a small pure function — same logic as today's bytes-level patchers, just operating on a Python dict instead of decoded JSON bytes. Net: ~50 LOC of helpers replacing 273 LOC of ASGI middleware. One design questionThe hook signature is `(tool_name, raw_args) -> raw_args` — fine. But the PR docstring says "The hook must return a new dict; mutating the input in-place is a bug — the original is captured separately for context echo." This is a footgun. `{**args, "buying_mode": "brief"}` is correct; `args["buying_mode"] = "brief"; return args` looks correct but breaks context echo silently. Two options to mitigate:
Option 1 is the cleaner adopter contract — let people mutate freely, the framework guarantees context-echo safety. The cost is one dict shallow-copy per call which is negligible relative to schema validation right after. Cross-link with #623#623 ("Typed dispatcher rejects valid request when `account` is omitted") is the spec-correct fix for one of the specific cases this hook works around. They're complementary: this hook is the general-purpose adopter API for ALL spec defaults; #623 is the canonical fix for one specific buyer-omission case. Adopters running this hook for `account` placeholder injection can drop that hook entry once #623 lands. Worth a docstring note pointing readers there. Ready to adoptOnce this lands we'll bump and migrate in one PR. Will report back on whether the migration matches the predicted ~50 LOC delta. |
deaa4f8 to
a7afcbb
Compare
…tion footgun Pass dict(params) to pre_validation_hook so hooks that mutate their argument in-place still leave raw_params (the context-echo snapshot) untouched. The "must return a new dict" restriction is removed from docstrings; either mutation style is now safe. Adds test_in_place_mutation_is_safe_for_context_echo to prove the invariant. Adds a docstring cross-reference to #623 for the account-omission case per adopter feedback on #629. Co-authored-by: bokelley <bokelley@users.noreply.github.com> https://claude.ai/code/session_0115Pruuy4MbdaxhPvgTBFoo
|
Good catch on the footgun — Option 1 applied in 49feaef.
Docstrings in Also added the #623 cross-reference you suggested: adopters using a hook to inject an Your migration sketch matches — Generated by Claude Code |
…tion footgun Pass dict(params) to pre_validation_hook so hooks that mutate their argument in-place still leave raw_params (the context-echo snapshot) untouched. The "must return a new dict" restriction is removed from docstrings; either mutation style is now safe. Adds test_in_place_mutation_is_safe_for_context_echo to prove the invariant. Adds a docstring cross-reference to #623 for the account-omission case per adopter feedback on #629. Co-authored-by: bokelley <bokelley@users.noreply.github.com> https://claude.ai/code/session_0115Pruuy4MbdaxhPvgTBFoo
49feaef to
83013d1
Compare
…tion footgun Pass dict(params) to pre_validation_hook so hooks that mutate their argument in-place still leave raw_params (the context-echo snapshot) untouched. The "must return a new dict" restriction is removed from docstrings; either mutation style is now safe. Adds test_in_place_mutation_is_safe_for_context_echo to prove the invariant. Adds a docstring cross-reference to #623 for the account-omission case per adopter feedback on #629. Co-authored-by: bokelley <bokelley@users.noreply.github.com> https://claude.ai/code/session_0115Pruuy4MbdaxhPvgTBFoo
83013d1 to
bbccd1f
Compare
Adds `pre_validation_hooks` to `serve()`, `create_mcp_server()`, `create_a2a_server()`, and `create_tool_caller()`. The per-tool hook runs on the raw wire dict before `validate_request` (schema) and `model_validate` (Pydantic), so it can supply spec-mandated defaults that absent pre-v3 buyers omit. Hook exceptions surface as INVALID_REQUEST. Includes 7 new pytest tests. Closes #614 https://claude.ai/code/session_015hHQqXRbn2jX9WTu564gjZ
… contract Both code-reviewer and dx-expert expert review flagged that assigning raw_params after the hook would echo server-injected defaults back to the buyer as if they were sent on the wire, violating the AdCP context-echo contract. Move the snapshot to before the hook call (raw_params = params), so inject_context always uses the original wire dict regardless of what the hook returns or mutates. Also strengthens test_hook_does_not_pollute_context_echo: the previous test passed a copy of the outer dict so the assertion was trivially true. The new test sends a wire payload with a context field, uses a stripping hook that returns a completely new dict (no context key), and asserts the response context echo still carries the original wire context — only possible if raw_params was captured before the hook ran. https://claude.ai/code/session_015hHQqXRbn2jX9WTu564gjZ
…tion footgun Pass dict(params) to pre_validation_hook so hooks that mutate their argument in-place still leave raw_params (the context-echo snapshot) untouched. The "must return a new dict" restriction is removed from docstrings; either mutation style is now safe. Adds test_in_place_mutation_is_safe_for_context_echo to prove the invariant. Adds a docstring cross-reference to #623 for the account-omission case per adopter feedback on #629. Co-authored-by: bokelley <bokelley@users.noreply.github.com> https://claude.ai/code/session_0115Pruuy4MbdaxhPvgTBFoo
bbccd1f to
8330283
Compare
Closes #614
What this adds
pre_validation_hooks: dict[str, Callable[[str, dict], dict]] | Noneon every public entry point —serve(),create_mcp_server(),create_a2a_server(),MCPToolSet,create_mcp_tools(), andcreate_tool_caller().The per-tool hook runs on the raw wire dict before
validate_request(JSON schema) andmodel_validate(Pydantic), so it can supply spec-mandated defaults for pre-v3 buyers that omit required fields. This eliminates the 273-line ASGI middleware workaround the issue author ships today.Signature:
(tool_name: str, raw_args: dict) -> dictQuick example:
Behaviour guarantees:
Nonedefault → zero overhead on the hot path (no branch entered)ADCPTaskError(code="INVALID_REQUEST")— neverINTERNAL_ERRORraw_paramssnapshotted before hook call → context echo always reflects original wire input, not server-injected defaultscreate_tool_caller()closures at registration time, not at call timeFiles changed
src/adcp/server/mcp_tools.pypre_validation_hookparam tocreate_tool_caller(),MCPToolSet,create_mcp_tools()src/adcp/server/serve.pypre_validation_hooksthroughServeConfig,serve(),_serve_mcp/a2a,create_mcp_server(),_register_handler_tools()src/adcp/server/a2a_server.pyADCPAgentExecutor.__init__()andcreate_a2a_server()src/adcp/decisioning/serve.pyserve()tests/test_pre_validation_hooks.pyTested
pytest tests/ -q→ 4161 passed, 0 new failures (pre-existingtest_real_tls_handshake_still_validates_hostnameexcluded — fails on main too)mypy src/adcp/→ Success: no issues found in 783 source filesruff check src/→ All checks passedPre-PR expert review
Two expert agents (code-reviewer + dx-expert) reviewed the diff independently. One blocker was found and fixed before this PR was opened:
BLOCKER (fixed):
raw_params = paramswas originally assigned after the hook ran, meaning server-injected defaults (e.g."buying_mode": "brief") would be echoed back to the buyer as if they sent them — a direct violation of the AdCP context-echo contract. Fixed by moving the snapshot to before the hook call. The fix is covered bytest_hook_does_not_pollute_context_echo, which sends a wire payload with acontextfield, uses a stripping hook that returns a completely new dict (nocontextkey), and asserts the response still echoes the original wire context.NITs (not fixed, left for reviewer discretion):
ServeConfiganda2a_server.pyusedict[str, Callable[..., Any]];mcp_tools.py/MCPToolSetuse the precisedict[str, Callable[[str, dict[str, Any]], dict[str, Any]]]— the looser type at outer layers loses IDE completionsPreValidationHooktype alias exported fromadcp.server(compare:SkillMiddlewareis exported); adopters writing typed code must spell out the full callable signatureThis PR was opened by the triage automation for issue #614. A human reviewer should confirm the hook ordering and context-echo invariants before merging.
https://claude.ai/code/session_015hHQqXRbn2jX9WTu564gjZ
Generated by Claude Code