Skip to content

Commit 49feaef

Browse files
committed
fix(server): defensive copy before hook call eliminates in-place mutation 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
1 parent a7afcbb commit 49feaef

3 files changed

Lines changed: 48 additions & 13 deletions

File tree

src/adcp/decisioning/serve.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -452,8 +452,9 @@ def serve(
452452
)
453453
454454
Hook exceptions surface as ``INVALID_REQUEST`` on the wire.
455-
The hook must return a new dict; mutating the input in-place is
456-
a bug — the original is captured separately for context echo.
455+
The hook receives a shallow copy of the wire args, so it may
456+
mutate its argument freely or return a new dict — either style
457+
is safe. Context echo always reflects the original wire input.
457458
"""
458459
# Local import to avoid a circular at module-load time. Adopter
459460
# serves never run during foundation imports anyway.

src/adcp/server/mcp_tools.py

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1901,17 +1901,25 @@ def create_tool_caller(
19011901
dispatcher-level enforcement.
19021902
19031903
**Pre-validation hook (issue #614).** When ``pre_validation_hook`` is
1904-
supplied, it is called with ``(tool_name, raw_args_dict)`` and must
1905-
return a (possibly modified) ``dict`` that replaces the wire args
1906-
before schema validation and Pydantic ``model_validate`` run. Use
1907-
this to apply spec-mandated defaults for pre-v3 buyers that omit
1908-
required fields (e.g. ``buying_mode``, ``format_id`` shape coercion,
1909-
``asset_type`` inference). The hook runs on every call; keep it fast.
1904+
supplied, it is called with ``(tool_name, shallow_copy_of_args)`` and
1905+
must return a ``dict`` that replaces the wire args before schema
1906+
validation and Pydantic ``model_validate`` run. The framework passes
1907+
a shallow copy of the incoming params dict, so the hook may mutate
1908+
its argument freely or return a brand-new dict — either style is safe.
1909+
The original wire params are captured before the copy is made, so
1910+
context echo always reflects what the buyer sent. Use this to apply
1911+
spec-mandated defaults for pre-v3 buyers that omit required fields
1912+
(e.g. ``buying_mode``, ``format_id`` shape coercion, ``asset_type``
1913+
inference). The hook runs on every call; keep it fast.
19101914
Exceptions from the hook surface as ``INVALID_REQUEST`` — do not raise
19111915
for missing-but-defaultable fields, only for structurally unusable args.
1912-
The hook must return a new dict (or the original unchanged); mutating
1913-
the input dict in-place is a bug — the original is captured separately
1914-
for the context-echo path.
1916+
1917+
.. note::
1918+
For the specific case of buyers omitting ``account``, see issue
1919+
#623 ("Typed dispatcher rejects valid request when ``account`` is
1920+
omitted") — that will be the canonical spec-level fix for that
1921+
field. Once #623 lands you can drop any ``account`` placeholder
1922+
hook entry.
19151923
19161924
Args:
19171925
handler: The ADCP handler instance
@@ -1960,7 +1968,7 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None)
19601968

19611969
if pre_validation_hook is not None:
19621970
try:
1963-
params = pre_validation_hook(method_name, params)
1971+
params = pre_validation_hook(method_name, dict(params))
19641972
except Exception as exc:
19651973
raise ADCPTaskError(
19661974
operation=method_name,

tests/test_pre_validation_hooks.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
- pre_validation_hooks=None (default) is a no-op (hot path unchanged).
88
- A hook for tool X is not called when tool Y is dispatched.
99
- Hook runs before validate_request in strict validation mode.
10-
- The hook must not mutate raw_params used for context echo.
10+
- In-place mutation of hook args is safe (framework passes a shallow copy).
1111
"""
1212

1313
from __future__ import annotations
@@ -173,6 +173,32 @@ def stripping_hook(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
173173
assert result.get("context") == wire_context
174174

175175

176+
@pytest.mark.asyncio
177+
async def test_in_place_mutation_is_safe_for_context_echo() -> None:
178+
"""Hook that mutates its argument in-place must not corrupt context echo.
179+
180+
The framework passes a shallow copy to the hook, so in-place mutation
181+
of the hook argument leaves the original wire params untouched for the
182+
context-echo path. This test exercises the ``args["key"] = val; return args``
183+
pattern that the original docstring labelled a "bug".
184+
"""
185+
wire_context = {"correlation_id": "req-xyz"}
186+
wire_args = {"buyer_field": "original", "context": wire_context}
187+
188+
def mutating_hook(tool_name: str, args: dict[str, Any]) -> dict[str, Any]:
189+
args["server_default"] = "injected"
190+
del args["buyer_field"]
191+
return args
192+
193+
handler = _MinimalHandler()
194+
caller = create_tool_caller(handler, "get_products", pre_validation_hook=mutating_hook)
195+
result = await caller(dict(wire_args))
196+
197+
assert result["params_received"].get("server_default") == "injected"
198+
assert "buyer_field" not in result["params_received"]
199+
assert result.get("context") == wire_context
200+
201+
176202
# ---------------------------------------------------------------------------
177203
# MCPToolSet threading
178204
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)