Skip to content

Commit 9818250

Browse files
bokelleyclaude
andauthored
feat(server): add force_create_media_buy_arm + force_task_completion controller scenarios (#282)
* feat(server): add force_create_media_buy_arm + force_task_completion controller scenarios Adds two new comply_test_controller scenarios for AdCP 3.0.1 storyboard parity. Sellers running the create_media_buy_async.yaml storyboard suite against a Python reference seller now grade `passing` rather than `not_applicable` on the submitted-arm phase. - Adds `force_create_media_buy_arm` and `force_task_completion` to SCENARIOS, TestControllerStore abstract base, and the dispatcher in _handle_test_controller. - Validates arm enum, conditional task_id-when-submitted, char limits, 256 KB result cap, and whitespace task_id stripping. - Updates register_test_controller inline schema (derived from SCENARIOS to prevent drift) and mcp_tools.py ADCP_TOOL_DEFINITIONS enum to include both. - Adds account field to both inline schemas so storyboard runners can drive cross-account isolation. - 20 new tests at parity with Node training-agent nine-test pattern. Closes #281 https://claude.ai/code/session_01KaGEJKsjnTEuLF6qnaRFqQ * fix(server): strip task_id for input-required arm; add coverage test Forced.extra='forbid' in the comply_test_controller response schema means a store that echoes task_id on arm='input-required' would produce an invalid Forced object. The dispatcher now nullifies task_id before the store call when arm='input-required', preventing protocol drift regardless of store implementation. Adds one test: test_arm_task_id_stripped_for_input_required. https://claude.ai/code/session_01KaGEJKsjnTEuLF6qnaRFqQ * refactor(server): address pre-PR review feedback - Extract _accepts_kwarg(method, name) so both context and account pass-through share one signature-inspection impl; _accepts_context_kwarg delegates to it. - Gate account kwarg via _accepts_kwarg in the shared `extra` dict so stores that omit account= don't receive an unexpected keyword and silently fall to INTERNAL_ERROR. - Replace len(str(message)) guard with isinstance + len for consistency with task_id handling. - Import SCENARIOS from test_controller in mcp_tools.py so the comply_test_controller inputSchema enum is always derived from the canonical list and can't drift on the next scenario addition. https://claude.ai/code/session_01KaGEJKsjnTEuLF6qnaRFqQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f02ea79 commit 9818250

3 files changed

Lines changed: 572 additions & 51 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from typing import Any
2525

2626
from adcp.server.base import ADCPHandler, ToolContext
27+
from adcp.server.test_controller import SCENARIOS as _CONTROLLER_SCENARIOS
2728
from adcp.validation.client_hooks import ValidationHookConfig
2829

2930
logger = logging.getLogger(__name__)
@@ -842,22 +843,14 @@
842843
"account": {"type": "object"},
843844
"scenario": {
844845
"type": "string",
845-
"enum": [
846-
"list_scenarios",
847-
"force_creative_status",
848-
"force_account_status",
849-
"force_media_buy_status",
850-
"force_session_status",
851-
"simulate_delivery",
852-
"simulate_budget_spend",
853-
"seed_product",
854-
"seed_pricing_option",
855-
"seed_creative",
856-
"seed_plan",
857-
"seed_media_buy",
858-
],
846+
# Derived from test_controller.SCENARIOS so the static stub
847+
# matches the dispatcher; the Pydantic-generated path also
848+
# carries the new names because #292 ships them in the
849+
# comply-test-controller-request schema.
850+
"enum": ["list_scenarios"] + _CONTROLLER_SCENARIOS,
859851
},
860852
"params": {"type": "object"},
853+
"account": {"type": "object"},
861854
"context": {"type": "object"},
862855
},
863856
"required": ["scenario"],

src/adcp/server/test_controller.py

Lines changed: 185 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ async def force_account_status(self, account_id, status):
4949
"force_creative_status",
5050
"force_account_status",
5151
"force_media_buy_status",
52+
"force_create_media_buy_arm",
53+
"force_task_completion",
5254
"force_session_status",
5355
"simulate_delivery",
5456
"simulate_budget_spend",
@@ -60,6 +62,10 @@ async def force_account_status(self, account_id, status):
6062
"seed_media_buy",
6163
]
6264

65+
_MAX_TASK_ID = 128
66+
_MAX_MESSAGE = 2000
67+
_MAX_RESULT_BYTES = 256 * 1024 # 256 KB soft cap per AdCP 3.0.1
68+
6369

6470
class TestControllerError(Exception):
6571
"""Typed error for test controller store methods.
@@ -165,6 +171,89 @@ async def force_session_status(
165171
"""
166172
raise NotImplementedError
167173

174+
async def force_create_media_buy_arm(
175+
self,
176+
arm: str,
177+
task_id: str | None = None,
178+
message: str | None = None,
179+
*,
180+
account: dict[str, Any] | None = None,
181+
context: ToolContext | None = None,
182+
) -> dict[str, Any]:
183+
"""Register a single-shot directive for the next create_media_buy call.
184+
185+
The directive is consumed by the next create_media_buy call from the
186+
same authenticated sandbox account, then cleared. A second registration
187+
before consumption overwrites the first.
188+
189+
Args:
190+
arm: Response arm — ``'submitted'`` or ``'input-required'``.
191+
task_id: Required when ``arm='submitted'``. The seller MUST emit
192+
this exact value on the next create_media_buy task envelope
193+
and accept it on subsequent tasks/get calls within the same
194+
sandbox account. Max 128 chars.
195+
message: Optional plain-text note surfaced on the response.
196+
Max 2000 chars.
197+
account: Caller-supplied account object from the MCP request.
198+
Implementations use this for single-shot-per-account isolation.
199+
context: Optional ToolContext from the server's context_factory.
200+
201+
Returns:
202+
ForcedDirectiveSuccess::
203+
204+
{"success": True, "forced": {"arm": str, "task_id"?: str}}
205+
206+
Raises:
207+
TestControllerError: with code ``"NOT_FOUND"`` if the caller
208+
account is not recognized, or ``"INVALID_PARAMS"`` on
209+
validation failure.
210+
"""
211+
raise NotImplementedError
212+
213+
async def force_task_completion(
214+
self,
215+
task_id: str,
216+
result: dict[str, Any],
217+
*,
218+
account: dict[str, Any] | None = None,
219+
context: ToolContext | None = None,
220+
) -> dict[str, Any]:
221+
"""Resolve a previously-submitted task to ``'completed'``.
222+
223+
Isolation and idempotency contract:
224+
225+
- **Cross-account replay** — raise ``TestControllerError("NOT_FOUND", ...)``
226+
when the task_id was registered by a different sandbox account.
227+
- **Identical-params replay** — idempotent; return the same
228+
``StateTransitionSuccess``.
229+
- **Diverging-params replay** against a terminal task — raise
230+
``TestControllerError("INVALID_TRANSITION", ...,
231+
current_state="completed")``.
232+
233+
Args:
234+
task_id: Task handle to resolve. Max 128 chars.
235+
result: Completion payload (non-empty object). Implementations
236+
SHOULD validate it against the response branch for the task's
237+
original method and MUST reject payloads that fail that check
238+
with ``TestControllerError("INVALID_PARAMS", ...)``.
239+
account: Caller-supplied account object from the MCP request.
240+
Used for cross-account isolation.
241+
context: Optional ToolContext from the server's context_factory.
242+
243+
Returns:
244+
StateTransitionSuccess::
245+
246+
{"success": True, "previous_state": "submitted",
247+
"current_state": "completed"}
248+
249+
Raises:
250+
TestControllerError: with code ``"NOT_FOUND"`` if the task_id
251+
is unknown or owned by a different account,
252+
``"INVALID_TRANSITION"`` if the task is already terminal and
253+
params diverge, or ``"INVALID_PARAMS"`` on validation failure.
254+
"""
255+
raise NotImplementedError
256+
168257
async def simulate_delivery(
169258
self,
170259
media_buy_id: str,
@@ -300,34 +389,23 @@ def _controller_error(error: str, detail: str, current_state: str | None = None)
300389
return resp
301390

302391

303-
def _accepts_context_kwarg(method: Any) -> bool:
304-
"""True when ``method``'s signature accepts ``context=`` by keyword.
392+
def _accepts_kwarg(method: Any, name: str) -> bool:
393+
"""True when ``method``'s signature accepts ``name`` as a keyword argument.
305394
306-
TestControllerStore subclasses written against the original API
307-
(pre-#227) don't declare ``context``; passing it would raise
308-
``TypeError`` at the call site. Signature inspection keeps the
309-
dispatcher backward-compatible while letting stores opt in to
310-
header-driven context by simply adding ``context=None`` to their
311-
override.
395+
Used by the dispatcher to decide whether to pass optional kwargs
396+
(``context``, ``account``) to store methods. Methods that don't
397+
declare the kwarg keep working unchanged; methods that do get the
398+
value threaded in.
312399
313400
Counts as an opt-in:
314401
315-
- ``*, context: ...`` — keyword-only (the documented recipe).
316-
- ``context: ...`` as a regular positional-or-keyword parameter.
317-
- ``**kwargs`` — accepts any keyword, including ``context``.
402+
- ``*, name: ...`` — keyword-only (the documented recipe).
403+
- ``name: ...`` as a regular positional-or-keyword parameter.
404+
- ``**kwargs`` — accepts any keyword.
318405
319406
Does **not** count:
320407
321-
- ``context`` as positional-only (before ``/``) — passing by
322-
keyword raises ``TypeError``.
323-
- ``context`` as ``*args`` (it's never a variadic positional).
324-
325-
Caveat: ``inspect.signature`` follows ``__wrapped__`` set by
326-
``@functools.wraps``. A decorator that wraps a legacy store method
327-
and exposes the legacy signature will look "not opted in" even if
328-
the wrapper itself would accept ``context``. This matches the
329-
behavior callers expect — the wrapped callable signature is the
330-
authoritative contract.
408+
- ``name`` as positional-only (before ``/``).
331409
"""
332410
try:
333411
sig = inspect.signature(method)
@@ -340,11 +418,16 @@ def _accepts_context_kwarg(method: Any) -> bool:
340418
for param in sig.parameters.values():
341419
if param.kind == inspect.Parameter.VAR_KEYWORD:
342420
return True
343-
if param.name == "context" and param.kind in allowed:
421+
if param.name == name and param.kind in allowed:
344422
return True
345423
return False
346424

347425

426+
def _accepts_context_kwarg(method: Any) -> bool:
427+
"""True when ``method``'s signature accepts ``context=`` by keyword."""
428+
return _accepts_kwarg(method, "context")
429+
430+
348431
async def _handle_test_controller(
349432
store: TestControllerStore,
350433
params: dict[str, Any],
@@ -385,6 +468,9 @@ async def _handle_test_controller(
385468
extra: dict[str, Any] = {}
386469
if context is not None and _accepts_context_kwarg(method):
387470
extra["context"] = context
471+
account = params.get("account")
472+
if account is not None and _accepts_kwarg(method, "account"):
473+
extra["account"] = account
388474

389475
try:
390476
if scenario == "force_creative_status":
@@ -414,6 +500,78 @@ async def _handle_test_controller(
414500
termination_reason=scenario_params.get("termination_reason"),
415501
**extra,
416502
)
503+
elif scenario == "force_create_media_buy_arm":
504+
arm = scenario_params.get("arm") or ""
505+
if arm not in ("submitted", "input-required"):
506+
return _controller_error(
507+
"INVALID_PARAMS",
508+
"arm must be 'submitted' or 'input-required'",
509+
)
510+
raw_task_id = scenario_params.get("task_id")
511+
task_id: str | None = (
512+
raw_task_id.strip() if isinstance(raw_task_id, str) else None
513+
)
514+
if not task_id:
515+
task_id = None
516+
if arm == "submitted" and not task_id:
517+
return _controller_error(
518+
"INVALID_PARAMS",
519+
"task_id is required when arm is 'submitted'",
520+
)
521+
if task_id and len(task_id) > _MAX_TASK_ID:
522+
return _controller_error(
523+
"INVALID_PARAMS",
524+
f"task_id must be at most {_MAX_TASK_ID} characters",
525+
)
526+
# Forced.task_id is only valid for arm='submitted'; strip it for
527+
# 'input-required' so stores can't inadvertently echo it into the
528+
# Forced object (which has extra="forbid" in the response schema).
529+
if arm == "input-required":
530+
task_id = None
531+
message = scenario_params.get("message")
532+
if message is not None and (
533+
not isinstance(message, str) or len(message) > _MAX_MESSAGE
534+
):
535+
return _controller_error(
536+
"INVALID_PARAMS",
537+
f"message must be a string of at most {_MAX_MESSAGE} characters",
538+
)
539+
result = await method(
540+
arm=arm,
541+
task_id=task_id,
542+
message=message,
543+
**extra,
544+
)
545+
elif scenario == "force_task_completion":
546+
raw_task_id = scenario_params.get("task_id")
547+
task_id = raw_task_id.strip() if isinstance(raw_task_id, str) else None
548+
if not task_id:
549+
return _controller_error(
550+
"INVALID_PARAMS",
551+
"Missing required parameter: 'task_id'",
552+
)
553+
if len(task_id) > _MAX_TASK_ID:
554+
return _controller_error(
555+
"INVALID_PARAMS",
556+
f"task_id must be at most {_MAX_TASK_ID} characters",
557+
)
558+
result_value = scenario_params.get("result")
559+
if not isinstance(result_value, dict) or not result_value:
560+
return _controller_error(
561+
"INVALID_PARAMS",
562+
"result must be a non-empty object",
563+
)
564+
result_bytes = len(json.dumps(result_value).encode("utf-8"))
565+
if result_bytes > _MAX_RESULT_BYTES:
566+
return _controller_error(
567+
"INVALID_PARAMS",
568+
f"result payload exceeds {_MAX_RESULT_BYTES // 1024} KB limit",
569+
)
570+
result = await method(
571+
task_id=task_id,
572+
result=result_value,
573+
**extra,
574+
)
417575
elif scenario == "simulate_delivery":
418576
result = await method(
419577
media_buy_id=scenario_params["media_buy_id"],
@@ -546,29 +704,19 @@ async def comply_test_controller(**kwargs: Any) -> str:
546704
description="Compliance test controller. Sandbox only, not for production use.",
547705
)
548706

549-
# Override schema with the proper comply_test_controller inputSchema
707+
# Override schema with the proper comply_test_controller inputSchema.
708+
# Derived from SCENARIOS so it can't drift from the dispatcher.
550709
tool.parameters = {
551710
"type": "object",
552711
"properties": {
553712
"account": {"type": "object"},
554713
"scenario": {
555714
"type": "string",
556-
"enum": [
557-
"list_scenarios",
558-
"force_creative_status",
559-
"force_account_status",
560-
"force_media_buy_status",
561-
"force_session_status",
562-
"simulate_delivery",
563-
"simulate_budget_spend",
564-
"seed_product",
565-
"seed_pricing_option",
566-
"seed_creative",
567-
"seed_plan",
568-
"seed_media_buy",
569-
],
715+
# Derived from SCENARIOS so the enum never drifts from the dispatcher.
716+
"enum": ["list_scenarios"] + SCENARIOS,
570717
},
571718
"params": {"type": "object"},
719+
"account": {"type": "object"},
572720
"context": {"type": "object"},
573721
},
574722
"required": ["scenario"],

0 commit comments

Comments
 (0)