Skip to content

Commit a7afcbb

Browse files
claudebokelley
authored andcommitted
fix(server): snapshot raw_params before hook to preserve context-echo 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
1 parent b31f335 commit a7afcbb

2 files changed

Lines changed: 27 additions & 17 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1956,6 +1956,8 @@ def create_tool_caller(
19561956
async def call_tool(params: dict[str, Any], context: ToolContext | None = None) -> Any:
19571957
ctx = context if context is not None else ToolContext()
19581958

1959+
raw_params = params # Preserve original wire params for context echo.
1960+
19591961
if pre_validation_hook is not None:
19601962
try:
19611963
params = pre_validation_hook(method_name, params)
@@ -1970,8 +1972,6 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
19701972
],
19711973
) from exc
19721974

1973-
raw_params = params # Preserve the (possibly hook-modified) dict for context echo.
1974-
19751975
if request_mode is not None and request_mode != "off":
19761976
outcome = validate_request(method_name, params)
19771977
if not outcome.valid:

tests/test_pre_validation_hooks.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -142,25 +142,35 @@ def bad_hook(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
142142

143143

144144
@pytest.mark.asyncio
145-
async def test_hook_return_does_not_mutate_original_dict() -> None:
146-
"""The hook must return a new dict; the original must remain unmodified.
147-
148-
The context-echo path uses raw_params after the handler returns — if
149-
the hook mutated the input in-place and then also returned it, the
150-
echo would include server-injected fields as if the buyer had sent
151-
them, violating the AdCP context-echo contract.
145+
async def test_hook_does_not_pollute_context_echo() -> None:
146+
"""raw_params must snapshot the original wire dict BEFORE the hook runs.
147+
148+
inject_context echoes the wire ``context`` field from raw_params back into
149+
the response. If raw_params were assigned after the hook, a hook that
150+
returns a new dict (dropping ``context``) would silently suppress the echo.
151+
Conversely, a hook that adds keys would cause server-injected fields to
152+
appear in the echo as if the buyer sent them.
153+
154+
We exercise both directions:
155+
- A hook that strips all fields and adds "server_default" (no context key
156+
in its return) still produces context echo from the original wire params.
157+
- The handler result carries hook-modified fields, confirming the hook ran.
152158
"""
153-
original = {"buyer_field": "x"}
159+
wire_context = {"correlation_id": "req-abc"}
160+
wire_args = {"buyer_field": "x", "context": wire_context}
154161

155-
def mutating_hook(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
156-
# Return a new dict; original is untouched
157-
return {**args, "server_default": "y"}
162+
def stripping_hook(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
163+
# Returns a brand-new dict — deliberately omits "context"
164+
return {"server_default": "y"}
158165

159166
handler = _MinimalHandler()
160-
caller = create_tool_caller(handler, "get_products", pre_validation_hook=mutating_hook)
161-
await caller(dict(original)) # pass a copy to preserve the sentinel
162-
# original dict (the buyer's view) must not include server_default
163-
assert "server_default" not in original
167+
caller = create_tool_caller(handler, "get_products", pre_validation_hook=stripping_hook)
168+
result = await caller(dict(wire_args))
169+
170+
# Hook ran: handler received hook-modified params, not original
171+
assert result["params_received"] == {"server_default": "y"}
172+
# Context echo used raw_params (pre-hook snapshot), not hook return
173+
assert result.get("context") == wire_context
164174

165175

166176
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)