@@ -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
6470class 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+
348431async 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