diff --git a/pyproject.toml b/pyproject.toml index 3d31815cb..75c26604f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ - "adcp==1.6.0", # Official AdCP Python client for external agent communication and adagents.json validation + "adcp==1.6.1", # Official AdCP Python client for external agent communication and adagents.json validation "fastmcp>=2.13.0", # Required for context.get_http_request() support "google-generativeai>=0.5.4", "google-cloud-iam>=2.19.1", diff --git a/src/a2a_server/adcp_a2a_server.py b/src/a2a_server/adcp_a2a_server.py index 1c89ba16e..df8299272 100644 --- a/src/a2a_server/adcp_a2a_server.py +++ b/src/a2a_server/adcp_a2a_server.py @@ -1325,6 +1325,7 @@ async def _handle_get_products_skill(self, parameters: dict, auth_token: str) -> min_exposures = parameters.get("min_exposures", None) adcp_version = parameters.get("adcp_version", "1.0.0") strategy_id = parameters.get("strategy_id", None) + context = parameters.get("context", None) # Normalize brand_manifest to dict format (adcp v1.2.1 requirement) brand_manifest: dict | None = None @@ -1355,7 +1356,8 @@ async def _handle_get_products_skill(self, parameters: dict, auth_token: str) -> min_exposures=min_exposures, adcp_version=adcp_version, strategy_id=strategy_id, - context=self._tool_context_to_mcp_context(tool_context), + context=context, + ctx=self._tool_context_to_mcp_context(tool_context), ) # Convert response to dict @@ -1434,7 +1436,8 @@ async def _handle_create_media_buy_skill(self, parameters: dict, auth_token: str budget=parameters.get("budget"), # Optional legacy field - ignored if provided targeting_overlay=parameters.get("custom_targeting", {}), push_notification_config=parameters.get("push_notification_config"), - context=self._tool_context_to_mcp_context(tool_context), + context=parameters.get("context"), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Convert response to dict and add A2A success wrapper @@ -1502,7 +1505,8 @@ async def _handle_sync_creatives_skill(self, parameters: dict, auth_token: str) dry_run=parameters.get("dry_run", False), validation_mode=parameters.get("validation_mode", "strict"), push_notification_config=parameters.get("push_notification_config"), - context=self._tool_context_to_mcp_context(tool_context), + context=parameters.get("context"), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Convert response to dict @@ -1536,7 +1540,7 @@ async def _handle_list_creatives_skill(self, parameters: dict, auth_token: str) auth_token=auth_token, tool_name="list_creatives", ) - + # Call core function with optional parameters (fixing original validation bug) response = core_list_creatives_tool( media_buy_id=parameters.get("media_buy_id"), @@ -1551,7 +1555,8 @@ async def _handle_list_creatives_skill(self, parameters: dict, auth_token: str) limit=parameters.get("limit", 50), sort_by=parameters.get("sort_by", "created_date"), sort_order=parameters.get("sort_order", "desc"), - context=self._tool_context_to_mcp_context(tool_context), + context=parameters.get("context"), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Convert response to dict @@ -1696,6 +1701,7 @@ async def _handle_get_signals_skill(self, parameters: dict, auth_token: str) -> deliver_to=parameters["deliver_to"], filters=parameters.get("filters"), max_results=parameters.get("max_results"), + context=parameters.get("context") or None, ) # Call core function directly @@ -1764,10 +1770,11 @@ async def _handle_list_creative_formats_skill(self, parameters: dict, auth_token standard_only=parameters.get("standard_only"), category=parameters.get("category"), format_ids=parameters.get("format_ids"), + context=parameters.get("context"), ) # Call core function with request - response = core_list_creative_formats_tool(req=req, context=self._tool_context_to_mcp_context(tool_context)) + response = core_list_creative_formats_tool(req=req, ctx=self._tool_context_to_mcp_context(tool_context)) # Convert response to dict if isinstance(response, dict): @@ -1825,12 +1832,12 @@ def __init__(self, headers): # Map A2A parameters to ListAuthorizedPropertiesRequest from src.core.schema_adapters import ListAuthorizedPropertiesRequest as SchemaAdapterRequest - request = SchemaAdapterRequest(tags=parameters.get("tags", [])) + request = SchemaAdapterRequest(tags=parameters.get("tags", []), context=parameters.get("context")) # Call core function directly # Context can be None for unauthenticated calls - tenant will be detected from headers # MinimalContext is not compatible with ToolContext type, but works at runtime - response = core_list_authorized_properties_tool(req=request, context=tool_context) # type: ignore[arg-type] + response = core_list_authorized_properties_tool(req=request, ctx=tool_context) # type: ignore[arg-type] # Return spec-compliant response (no extra fields) # Per AdCP v2.4 spec: only publisher_domains, primary_channels, primary_countries, @@ -1882,7 +1889,8 @@ async def _handle_update_media_buy_skill(self, parameters: dict, auth_token: str budget=parameters.get("budget"), packages=packages, push_notification_config=parameters.get("push_notification_config"), - context=self._tool_context_to_mcp_context(tool_context), + context=parameters.get("context"), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Return spec-compliant response (no extra fields) @@ -1937,7 +1945,8 @@ async def _handle_get_media_buy_delivery_skill(self, parameters: dict, auth_toke status_filter=status_filter, start_date=start_date, end_date=end_date, - context=self._tool_context_to_mcp_context(tool_context), + context=parameters.get("context"), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Convert response to dict for A2A format @@ -1972,7 +1981,8 @@ async def _handle_update_performance_index_skill(self, parameters: dict, auth_to response = core_update_performance_index_tool( media_buy_id=parameters["media_buy_id"], performance_data=parameters["performance_data"], - context=self._tool_context_to_mcp_context(tool_context), + context=parameters.get("context"), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Return spec-compliant response (no extra fields) @@ -2012,7 +2022,7 @@ async def _get_products(self, query: str, auth_token: str | None) -> dict: response = await core_get_products_tool( brief=query, brand_manifest=brand_manifest, - context=self._tool_context_to_mcp_context(tool_context), + ctx=self._tool_context_to_mcp_context(tool_context), ) # Convert to A2A response format diff --git a/src/core/helpers/creative_helpers.py b/src/core/helpers/creative_helpers.py index 968ff5799..e5e427147 100644 --- a/src/core/helpers/creative_helpers.py +++ b/src/core/helpers/creative_helpers.py @@ -346,7 +346,7 @@ def process_and_upload_package_creatives( dry_run=testing_ctx.dry_run if testing_ctx else False, validation_mode="strict", push_notification_config=None, - context=context, # For principal_id extraction + ctx=context, # For principal_id extraction ) # Extract creative IDs from response diff --git a/src/core/mcp_context_wrapper.py b/src/core/mcp_context_wrapper.py index d81eb4d36..17c51cdb6 100644 --- a/src/core/mcp_context_wrapper.py +++ b/src/core/mcp_context_wrapper.py @@ -153,9 +153,10 @@ def wrapper(*args, **kwargs): def _extract_fastmcp_context(self, args: tuple, kwargs: dict) -> FastMCPContext | None: """Extract FastMCP Context from function arguments.""" - # Check kwargs first - if "context" in kwargs and isinstance(kwargs["context"], FastMCPContext): - return kwargs["context"] + # Check kwargs for any value that is a FastMCPContext (supports 'ctx' or other param names) + for k, v in kwargs.items(): + if isinstance(v, FastMCPContext): + return v # Check positional args for arg in args: @@ -259,9 +260,16 @@ def _create_tool_context(self, fastmcp_context: FastMCPContext, tool_name: str) def _replace_context_in_args(self, args: tuple, kwargs: dict, tool_context: ToolContext) -> tuple[tuple, dict]: """Replace FastMCP Context with ToolContext in arguments.""" - # Replace in kwargs - if "context" in kwargs: - kwargs = {**kwargs, "context": tool_context} + # Replace in kwargs: set on whichever key carried the FastMCP context (supports 'ctx' or others) + new_kwargs = {} + replaced = False + for k, v in kwargs.items(): + if isinstance(v, FastMCPContext): + new_kwargs[k] = tool_context + replaced = True + else: + new_kwargs[k] = v + kwargs = new_kwargs # Replace in positional args new_args = [] diff --git a/src/core/schema_adapters.py b/src/core/schema_adapters.py index 5070fd2da..ee00cdbc5 100644 --- a/src/core/schema_adapters.py +++ b/src/core/schema_adapters.py @@ -60,6 +60,7 @@ class GetProductsRequest(BaseModel): min_exposures: int | None = Field(None, description="Minimum exposures needed for measurement validity") strategy_id: str | None = Field(None, description="Optional strategy ID for linking operations") webhook_url: str | None = Field(None, description="URL for async task completion notifications") + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") @model_validator(mode="before") @classmethod @@ -276,6 +277,7 @@ class CreateMediaBuyRequest(BaseModel): start_time: datetime | str = Field(..., description="Campaign start time or 'asap'") end_time: datetime = Field(..., description="Campaign end time") budget: dict[str, Any] = Field(..., description="Budget configuration") + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") # Optional fields promoted_offering: str | None = Field(None, description="DEPRECATED: Use brand_manifest") @@ -503,6 +505,9 @@ class ListCreativeFormatsRequest(BaseModel): format_ids: list[FormatId] | None = Field( None, description="Return only these specific format IDs (e.g., from get_products response)" ) + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client" + ) def to_generated(self) -> _GeneratedListCreativeFormatsRequest: """Convert to generated schema for protocol validation.""" @@ -513,6 +518,9 @@ class ListAuthorizedPropertiesRequest(BaseModel): """Adapter for ListAuthorizedPropertiesRequest - simple pass-through to generated schema.""" tags: list[str] | None = Field(None, description="Filter properties by specific tags") + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client" + ) def to_generated(self) -> _GeneratedListAuthorizedPropertiesRequest: """Convert to generated schema for protocol validation.""" @@ -550,8 +558,11 @@ class CreateMediaBuyResponse(AdCPBaseModel): # Optional AdCP domain fields media_buy_id: str | None = None creative_deadline: Any | None = None - packages: list[Any] | None = Field(default_factory=list) + packages: list[Any] = Field(default_factory=list) errors: list[Any] | None = None + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) # Internal fields (excluded from AdCP responses) workflow_step_id: str | None = None @@ -594,6 +605,9 @@ class UpdateMediaBuyResponse(AdCPBaseModel): implementation_date: str | None = Field(None, description="ISO 8601 date when changes will take effect") affected_packages: list[Any] | None = Field(default_factory=list) errors: list[Any] | None = None + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) def __str__(self) -> str: """Return human-readable message for protocol layer.""" @@ -624,6 +638,9 @@ class SyncCreativesResponse(AdCPBaseModel): # Optional fields (per official spec) dry_run: bool | None = Field(None, description="Whether this was a dry run (no actual changes made)") + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) def __str__(self) -> str: """Return human-readable summary message for protocol envelope.""" @@ -738,6 +755,9 @@ class ActivateSignalResponse(AdCPBaseModel): estimated_activation_duration_minutes: float | None = None deployed_at: str | None = None errors: list[Any] | None = None + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) def __str__(self) -> str: """Return human-readable message for protocol layer.""" diff --git a/src/core/schema_helpers.py b/src/core/schema_helpers.py index 9e86b20f2..257abfc01 100644 --- a/src/core/schema_helpers.py +++ b/src/core/schema_helpers.py @@ -31,6 +31,7 @@ def create_get_products_request( brief: str = "", brand_manifest: dict[str, Any] | None = None, filters: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, ) -> GetProductsRequest: """Create GetProductsRequest aligned with adcp v1.2.1 spec. @@ -55,12 +56,14 @@ def create_get_products_request( brand_manifest=brand_manifest, brief=brief or None, filters=filters, + context=context, ) def create_get_products_response( products: list[Product | dict[str, Any]], errors: list | None = None, + request_context: dict[str, Any] | None = None, ) -> GetProductsResponse: """Create GetProductsResponse. @@ -77,6 +80,7 @@ def create_get_products_response( return GetProductsResponse( products=products, # type: ignore[arg-type] errors=errors, + context=request_context, ) diff --git a/src/core/schemas.py b/src/core/schemas.py index f1e62c9f4..de9ee8487 100644 --- a/src/core/schemas.py +++ b/src/core/schemas.py @@ -1332,11 +1332,17 @@ class ProductPerformance(BaseModel): class UpdatePerformanceIndexRequest(AdCPBaseModel): media_buy_id: str performance_data: list[ProductPerformance] + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) class UpdatePerformanceIndexResponse(AdCPBaseModel): status: str detail: str + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) def __str__(self) -> str: """Return human-readable text for MCP content field.""" @@ -1412,6 +1418,9 @@ class GetProductsRequest(AdCPBaseModel): "", description="Brief description of the advertising campaign or requirements (optional)", ) + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) brand_manifest: "BrandManifest | str" = Field( ..., description="Brand information manifest (inline object or URL string). REQUIRED per AdCP v2.2.0 spec.", @@ -1426,7 +1435,6 @@ class GetProductsRequest(AdCPBaseModel): description="Structured filters for product discovery", ) - class GetProductsResponse(NestedModelSerializerMixin, AdCPBaseModel): """Response for get_products tool (AdCP v2.4 spec compliant). @@ -1434,6 +1442,9 @@ class GetProductsResponse(NestedModelSerializerMixin, AdCPBaseModel): Protocol fields (status, task_id, message, context_id) are added by the protocol layer (MCP, A2A, REST) via ProtocolEnvelope wrapper. """ + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) # Required AdCP domain fields products: list[Product] = Field(..., description="List of available advertising products") @@ -1471,6 +1482,9 @@ class ListCreativeFormatsRequest(AdCPBaseModel): All parameters are optional filters per AdCP spec. """ + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) adcp_version: str = Field( default="1.0.0", @@ -1528,6 +1542,9 @@ class ListCreativeFormatsResponse(NestedModelSerializerMixin, AdCPBaseModel): Protocol fields (status, task_id, message, context_id) are added by the protocol layer (MCP, A2A, REST) via ProtocolEnvelope wrapper. """ + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) formats: list[Format] = Field(..., description="Full format definitions per AdCP spec") creative_agents: list[dict[str, Any]] | None = Field( @@ -1768,6 +1785,9 @@ class SyncCreativesRequest(AdCPBaseModel): """ creatives: list[Creative] = Field(..., description="Array of creative assets to sync (create or update)") + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) patch: bool = Field( False, description="When true, only provided fields are updated (partial update). When false, entire creative is replaced (full upsert).", @@ -1908,6 +1928,9 @@ class SyncCreativesResponse(AdCPBaseModel): creatives: list[SyncCreativeResult] = Field(..., description="Results for each creative processed") # Optional fields (per official spec) + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) dry_run: bool | None = Field(None, description="Whether this was a dry run (no actual changes made)") @model_serializer(mode="wrap") @@ -1977,6 +2000,9 @@ class ListCreativesRequest(AdCPBaseModel): media_buy_id: str | None = Field(None, description="Filter by media buy ID") buyer_ref: str | None = Field(None, description="Filter by buyer reference") + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) status: str | None = Field(None, description="Filter by creative status (pending, approved, rejected)") format: str | None = Field(None, description="Filter by creative format") tags: list[str] | None = Field(None, description="Filter by tags") @@ -2036,6 +2062,9 @@ class ListCreativesResponse(AdCPBaseModel): Protocol fields (status, task_id, message, context_id) are added by the protocol layer (MCP, A2A, REST) via ProtocolEnvelope wrapper. """ + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) # Required AdCP domain fields query_summary: QuerySummary = Field(..., description="Summary of the query that was executed") @@ -2555,6 +2584,9 @@ class CreateMediaBuyRequest(AdCPBaseModel): None, description="Application-level webhook config (NOTE: Protocol-level push notifications via A2A/MCP transport take precedence)", ) + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) @model_validator(mode="before") @classmethod @@ -2782,6 +2814,9 @@ class GetMediaBuyDeliveryRequest(AdCPBaseModel): push_notification_config: PushNotificationConfig | None = Field( None, description="Push notification configuration for async task updates." ) + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) # AdCP-compliant delivery models @@ -2867,6 +2902,9 @@ class GetMediaBuyDeliveryResponse(NestedModelSerializerMixin, AdCPBaseModel): ..., description="Array of delivery data for each media buy" ) errors: list[dict] | None = Field(None, description="Task-specific errors and warnings") + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) def __str__(self) -> str: """Return human-readable summary message for protocol envelope.""" @@ -2994,6 +3032,9 @@ class UpdateMediaBuyRequest(AdCPBaseModel): None, description="Application-level webhook config (NOTE: Protocol-level push notifications via A2A/MCP transport take precedence)", ) + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) today: date | None = Field(None, exclude=True, description="For testing/simulation only - not part of AdCP spec") # NOTE: No Python validator needed for oneOf constraint - AdCP schema enforces media_buy_id/buyer_ref oneOf @@ -3380,6 +3421,7 @@ class GetSignalsRequest(AdCPBaseModel): deliver_to: SignalDeliverTo | None = Field(None, description="Where the signals need to be delivered") filters: SignalFilters | None = Field(None, description="Filters to refine results") max_results: int | None = Field(None, ge=1, description="Maximum number of results to return") + context: dict[str, Any] | None = Field(None, description="Application-level context provided by the client") # Backward compatibility properties (deprecated) @property @@ -3405,6 +3447,7 @@ class GetSignalsResponse(AdCPBaseModel): """ signals: list[Signal] = Field(..., description="Array of available signals") + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") @model_serializer(mode="wrap") def _serialize_nested_models(self, serializer, info): @@ -3448,7 +3491,7 @@ class ActivateSignalRequest(AdCPBaseModel): signal_id: str = Field(..., description="Signal ID to activate") campaign_id: str | None = Field(None, description="Optional campaign ID to activate signal for") media_buy_id: str | None = Field(None, description="Optional media buy ID to activate signal for") - + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") class ActivateSignalResponse(AdCPBaseModel): """Response from signal activation (AdCP v2.4 spec compliant). @@ -3461,6 +3504,7 @@ class ActivateSignalResponse(AdCPBaseModel): signal_id: str = Field(..., description="Activated signal ID") activation_details: dict[str, Any] | None = Field(None, description="Platform-specific activation details") errors: list[Error] | None = Field(None, description="Optional error reporting") + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") def __str__(self) -> str: """Return human-readable summary message for protocol envelope.""" @@ -3476,6 +3520,7 @@ class SimulationControlRequest(AdCPBaseModel): strategy_id: str = Field(..., description="Strategy ID to control (must be simulation strategy with 'sim_' prefix)") action: Literal["jump_to", "reset", "set_scenario"] = Field(..., description="Action to perform on the simulation") parameters: dict[str, Any] = Field(default_factory=dict, description="Action-specific parameters") + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") class SimulationControlResponse(AdCPBaseModel): @@ -3485,6 +3530,7 @@ class SimulationControlResponse(AdCPBaseModel): message: str | None = None current_state: dict[str, Any] | None = None simulation_time: datetime | None = None + context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request") def __str__(self) -> str: """Return human-readable text for MCP content field.""" @@ -3598,6 +3644,9 @@ class ListAuthorizedPropertiesRequest(AdCPBaseModel): adcp_version: str = Field( default="1.0.0", pattern=r"^\d+\.\d+\.\d+$", description="AdCP schema version for this request" ) + context: dict[str, Any] | None = Field( + None, description="Application-level context provided by the client (echoed in responses)" + ) publisher_domains: list[str] | None = Field( None, description="Filter to specific publisher domains (optional). If omitted, returns all publishers this agent represents.", @@ -3625,6 +3674,9 @@ class ListAuthorizedPropertiesResponse(AdCPBaseModel): """ publisher_domains: list[str] = Field(..., description="Publisher domains this agent is authorized to represent") + context: dict[str, Any] | None = Field( + None, description="Application-level context echoed from the request" + ) primary_channels: list[str] | None = Field( None, description="Primary advertising channels in this portfolio (helps buyers filter relevance)" ) diff --git a/src/core/tools/creative_formats.py b/src/core/tools/creative_formats.py index 8b041db4a..e41924f20 100644 --- a/src/core/tools/creative_formats.py +++ b/src/core/tools/creative_formats.py @@ -36,7 +36,7 @@ def _list_creative_formats_impl( # Use default request if none provided if req is None: - req = ListCreativeFormatsRequest(type=None, standard_only=None, category=None, format_ids=None) + req = ListCreativeFormatsRequest(type=None, standard_only=None, category=None, format_ids=None, context=None) # For discovery endpoints, authentication is optional # require_valid_token=False means invalid tokens are treated like missing tokens (discovery endpoint behavior) @@ -121,7 +121,7 @@ def _list_creative_formats_impl( ) # Create response (no message/specification_version - not in adapter schema) - response = ListCreativeFormatsResponse(formats=formats, creative_agents=None, errors=None) + response = ListCreativeFormatsResponse(formats=formats, creative_agents=None, errors=None, context=req.context) # Always return Pydantic model - MCP wrapper will handle serialization # Schema enhancement (if needed) should happen in the MCP wrapper, not here @@ -134,7 +134,8 @@ def list_creative_formats( category: str | None = None, format_ids: list[str] | None = None, webhook_url: str | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ): """List all available creative formats (AdCP spec endpoint). @@ -146,7 +147,7 @@ def list_creative_formats( category: Filter by format category (standard, custom) format_ids: Filter by specific format IDs webhook_url: URL for async task completion notifications (AdCP spec, optional) - context: FastMCP context (automatically provided) + ctx: FastMCP context (automatically provided) Returns: ToolResult with ListCreativeFormatsResponse data @@ -166,16 +167,18 @@ def list_creative_formats( standard_only=standard_only, category=category, format_ids=format_ids_objects, + context=context, ) except ValidationError as e: raise ToolError(format_validation_error(e, context="list_creative_formats request")) from e - response = _list_creative_formats_impl(req, context) + response = _list_creative_formats_impl(req, ctx) return ToolResult(content=str(response), structured_content=response.model_dump()) def list_creative_formats_raw( - req: ListCreativeFormatsRequest | None = None, context: Context | ToolContext | None = None + req: ListCreativeFormatsRequest | None = None, + ctx: Context | ToolContext | None = None, ) -> ListCreativeFormatsResponse: """List all available creative formats (raw function for A2A server use). @@ -183,9 +186,9 @@ def list_creative_formats_raw( Args: req: Optional request with filter parameters - context: FastMCP context + ctx: FastMCP context Returns: ListCreativeFormatsResponse with all available formats """ - return _list_creative_formats_impl(req, context) + return _list_creative_formats_impl(req, ctx) diff --git a/src/core/tools/creatives.py b/src/core/tools/creatives.py index c39afbd37..157da41a6 100644 --- a/src/core/tools/creatives.py +++ b/src/core/tools/creatives.py @@ -47,7 +47,8 @@ def _sync_creatives_impl( dry_run: bool = False, validation_mode: str = "strict", push_notification_config: dict | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ) -> SyncCreativesResponse: """Sync creative assets to centralized library (AdCP v2.4 spec compliant endpoint). @@ -65,7 +66,8 @@ def _sync_creatives_impl( dry_run: Preview changes without applying them validation_mode: Validation strictness (strict or lenient) push_notification_config: Push notification config for status updates (AdCP spec, optional) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: SyncCreativesResponse with synced creatives and assignments @@ -79,7 +81,7 @@ def _sync_creatives_impl( start_time = time.time() # Authentication - principal_id = get_principal_id_from_context(context) + principal_id = get_principal_id_from_context(ctx) # CRITICAL: principal_id is required for creative sync (NOT NULL in database) if not principal_id: @@ -92,14 +94,14 @@ def _sync_creatives_impl( # If context is ToolContext (A2A), tenant is already set, but verify it matches from src.core.tool_context import ToolContext - if isinstance(context, ToolContext): + if isinstance(ctx, ToolContext): # Tenant context should already be set by A2A handler, but verify tenant = get_current_tenant() - if not tenant or tenant.get("tenant_id") != context.tenant_id: + if not tenant or tenant.get("tenant_id") != ctx.tenant_id: # Tenant context wasn't set properly - this shouldn't happen but handle it - logger.warning(f"Warning: Tenant context mismatch, setting from ToolContext: {context.tenant_id}") + logger.warning(f"Warning: Tenant context mismatch, setting from ToolContext: {ctx.tenant_id}") # We need to load the tenant properly - for now use the ID from context - tenant = {"tenant_id": context.tenant_id} + tenant = {"tenant_id": ctx.tenant_id} else: # FastMCP path - tenant should be set by get_principal_from_context tenant = get_current_tenant() @@ -349,6 +351,9 @@ def _sync_creatives_impl( if creative.get("template_variables") is not None: data["template_variables"] = creative.get("template_variables") changes.append("template_variables") + # Persist application context + if context is not None: + data["context"] = context else: # Full upsert mode: replace all data # Extract URL from assets if not provided at top level @@ -388,6 +393,8 @@ def _sync_creatives_impl( data["assets"] = creative.get("assets") if creative.get("template_variables"): data["template_variables"] = creative.get("template_variables") + if context is not None: + data["context"] = context # ALWAYS validate updates with creative agent if creative_format: @@ -1575,8 +1582,8 @@ def normalize_url(url: str | None) -> str | None: # Log activity # Activity logging imported at module level - if context is not None: - log_tool_activity(context, "sync_creatives", start_time) + if ctx is not None: + log_tool_activity(ctx, "sync_creatives", start_time) # Build message message = f"Synced {created_count + updated_count} creatives" @@ -1635,6 +1642,7 @@ def normalize_url(url: str | None) -> str | None: return SyncCreativesResponse( creatives=results, dry_run=dry_run, + context=context, ) @@ -1646,7 +1654,8 @@ async def sync_creatives( dry_run: bool = False, validation_mode: str = "strict", push_notification_config: dict | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ): """Sync creative assets to centralized library (AdCP v2.4 spec compliant endpoint). @@ -1660,7 +1669,8 @@ async def sync_creatives( dry_run: Preview changes without applying them validation_mode: Validation strictness (strict or lenient) push_notification_config: Push notification config for async notifications (AdCP spec, optional) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ToolResult with SyncCreativesResponse data @@ -1674,6 +1684,7 @@ async def sync_creatives( validation_mode=validation_mode, push_notification_config=push_notification_config, context=context, + ctx=ctx, ) return ToolResult(content=str(response), structured_content=response.model_dump()) @@ -1698,7 +1709,8 @@ def _list_creatives_impl( limit: int = 50, sort_by: str = "created_date", sort_order: str = "desc", - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ) -> ListCreativesResponse: """List and search creative library (AdCP spec endpoint). @@ -1725,7 +1737,8 @@ def _list_creatives_impl( limit: Number of results per page (default: 50, max: 1000) sort_by: Sort field (created_date, name, status) (default: created_date) sort_order: Sort order (asc, desc) (default: desc) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ListCreativesResponse with filtered creative assets and pagination info @@ -1775,6 +1788,7 @@ def _list_creatives_impl( limit=min(limit, 1000), # Enforce max limit sort_by=sort_by, sort_order=valid_sort_order, + context=context, ) except ValidationError as e: raise ToolError(format_validation_error(e, context="list_creatives request")) from e @@ -1784,7 +1798,7 @@ def _list_creatives_impl( # Authentication - REQUIRED (creatives contain sensitive data) # Unlike discovery endpoints (list_creative_formats), this returns actual creative assets # which are principal-specific and must be access-controlled - principal_id = get_principal_id_from_context(context) + principal_id = get_principal_id_from_context(ctx) if not principal_id: raise ToolError("Missing x-adcp-auth header") @@ -1964,8 +1978,8 @@ def _list_creatives_impl( # Log activity # Activity logging imported at module level - if context is not None: - log_tool_activity(context, "list_creatives", start_time) + if ctx is not None: + log_tool_activity(ctx, "list_creatives", start_time) message = f"Found {len(creatives)} creatives" if total_count > len(creatives): @@ -2011,6 +2025,7 @@ def _list_creatives_impl( limit=req.limit, offset=offset, has_more=has_more, total_pages=total_pages, current_page=req.page ), creatives=creatives, + context=req.context, ) @@ -2035,7 +2050,8 @@ async def list_creatives( sort_by: str = "created_date", sort_order: str = "desc", webhook_url: str | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ): """List and filter creative assets from the centralized library. @@ -2067,6 +2083,7 @@ async def list_creatives( sort_by, sort_order, context, + ctx, ) return ToolResult(content=str(response), structured_content=response.model_dump()) @@ -2079,7 +2096,8 @@ def sync_creatives_raw( dry_run: bool = False, validation_mode: str = "strict", push_notification_config: dict = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ): """Sync creative assets to the centralized creative library (raw function for A2A server use). @@ -2093,7 +2111,8 @@ def sync_creatives_raw( dry_run: Preview changes without applying them validation_mode: Validation strictness (strict or lenient) push_notification_config: Push notification config for status updates - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: SyncCreativesResponse with synced creatives and assignments @@ -2107,6 +2126,7 @@ def sync_creatives_raw( validation_mode=validation_mode, push_notification_config=push_notification_config, context=context, + ctx=ctx, ) @@ -2123,7 +2143,8 @@ def list_creatives_raw( limit: int = 50, sort_by: str = "created_date", sort_order: str = "desc", - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ): """List creative assets with filtering and pagination (raw function for A2A server use). @@ -2142,7 +2163,8 @@ def list_creatives_raw( limit: Number of results per page (default: 50, max: 1000) sort_by: Sort field (default: created_date) sort_order: Sort order (default: desc) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ListCreativesResponse with filtered creative assets and pagination info @@ -2161,4 +2183,5 @@ def list_creatives_raw( sort_by=sort_by, sort_order=sort_order, context=context, + ctx=ctx, ) diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index dfdb0ff21..2e05781a1 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -1210,7 +1210,8 @@ async def _create_media_buy_impl( enable_creative_macro: bool = False, strategy_id: str | None = None, push_notification_config: dict[str, Any] | None = None, - context: Context | ToolContext | None = None, + context: dict[str, Any] | None = None, # Optional application level context per adcp spec + ctx: Context | ToolContext | None = None, ) -> CreateMediaBuyResponse: """Create a media buy with the specified parameters. @@ -1235,7 +1236,8 @@ async def _create_media_buy_impl( enable_creative_macro: Enable AXE to provide creative_macro signal strategy_id: Optional strategy ID for linking operations push_notification_config: Push notification config for status updates (MCP/A2A) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) (automatically provided) Returns: CreateMediaBuyResponse with media buy details @@ -1271,19 +1273,20 @@ async def _create_media_buy_impl( webhook_url=None, # Internal field, not in AdCP spec webhook_auth_token=None, # Internal field, not in AdCP spec push_notification_config=push_notification_config, + context=context, ) except ValidationError as e: # Format validation errors with helpful context using shared helper raise ToolError(format_validation_error(e, context="request")) from e # Extract testing context first - if context is None: + if ctx is None: raise ToolError("Context is required") - testing_ctx = get_testing_context(context) + testing_ctx = get_testing_context(ctx) # Authentication and tenant setup - principal_id = get_principal_id_from_context(context) + principal_id = get_principal_id_from_context(ctx) if principal_id is None: raise ToolError("Principal ID not found in context - authentication required") @@ -1309,11 +1312,12 @@ async def _create_media_buy_impl( # Cannot create context or workflow step without valid principal return CreateMediaBuyError( errors=[Error(code="authentication_error", message=error_msg, details=None)], + context=req.context, ) # Context management and workflow step creation - create workflow step FIRST ctx_manager = get_context_manager() - ctx_id = context.headers.get("x-context-id") if context and hasattr(context, "headers") else None + ctx_id = ctx.headers.get("x-context-id") if ctx and hasattr(ctx, "headers") else None persistent_ctx = None step = None @@ -1722,6 +1726,7 @@ async def _create_media_buy_impl( # Return error response (protocol layer will add status="failed") return CreateMediaBuyError( errors=[Error(code="validation_error", message=str(e), details=None)], + context=req.context, ) # Principal already validated earlier (before context creation) to avoid foreign key errors @@ -1740,7 +1745,7 @@ async def _create_media_buy_impl( logger.info("[INLINE_CREATIVE_DEBUG] Calling process_and_upload_package_creatives") updated_packages, uploaded_ids = process_and_upload_package_creatives( packages=req.packages, - context=context, + context=ctx, testing_ctx=testing_ctx, ) # Replace packages with updated versions (functional approach) @@ -2118,6 +2123,7 @@ async def _create_media_buy_impl( creative_deadline=None, packages=pending_packages, workflow_step_id=step.step_id, # Client can track approval via this ID + context=req.context, ) # Get products for the media buy to check product-level auto-creation settings @@ -2184,6 +2190,7 @@ async def _create_media_buy_impl( ctx_manager.update_workflow_step(step.step_id, status="failed", error_message=error_detail) return CreateMediaBuyError( errors=[Error(code="invalid_configuration", message=err, details=None) for err in config_errors], + context=req.context, ) product_auto_create = all( @@ -2279,6 +2286,7 @@ async def _create_media_buy_impl( media_buy_id=media_buy_id, packages=response_packages, # Include packages with buyer_ref workflow_step_id=step.step_id, + context=req.context, ) # Continue with synchronized media buy creation @@ -2528,6 +2536,7 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key ctx_manager.update_workflow_step(step.step_id, status="failed", error_message=error_msg) return CreateMediaBuyError( errors=[Error(code="invalid_datetime", message=error_msg, details=None)], + context=req.context, ) # PRE-VALIDATE: Check all creatives have required fields BEFORE calling adapter @@ -3075,12 +3084,13 @@ def serialize_for_json(value): media_buy_id=response.media_buy_id, packages=response_packages, creative_deadline=response.creative_deadline, + context=req.context, ) # Log activity # Activity logging imported at module level - log_tool_activity(context, "create_media_buy", request_start_time) + log_tool_activity(ctx, "create_media_buy", request_start_time) # Also log specific media buy activity try: @@ -3143,6 +3153,7 @@ def serialize_for_json(value): media_buy_id=filtered_data["media_buy_id"], packages=filtered_data["packages"], creative_deadline=filtered_data.get("creative_deadline"), + context=req.context, ) # Mark workflow step as completed on success @@ -3310,8 +3321,9 @@ async def create_media_buy( enable_creative_macro: bool = False, strategy_id: str | None = None, push_notification_config: dict[str, Any] | None = None, + context: dict[str, Any] | None = None, # payload-level context webhook_url: str | None = None, - context: Context | ToolContext | None = None, + ctx: Context | ToolContext | None = None, ): """Create a media buy with the specified parameters. @@ -3338,7 +3350,8 @@ async def create_media_buy( enable_creative_macro: Enable AXE to provide creative_macro signal strategy_id: Optional strategy ID for linking operations push_notification_config: Push notification config dict with url, authentication (AdCP spec) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) (automatically provided) Returns: ToolResult with CreateMediaBuyResponse data @@ -3365,6 +3378,7 @@ async def create_media_buy( strategy_id=strategy_id, push_notification_config=push_notification_config, context=context, + ctx=ctx, ) return ToolResult(content=str(response), structured_content=response.model_dump()) @@ -3390,7 +3404,8 @@ async def create_media_buy_raw( enable_creative_macro: bool = False, strategy_id: str | None = None, push_notification_config: dict[str, Any] | None = None, - context: Context | ToolContext | None = None, + context: dict[str, Any] | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None ): """Create a new media buy with specified parameters (raw function for A2A server use). @@ -3417,7 +3432,7 @@ async def create_media_buy_raw( enable_creative_macro: Enable creative macro strategy_id: Strategy ID push_notification_config: Push notification config for status updates - context: FastMCP context (automatically provided) + ctx: FastMCP context (automatically provided) (automatically provided) Returns: CreateMediaBuyResponse with media buy details @@ -3444,6 +3459,7 @@ async def create_media_buy_raw( strategy_id=strategy_id, push_notification_config=push_notification_config, context=context, + ctx=ctx, ) diff --git a/src/core/tools/media_buy_delivery.py b/src/core/tools/media_buy_delivery.py index e131cd42c..31017d484 100644 --- a/src/core/tools/media_buy_delivery.py +++ b/src/core/tools/media_buy_delivery.py @@ -40,7 +40,7 @@ def _get_media_buy_delivery_impl( - req: GetMediaBuyDeliveryRequest, context: Context | ToolContext | None + req: GetMediaBuyDeliveryRequest, ctx: Context | ToolContext | None ) -> GetMediaBuyDeliveryResponse: """Get delivery data for one or more media buys. @@ -49,13 +49,13 @@ def _get_media_buy_delivery_impl( """ # Validate context is provided - if context is None: + if ctx is None: raise ToolError("Context is required") # Extract testing context for time simulation and event jumping - testing_ctx = get_testing_context(context) + testing_ctx = get_testing_context(ctx) - principal_id = get_principal_id_from_context(context) + principal_id = get_principal_id_from_context(ctx) if not principal_id: # Return AdCP-compliant error response return GetMediaBuyDeliveryResponse( @@ -324,6 +324,7 @@ def _get_media_buy_delivery_impl( "media_buy_count": media_buy_count, }, media_buy_deliveries=deliveries, + context=req.context or None, ) # Apply testing hooks if needed @@ -391,6 +392,7 @@ def _get_media_buy_delivery_impl( sequence_number=filtered_data.get("sequence_number"), next_expected_at=filtered_data.get("next_expected_at"), errors=filtered_data.get("errors"), + context=req.context or None, ) return response @@ -402,9 +404,10 @@ def get_media_buy_delivery( status_filter: str | None = None, start_date: str | None = None, end_date: str | None = None, + context: dict | None = None, # Application level context per adcp spec webhook_url: str | None = None, push_notification_config: PushNotificationConfig | None = None, - context: Context | ToolContext | None = None, + ctx: Context | ToolContext | None = None, ): """Get delivery data for media buys. @@ -418,7 +421,8 @@ def get_media_buy_delivery( end_date: End date for reporting period in YYYY-MM-DD format (optional) webhook_url: URL for async task completion notifications (AdCP spec, optional) push_notification_config: Optional webhook configuration (accepted, ignored by this operation) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ToolResult with GetMediaBuyDeliveryResponse data @@ -432,11 +436,12 @@ def get_media_buy_delivery( start_date=start_date, end_date=end_date, push_notification_config=push_notification_config, + context=context, ) except ValidationError as e: raise ToolError(format_validation_error(e, context="get_media_buy_delivery request")) from e - response = _get_media_buy_delivery_impl(req, context) + response = _get_media_buy_delivery_impl(req, ctx) return ToolResult(content=str(response), structured_content=response.model_dump()) @@ -446,7 +451,8 @@ def get_media_buy_delivery_raw( status_filter: str | None = None, start_date: str | None = None, end_date: str | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ): """Get delivery metrics for media buys (raw function for A2A server use). @@ -471,10 +477,11 @@ def get_media_buy_delivery_raw( start_date=start_date, end_date=end_date, push_notification_config=None, + context=context, ) # Call the implementation - return _get_media_buy_delivery_impl(req, context) + return _get_media_buy_delivery_impl(req, ctx) # --- Admin Tools --- diff --git a/src/core/tools/media_buy_update.py b/src/core/tools/media_buy_update.py index c83495ec5..13d42afde 100644 --- a/src/core/tools/media_buy_update.py +++ b/src/core/tools/media_buy_update.py @@ -121,7 +121,8 @@ def _update_media_buy_impl( packages: list | None = None, creatives: list | None = None, push_notification_config: dict | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, + ctx: Context | ToolContext | None = None, ) -> UpdateMediaBuyResponse: """Shared implementation for update_media_buy (used by both MCP and A2A). @@ -143,7 +144,8 @@ def _update_media_buy_impl( packages: Package-specific updates creatives: Add new creatives push_notification_config: Push notification config for status updates (AdCP spec, optional) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: UpdateMediaBuyResponse with updated media buy details @@ -186,6 +188,7 @@ def _update_media_buy_impl( "budget": budget_obj, "packages": packages, "push_notification_config": push_notification_config, + "context": context, } # Remove None values to avoid validation errors in strict mode request_params = {k: v for k, v in request_params.items() if v is not None} @@ -198,15 +201,15 @@ def _update_media_buy_impl( # Initialize tracking for affected packages (internal tracking, not part of schema) affected_packages_list: list[dict] = [] - if context is None: + if ctx is None: raise ValueError("Context is required for update_media_buy") if not req.media_buy_id: # TODO: Handle buyer_ref case - for now just raise error raise ValueError("media_buy_id is required (buyer_ref lookup not yet implemented)") - _verify_principal(req.media_buy_id, context) - principal_id = get_principal_id_from_context(context) # Already verified by _verify_principal + _verify_principal(req.media_buy_id, ctx) + principal_id = get_principal_id_from_context(ctx) # Already verified by _verify_principal # Verify principal_id is not None (get_principal_id_from_context should raise if None) if principal_id is None: @@ -216,7 +219,7 @@ def _update_media_buy_impl( # Create or get persistent context ctx_manager = get_context_manager() - ctx_id = context.headers.get("x-context-id") if hasattr(context, "headers") else None + ctx_id = ctx.headers.get("x-context-id") if hasattr(ctx, "headers") else None persistent_ctx = ctx_manager.get_or_create_context( tenant_id=tenant["tenant_id"], principal_id=principal_id, # Now guaranteed to be str @@ -243,6 +246,7 @@ def _update_media_buy_impl( error_msg = f"Principal {principal_id} not found" response_data = UpdateMediaBuyError( errors=[{"code": "principal_not_found", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -253,7 +257,7 @@ def _update_media_buy_impl( return response_data # Extract testing context for dry_run and testing_context parameters - testing_ctx = get_testing_context(context) + testing_ctx = get_testing_context(ctx) adapter = get_adapter(principal, dry_run=testing_ctx.dry_run, testing_context=testing_ctx) today = req.today or date.today() @@ -274,6 +278,7 @@ def _update_media_buy_impl( buyer_ref=req.buyer_ref or "", packages=[], # Required by AdCP spec affected_packages=[], # Internal field for tracking changes + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -323,6 +328,7 @@ def _update_media_buy_impl( error_msg = f"Currency {request_currency} is not supported by this publisher." response_data = UpdateMediaBuyError( errors=[{"code": "currency_not_supported", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, status="failed", response_data=response_data.model_dump(), error_message=error_msg @@ -386,6 +392,7 @@ def _update_media_buy_impl( ) response_data = UpdateMediaBuyError( errors=[{"code": "budget_limit_exceeded", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -493,6 +500,7 @@ def _update_media_buy_impl( error_msg = "package_id is required when updating creative_ids" response_data = UpdateMediaBuyError( errors=[{"code": "missing_package_id", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -527,6 +535,7 @@ def _update_media_buy_impl( error_msg = f"Media buy '{req.media_buy_id}' not found" response_data = UpdateMediaBuyError( errors=[{"code": "media_buy_not_found", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -552,6 +561,7 @@ def _update_media_buy_impl( error_msg = f"Creative IDs not found: {', '.join(missing_ids)}" response_data = UpdateMediaBuyError( errors=[{"code": "creatives_not_found", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -725,6 +735,7 @@ def normalize_url(url: str | None) -> str | None: error_msg = f"Invalid budget: {total_budget}. Budget must be positive." response_data = UpdateMediaBuyError( errors=[{"code": "invalid_budget", "message": error_msg}], + context=req.context, ) ctx_manager.update_workflow_step( step.step_id, @@ -802,6 +813,7 @@ def normalize_url(url: str | None) -> str | None: buyer_ref=req.buyer_ref or "", packages=[], # Required by AdCP spec affected_packages=affected_packages_list if affected_packages_list else [], # Internal field for tracking changes + context=req.context, ) # Persist success with response data, then return @@ -830,7 +842,8 @@ def update_media_buy( packages: list = None, creatives: list = None, push_notification_config: dict | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context + ctx: Context | ToolContext | None = None, ): """Update a media buy with campaign-level and/or package-level changes. @@ -874,6 +887,7 @@ def update_media_buy( creatives=creatives, push_notification_config=push_notification_config, context=context, + ctx=ctx, ) return ToolResult(content=str(response), structured_content=response.model_dump()) @@ -894,7 +908,8 @@ def update_media_buy_raw( packages: list = None, creatives: list = None, push_notification_config: dict = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context + ctx: Context | ToolContext | None = None, ): """Update an existing media buy (raw function for A2A server use). @@ -916,7 +931,8 @@ def update_media_buy_raw( packages: Package updates creatives: Creative updates push_notification_config: Push notification config for status updates - context: Context for authentication + context: Application level context per adcp spec + ctx: Context for authentication Returns: UpdateMediaBuyResponse @@ -938,4 +954,5 @@ def update_media_buy_raw( creatives=creatives, push_notification_config=push_notification_config, context=context, + ctx=ctx, ) diff --git a/src/core/tools/performance.py b/src/core/tools/performance.py index 08924a03c..4be6891e3 100644 --- a/src/core/tools/performance.py +++ b/src/core/tools/performance.py @@ -27,14 +27,18 @@ def _update_performance_index_impl( - media_buy_id: str, performance_data: list[dict[str, Any]], context: Context | ToolContext | None = None + media_buy_id: str, + performance_data: list[dict[str, Any]], + context: dict | None = None, + ctx: Context | ToolContext | None = None, ) -> UpdatePerformanceIndexResponse: """Shared implementation for update_performance_index (used by both MCP and A2A). Args: media_buy_id: ID of the media buy to update performance_data: List of performance data objects - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: UpdatePerformanceIndexResponse with update status @@ -45,15 +49,17 @@ def _update_performance_index_impl( try: performance_objects = [ProductPerformance(**perf) for perf in performance_data] - req = UpdatePerformanceIndexRequest(media_buy_id=media_buy_id, performance_data=performance_objects) + req = UpdatePerformanceIndexRequest( + media_buy_id=media_buy_id, performance_data=performance_objects, context=context + ) except ValidationError as e: raise ToolError(format_validation_error(e, context="update_performance_index request")) from e - if context is None: + if ctx is None: raise ValueError("Context is required for update_performance_index") - _verify_principal(req.media_buy_id, context) - principal_id = _get_principal_id_from_context(context) # Already verified by _verify_principal + _verify_principal(req.media_buy_id, ctx) + principal_id = _get_principal_id_from_context(ctx) # Already verified by _verify_principal if principal_id is None: raise ToolError("Principal ID not found in context - authentication required") @@ -89,6 +95,7 @@ def _update_performance_index_impl( return UpdatePerformanceIndexResponse( status="success" if success else "failed", detail=f"Performance index updated for {len(req.performance_data)} products", + context=req.context, ) @@ -96,7 +103,8 @@ def update_performance_index( media_buy_id: str, performance_data: list[dict[str, Any]], webhook_url: str | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, + ctx: Context | ToolContext | None = None, ): """Update performance index data for a media buy. @@ -106,17 +114,20 @@ def update_performance_index( media_buy_id: ID of the media buy to update performance_data: List of performance data objects webhook_url: URL for async task completion notifications (AdCP spec, optional) - context: FastMCP context (automatically provided) + ctx: FastMCP context (automatically provided) Returns: ToolResult with UpdatePerformanceIndexResponse data """ - response = _update_performance_index_impl(media_buy_id, performance_data, context) + response = _update_performance_index_impl(media_buy_id, performance_data, context, ctx) return ToolResult(content=str(response), structured_content=response.model_dump()) def update_performance_index_raw( - media_buy_id: str, performance_data: list[dict[str, Any]], context: Context | ToolContext | None = None + media_buy_id: str, + performance_data: list[dict[str, Any]], + context: dict | None = None, + ctx: Context | ToolContext | None = None, ): """Update performance data for a media buy (raw function for A2A server use). @@ -125,12 +136,12 @@ def update_performance_index_raw( Args: media_buy_id: The ID of the media buy to update performance for performance_data: List of performance data objects - context: Context for authentication + ctx: Context for authentication Returns: UpdatePerformanceIndexResponse """ - return _update_performance_index_impl(media_buy_id, performance_data, context) + return _update_performance_index_impl(media_buy_id, performance_data, context, ctx) # --- Human-in-the-Loop Task Queue Tools --- diff --git a/src/core/tools/products.py b/src/core/tools/products.py index 8a076e0b8..4e57f4564 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -125,6 +125,8 @@ async def _get_products_impl( testing_ctx = testing_ctx_raw principal_id: str | None = context.principal_id tenant: dict[str, Any] = {"tenant_id": context.tenant_id} # Simplified tenant info + # Ensure ContextVar is populated for helpers that require tenant context + set_current_tenant(tenant) else: # Legacy path - extract from FastMCP Context if context is None: @@ -601,7 +603,9 @@ async def _get_products_impl( product.pricing_options = [] # Response __str__() will generate appropriate message based on content - return GetProductsResponse(products=modified_products, errors=None) + resp = GetProductsResponse(products=modified_products, errors=None, context=req.context) + + return resp async def get_products( @@ -609,7 +613,8 @@ async def get_products( brief: str = "", filters: dict | None = None, push_notification_config: PushNotificationConfig | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context + ctx: Context | ToolContext | None = None, ): """Get available products matching the brief. @@ -620,7 +625,8 @@ async def get_products( Example: {"name": "Acme", "url": "https://acme.com"} brief: Brief description of the advertising campaign or requirements (optional) filters: Structured filters for product discovery (optional) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) push_notification_config: Optional webhook configuration (accepted, ignored by this operation) Returns: @@ -632,7 +638,9 @@ async def get_products( brief=brief, brand_manifest=brand_manifest, filters=filters, + context=context, ) + except ValidationError as e: raise ToolError(format_validation_error(e, context="get_products request")) from e except ValueError as e: @@ -641,7 +649,7 @@ async def get_products( # Call shared implementation # Note: GetProductsRequest is now a flat class (not RootModel), so pass req directly - response = await _get_products_impl(req, context) + response = await _get_products_impl(req, ctx) # Return ToolResult with human-readable text and structured data return ToolResult(content=str(response), structured_content=response.model_dump()) @@ -654,7 +662,8 @@ async def get_products_raw( min_exposures: int | None = None, filters: dict | None = None, strategy_id: str | None = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # Application level context per adcp spec + ctx: Context | ToolContext | None = None, ) -> GetProductsResponse: """Get available products matching the brief. @@ -669,7 +678,8 @@ async def get_products_raw( min_exposures: Minimum impressions needed for measurement validity (optional) filters: Structured filters for product discovery (optional) strategy_id: Optional strategy ID for linking operations (optional) - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: GetProductsResponse containing matching products @@ -679,10 +689,11 @@ async def get_products_raw( brief=brief or "", brand_manifest=brand_manifest, filters=filters, + context=context, ) # Call shared implementation - return await _get_products_impl(req, context) + return await _get_products_impl(req, ctx) def get_product_catalog() -> list[Product]: diff --git a/src/core/tools/properties.py b/src/core/tools/properties.py index 0a8a2e5e5..e85cc335a 100644 --- a/src/core/tools/properties.py +++ b/src/core/tools/properties.py @@ -51,7 +51,7 @@ def _list_authorized_properties_impl( # Handle missing request object (allows empty calls) if req is None: - req = ListAuthorizedPropertiesRequest(tags=None) + req = ListAuthorizedPropertiesRequest(tags=None, context=None) # Get tenant and principal from context # Authentication is OPTIONAL for discovery endpoints (returns public inventory) @@ -222,6 +222,10 @@ def _list_authorized_properties_impl( response = ListAuthorizedPropertiesResponse(**response_data) + # Carry back application context from request if provided + if req.context is not None: + response.context = req.context + # Log audit audit_logger = get_audit_logger("AdCP", tenant_id) audit_logger.log_operation( @@ -260,7 +264,8 @@ def _list_authorized_properties_impl( def list_authorized_properties( req: ListAuthorizedPropertiesRequest | None = None, webhook_url: str | None = None, - context: Context | ToolContext | None = None, + ctx: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context ): """List all properties this agent is authorized to represent (AdCP spec endpoint). @@ -269,7 +274,8 @@ def list_authorized_properties( Args: req: Request parameters including optional tag filters webhook_url: URL for async task completion notifications (AdCP spec, optional) - context: FastMCP context for authentication + context: Application level context per adcp spec + ctx: FastMCP context for authentication Returns: ToolResult with human-readable text and structured data @@ -282,17 +288,17 @@ def list_authorized_properties( logger = logging.getLogger(__name__) tool_context: Context | ToolContext | None = None - if context: + if ctx: try: # Log ALL headers received for debugging virtual host issues logger.error("🔍 MCP list_authorized_properties called") - logger.error(f"🔍 context type={type(context)}") + logger.error(f"🔍 context type={type(ctx)}") # Access raw Starlette request headers via context.request_context.request # ToolContext doesn't have request_context (A2A path doesn't use Starlette) request = None - if isinstance(context, Context) and hasattr(context, "request_context"): - request = context.request_context.request + if isinstance(ctx, Context) and hasattr(ctx, "request_context"): + request = ctx.request_context.request logger.error(f"🔍 request type={type(request) if request else None}") if request and hasattr(request, "headers"): @@ -321,18 +327,19 @@ def __init__(self, headers: dict[str, str]): else: print("[MCP DEBUG] request has no headers attribute", file=sys.stderr, flush=True) logger.warning("MCP list_authorized_properties: request has no headers attribute") - tool_context = context + tool_context = ctx except Exception as e: # Fallback to passing context as-is print(f"[MCP DEBUG] Exception extracting headers: {e}", file=sys.stderr, flush=True) logger.error( f"MCP list_authorized_properties: Could not extract headers from FastMCP context: {e}", exc_info=True ) - tool_context = context + tool_context = ctx else: print("[MCP DEBUG] No context provided", file=sys.stderr, flush=True) logger.info("MCP list_authorized_properties: No context provided") - tool_context = context + tool_context = ctx + response = _list_authorized_properties_impl(req, tool_context) @@ -343,7 +350,7 @@ def __init__(self, headers: dict[str, str]): def list_authorized_properties_raw( - req: "ListAuthorizedPropertiesRequest" = None, context: Context | ToolContext | None = None + req: "ListAuthorizedPropertiesRequest" = None, ctx: Context | ToolContext | None = None ) -> "ListAuthorizedPropertiesResponse": """List all properties this agent is authorized to represent (raw function for A2A server use). @@ -356,4 +363,4 @@ def list_authorized_properties_raw( Returns: ListAuthorizedPropertiesResponse with authorized properties """ - return _list_authorized_properties_impl(req, context) + return _list_authorized_properties_impl(req, ctx) diff --git a/src/core/tools/signals.py b/src/core/tools/signals.py index a066c489e..f004f549d 100644 --- a/src/core/tools/signals.py +++ b/src/core/tools/signals.py @@ -262,7 +262,8 @@ async def _activate_signal_impl( signal_id: str, campaign_id: str = None, media_buy_id: str = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context + ctx: Context | ToolContext | None = None, ) -> ActivateSignalResponse: """Shared implementation for activate_signal (used by both MCP and A2A). @@ -270,7 +271,8 @@ async def _activate_signal_impl( signal_id: Signal ID to activate campaign_id: Optional campaign ID to activate signal for media_buy_id: Optional media buy ID to activate signal for - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ActivateSignalResponse with activation status @@ -278,7 +280,7 @@ async def _activate_signal_impl( start_time = time.time() # Authentication required for signal activation - principal_id = _get_principal_id_from_context(context) + principal_id = _get_principal_id_from_context(ctx) # Get tenant information tenant = get_current_tenant() @@ -291,9 +293,9 @@ async def _activate_signal_impl( principal = get_principal_object(principal_id) # Apply testing hooks - if not context: + if not ctx: raise ToolError("Context required for signal activation") - testing_ctx = get_testing_context(context) + testing_ctx = get_testing_context(ctx) campaign_info = {"endpoint": "activate_signal", "signal_id": signal_id} # Note: apply_testing_hooks modifies response data dict, not called here as no response yet @@ -333,6 +335,7 @@ async def _activate_signal_impl( task_id=task_id, status=status, errors=errors, + context=context ) else: response = ActivateSignalResponse( @@ -342,6 +345,7 @@ async def _activate_signal_impl( estimated_activation_duration_minutes=( estimated_activation_duration_minutes if activation_success else None ), + context=context ) return response @@ -351,6 +355,7 @@ async def _activate_signal_impl( task_id=f"task_{uuid.uuid4().hex[:12]}", status="failed", errors=[{"code": "ACTIVATION_ERROR", "message": str(e)}], + context=context, ) @@ -358,7 +363,8 @@ async def activate_signal( signal_id: str, campaign_id: str = None, media_buy_id: str = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context + ctx: Context | ToolContext | None = None, ): """Activate a signal for use in campaigns. @@ -368,16 +374,17 @@ async def activate_signal( signal_id: Signal ID to activate campaign_id: Optional campaign ID to activate signal for media_buy_id: Optional media buy ID to activate signal for - context: FastMCP context (automatically provided) + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ToolResult with ActivateSignalResponse data """ - response = await _activate_signal_impl(signal_id, campaign_id, media_buy_id, context) + response = await _activate_signal_impl(signal_id, campaign_id, media_buy_id, context, ctx) return ToolResult(content=str(response), structured_content=response.model_dump()) -async def get_signals_raw(req: GetSignalsRequest, context: Context | ToolContext | None = None) -> GetSignalsResponse: +async def get_signals_raw(req: GetSignalsRequest, ctx: Context | ToolContext | None = None) -> GetSignalsResponse: """Optional endpoint for discovering available signals (raw function for A2A server use). Delegates to the shared implementation. @@ -389,14 +396,15 @@ async def get_signals_raw(req: GetSignalsRequest, context: Context | ToolContext Returns: GetSignalsResponse containing matching signals """ - return await _get_signals_impl(req, context) + return await _get_signals_impl(req, ctx) async def activate_signal_raw( signal_id: str, campaign_id: str = None, media_buy_id: str = None, - context: Context | ToolContext | None = None, + context: dict | None = None, # payload-level context + ctx: Context | ToolContext | None = None, ) -> ActivateSignalResponse: """Activate a signal for use in campaigns (raw function for A2A server use). @@ -406,9 +414,10 @@ async def activate_signal_raw( signal_id: Signal ID to activate campaign_id: Optional campaign ID to activate signal for media_buy_id: Optional media buy ID to activate signal for - context: Context for authentication + context: Application level context per adcp spec + ctx: FastMCP context (automatically provided) Returns: ActivateSignalResponse with activation status """ - return await _activate_signal_impl(signal_id, campaign_id, media_buy_id, context) + return await _activate_signal_impl(signal_id, campaign_id, media_buy_id, context, ctx) diff --git a/tests/e2e/adcp_request_builder.py b/tests/e2e/adcp_request_builder.py index 4295cd7a5..d33d30d84 100644 --- a/tests/e2e/adcp_request_builder.py +++ b/tests/e2e/adcp_request_builder.py @@ -54,6 +54,7 @@ def build_adcp_media_buy_request( pacing: str = "even", webhook_url: str | None = None, brand_manifest: dict[str, Any] | str | None = None, # AdCP spec field (preferred) + context: dict[str, Any] | None = None, ) -> dict[str, Any]: """ Build a valid AdCP V2.3 create_media_buy request. @@ -125,6 +126,9 @@ def build_adcp_media_buy_request( "authentication": {"type": "none"}, } + if context: + request["context"] = context + return request diff --git a/tests/e2e/test_a2a_adcp_compliance.py b/tests/e2e/test_a2a_adcp_compliance.py index fdc113c7e..ea2edf5a0 100644 --- a/tests/e2e/test_a2a_adcp_compliance.py +++ b/tests/e2e/test_a2a_adcp_compliance.py @@ -309,6 +309,7 @@ async def test_explicit_skill_get_products(self, compliance_client, compliance_r { "brief": "Video advertising for sports content", "brand_manifest": {"name": "Athletic apparel brand"}, + "context": {"e2e": "get_products"}, }, ) @@ -316,6 +317,9 @@ async def test_explicit_skill_get_products(self, compliance_client, compliance_r compliance_report.add_result(validation_result) assert "skill" in validation_result + # Verify context echoed + payload = compliance_client.extract_adcp_payload_from_a2a_response(response) + assert payload and payload.get("context") == {"e2e": "get_products"} @pytest.mark.asyncio async def test_explicit_skill_create_media_buy(self, compliance_client, compliance_report): @@ -327,6 +331,7 @@ async def test_explicit_skill_create_media_buy(self, compliance_client, complian "total_budget": 10000.0, "flight_start_date": "2025-02-01", "flight_end_date": "2025-02-28", + "context": {"e2e": "create_media_buy"}, }, ) @@ -334,6 +339,9 @@ async def test_explicit_skill_create_media_buy(self, compliance_client, complian compliance_report.add_result(validation_result) assert "skill" in validation_result + # Verify context echoed + payload = compliance_client.extract_adcp_payload_from_a2a_response(response) + assert payload and payload.get("context") == {"e2e": "create_media_buy"} @pytest.mark.asyncio async def test_explicit_skill_get_signals(self, compliance_client, compliance_report): diff --git a/tests/e2e/test_adcp_reference_implementation.py b/tests/e2e/test_adcp_reference_implementation.py index ace33b9b7..abefd5bfb 100644 --- a/tests/e2e/test_adcp_reference_implementation.py +++ b/tests/e2e/test_adcp_reference_implementation.py @@ -130,6 +130,7 @@ async def test_complete_campaign_lifecycle_with_webhooks( { "brand_manifest": {"name": "Premium Athletic Footwear"}, "brief": "display advertising", + "context": {"e2e": "get_products"}, }, ) products_data = parse_tool_result(products_result) @@ -141,6 +142,8 @@ async def test_complete_campaign_lifecycle_with_webhooks( assert "products" in products_data, "Response must contain products" assert len(products_data["products"]) > 0, "Must have at least one product" + # Context should echo back + assert products_data.get("context") == {"e2e": "get_products"} # Get first product product = products_data["products"][0] @@ -175,8 +178,9 @@ async def test_complete_campaign_lifecycle_with_webhooks( "demographic": {"age_range": "25-44"}, }, webhook_url=webhook_server["url"], # Async notifications! + context={"e2e": "create_media_buy"}, ) - + # Create media buy (pass params directly - no req wrapper) media_buy_result = await client.call_tool("create_media_buy", media_buy_request) media_buy_data = parse_tool_result(media_buy_result) @@ -198,6 +202,8 @@ async def test_complete_campaign_lifecycle_with_webhooks( print(f" ✓ Media buy created: {media_buy_id}") print(f" ✓ Status: {media_buy_data.get('status', 'unknown')}") print(f" ✓ Webhook configured: {webhook_server['url']}") + # Context should echo back + assert media_buy_data.get("context") == {"e2e": "create_media_buy"} # ================================================================ # PHASE 3: Creative Sync (Synchronous) @@ -246,6 +252,12 @@ async def test_complete_campaign_lifecycle_with_webhooks( # Verify delivery response structure (AdCP spec: deliveries is an array) assert "deliveries" in delivery_data or "media_buy_deliveries" in delivery_data print(f" ✓ Delivery data retrieved for: {media_buy_id}") + # If context was provided, ensure echo works when present; add context and re-call minimally + delivery_result_ctx = await client.call_tool( + "get_media_buy_delivery", {"media_buy_ids": [media_buy_id], "context": {"e2e": "delivery"}} + ) + delivery_data_ctx = parse_tool_result(delivery_result_ctx) + assert delivery_data_ctx.get("context") == {"e2e": "delivery"} # Check if we have deliveries deliveries = delivery_data.get("deliveries") or delivery_data.get("media_buy_deliveries", []) @@ -269,6 +281,7 @@ async def test_complete_campaign_lifecycle_with_webhooks( { "media_buy_id": media_buy_id, "budget": 7500.0, # AdCP spec: budget is a number + "context": {"e2e": "update_media_buy"}, "push_notification_config": { "url": webhook_server["url"], "authentication": {"type": "none"}, @@ -280,6 +293,8 @@ async def test_complete_campaign_lifecycle_with_webhooks( assert "media_buy_id" in update_data or "buyer_ref" in update_data print(" ✓ Budget update requested: $5000 → $7500") print(f" ✓ Update status: {update_data.get('status', 'unknown')}") + # Context should echo back on response + assert update_data.get("context") == {"e2e": "update_media_buy"} # ================================================================ # PHASE 6: Verify Webhook Notification (Async Verification) @@ -309,6 +324,9 @@ async def test_complete_campaign_lifecycle_with_webhooks( # Basic webhook validation assert isinstance(webhook_data, dict), "Webhook data must be a dict" print(" ✓ Webhook data validated") + # Verify context echoed in webhook result payload + if "result" in webhook_data and isinstance(webhook_data["result"], dict): + assert webhook_data["result"].get("context") == {"e2e": "update_media_buy"} else: # Webhook may not be implemented yet - that's okay for this reference test print(f" ⚠ No webhook received after {max_wait}s (may not be implemented yet)") diff --git a/tests/integration/test_a2a_response_compliance.py b/tests/integration/test_a2a_response_compliance.py index eb96d4453..2b48331be 100644 --- a/tests/integration/test_a2a_response_compliance.py +++ b/tests/integration/test_a2a_response_compliance.py @@ -42,7 +42,9 @@ def test_list_authorized_properties_spec_compliance(self): } # Verify this is spec-compliant - response = ListAuthorizedPropertiesResponse(**response_data) + # Include context and ensure it's present in payload + ctx = {"user_id": "1234567890"} + response = ListAuthorizedPropertiesResponse(**response_data, context=ctx) # Check response has NO extra fields spec_fields = { @@ -53,6 +55,7 @@ def test_list_authorized_properties_spec_compliance(self): "advertising_policies", "last_updated", "errors", + "context", } response_fields = set(response.model_dump().keys()) extra_fields = response_fields - spec_fields @@ -69,10 +72,11 @@ def test_get_products_spec_compliance(self): "errors": None, } - response = GetProductsResponse(**response_data) + ctx = {"user_id": "1234567890"} + response = GetProductsResponse(**response_data, context=ctx) # Check no extra fields - spec_fields = {"products", "errors", "status"} + spec_fields = {"products", "errors", "status", "context"} response_fields = set(response.model_dump().keys()) extra_fields = response_fields - spec_fields @@ -95,10 +99,11 @@ def test_sync_creatives_spec_compliance(self): "dry_run": False, } - response = SyncCreativesResponse(**response_data) + ctx = {"user_id": "1234567890"} + response = SyncCreativesResponse(**response_data, context=ctx) # Check no extra fields - spec_fields = {"creatives", "dry_run"} + spec_fields = {"creatives", "dry_run", "context"} response_fields = set(response.model_dump().keys()) extra_fields = response_fields - spec_fields @@ -116,7 +121,8 @@ def test_list_creatives_spec_compliance(self): "creatives": [], } - response = ListCreativesResponse(**response_data) + ctx = {"user_id": "1234567890"} + response = ListCreativesResponse(**response_data, context=ctx) # Check no extra fields spec_fields = { @@ -126,6 +132,7 @@ def test_list_creatives_spec_compliance(self): "context_id", "format_summary", "status_summary", + "context", } response_fields = set(response.model_dump().keys()) extra_fields = response_fields - spec_fields @@ -141,10 +148,11 @@ def test_list_creative_formats_spec_compliance(self): "errors": None, } - response = ListCreativeFormatsResponse(**response_data) + ctx = {"user_id": "1234567890"} + response = ListCreativeFormatsResponse(**response_data, context=ctx) # Check no extra fields - spec_fields = {"formats", "creative_agents", "errors", "status"} + spec_fields = {"formats", "creative_agents", "errors", "status", "context"} response_fields = set(response.model_dump().keys()) extra_fields = response_fields - spec_fields @@ -153,9 +161,11 @@ def test_list_creative_formats_spec_compliance(self): def test_create_media_buy_spec_compliance(self): """Test create_media_buy returns only spec-defined fields.""" + ctx = {"user_id": "1234567890"} response = CreateMediaBuyResponse( buyer_ref="test-123", media_buy_id="mb-456", + context=ctx, ) # Check response can be dumped (has all required fields) @@ -172,9 +182,11 @@ def test_create_media_buy_spec_compliance(self): def test_update_media_buy_spec_compliance(self): """Test update_media_buy returns only spec-defined fields.""" + ctx = {"user_id": "1234567890"} response = UpdateMediaBuyResponse( buyer_ref="test-123", media_buy_id="mb-456", + context=ctx, ) response_dict = response.model_dump() @@ -192,6 +204,7 @@ def test_get_media_buy_delivery_spec_compliance(self): from src.core.schemas import ReportingPeriod + ctx = {"user_id": "1234567890"} response = GetMediaBuyDeliveryResponse( reporting_period=ReportingPeriod( start=datetime.now(UTC).isoformat(), @@ -199,6 +212,7 @@ def test_get_media_buy_delivery_spec_compliance(self): ), currency="USD", media_buy_deliveries=[], + context=ctx, ) response_dict = response.model_dump() diff --git a/tests/integration/test_a2a_response_message_fields.py b/tests/integration/test_a2a_response_message_fields.py index dd3e1bfd5..f4d8d1bf9 100644 --- a/tests/integration/test_a2a_response_message_fields.py +++ b/tests/integration/test_a2a_response_message_fields.py @@ -128,7 +128,7 @@ async def test_get_products_message_field_exists(self, handler, mock_auth_contex with mock_auth_context(handler): params = { "brand_manifest": {"name": "Test product search"}, - "brief": "Looking for display ads", + "brief": "Looking for display ads" } result = await handler._handle_get_products_skill(params, sample_principal["access_token"]) diff --git a/tests/integration/test_creative_sync_data_preservation.py b/tests/integration/test_creative_sync_data_preservation.py index e5bbf59b1..aff4e772a 100644 --- a/tests/integration/test_creative_sync_data_preservation.py +++ b/tests/integration/test_creative_sync_data_preservation.py @@ -176,7 +176,7 @@ def test_sync_preserves_user_url_when_preview_available(self, mock_get_registry) context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "preserve-url-001", @@ -251,7 +251,7 @@ def test_sync_preserves_dimensions_when_preview_has_different_size(self, mock_ge context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "preserve-dims-001", @@ -340,7 +340,7 @@ def test_generative_output_preserves_user_assets(self, mock_get_config, mock_get context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "preserve-assets-001", @@ -416,7 +416,7 @@ def test_generative_output_preserves_user_url(self, mock_get_config, mock_get_re context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "preserve-gen-url-001", @@ -486,7 +486,7 @@ def test_update_preserves_user_url_when_preview_changes(self, mock_get_registry) # First create the creative original_url = "https://user-v1.example.com/banner.png" sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "update-preserve-001", @@ -500,7 +500,7 @@ def test_update_preserves_user_url_when_preview_changes(self, mock_get_registry) # Now update with new user URL new_user_url = "https://user-v2.example.com/banner-updated.png" result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "update-preserve-001", diff --git a/tests/integration/test_cross_principal_security.py b/tests/integration/test_cross_principal_security.py index 4db5aebdd..863459b52 100644 --- a/tests/integration/test_cross_principal_security.py +++ b/tests/integration/test_cross_principal_security.py @@ -154,7 +154,7 @@ def test_list_creatives_cannot_see_other_principals_creatives(self): "host": "security-test.sales-agent.scope3.com", }, ): - response = _list_creatives_impl(context=mock_context_b) + response = _list_creatives_impl(ctx=mock_context_b) assert isinstance(response, ListCreativesResponse) @@ -185,7 +185,7 @@ def test_update_media_buy_cannot_modify_other_principals_media_buy(self): _update_media_buy_impl( media_buy_id="media_buy_a", # Owned by Principal A! buyer_ref="hacked_by_b", - context=mock_context_b, + ctx=mock_context_b, ) # Verify media buy was NOT modified @@ -219,7 +219,7 @@ def test_get_media_buy_delivery_cannot_see_other_principals_data(self): "host": "security-test.sales-agent.scope3.com", }, ): - response = _get_media_buy_delivery_impl(req=request, context=mock_context_b) + response = _get_media_buy_delivery_impl(req=request, ctx=mock_context_b) # Principal B should NOT see Principal A's media buy assert len(response.media_buy_deliveries) == 0, "Principal B should not see Principal A's delivery data!" @@ -286,7 +286,7 @@ def test_cross_tenant_isolation_also_enforced(self): "host": "security-test.sales-agent.scope3.com", }, ): - response = _list_creatives_impl(context=mock_context_a) + response = _list_creatives_impl(ctx=mock_context_a) # Should only see their own creative, not creative_c from other tenant creative_ids = [c.creative_id for c in response.creatives] diff --git a/tests/integration/test_duplicate_product_validation.py b/tests/integration/test_duplicate_product_validation.py index b45b71e8f..a52fbe275 100644 --- a/tests/integration/test_duplicate_product_validation.py +++ b/tests/integration/test_duplicate_product_validation.py @@ -88,7 +88,7 @@ async def test_duplicate_product_in_packages_rejected(self, integration_db): start_time=start_time, end_time=end_time, budget=Budget(total=2500, currency="USD"), - context=mock_context, + ctx=mock_context, ) # Verify response contains error about duplicate products @@ -175,7 +175,7 @@ async def test_multiple_duplicate_products_all_listed(self, integration_db): start_time=start_time, end_time=end_time, budget=Budget(total=6300, currency="USD"), - context=mock_context, + ctx=mock_context, ) # Verify both duplicate products are mentioned diff --git a/tests/integration/test_gam_pricing_models_integration.py b/tests/integration/test_gam_pricing_models_integration.py index 53ce3d739..293a1e669 100644 --- a/tests/integration/test_gam_pricing_models_integration.py +++ b/tests/integration/test_gam_pricing_models_integration.py @@ -396,7 +396,7 @@ async def test_gam_cpm_guaranteed_creates_standard_line_item(setup_gam_tenant_wi start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (AdCP 2.4 compliant) @@ -451,7 +451,7 @@ async def test_gam_cpc_creates_price_priority_line_item_with_clicks_goal(setup_g start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (AdCP 2.4 compliant) @@ -507,7 +507,7 @@ async def test_gam_vcpm_creates_standard_line_item_with_viewable_impressions(set start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (AdCP 2.4 compliant) @@ -564,7 +564,7 @@ async def test_gam_flat_rate_calculates_cpd_correctly(setup_gam_tenant_with_all_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (AdCP 2.4 compliant) @@ -634,7 +634,7 @@ async def test_gam_multi_package_mixed_pricing_models(setup_gam_tenant_with_all_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (AdCP 2.4 compliant) @@ -704,7 +704,7 @@ async def test_gam_auction_cpc_creates_price_priority(setup_gam_tenant_with_all_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (AdCP 2.4 compliant) diff --git a/tests/integration/test_gam_pricing_restriction.py b/tests/integration/test_gam_pricing_restriction.py index a48e91561..08e51cf49 100644 --- a/tests/integration/test_gam_pricing_restriction.py +++ b/tests/integration/test_gam_pricing_restriction.py @@ -298,7 +298,7 @@ async def test_gam_rejects_cpcv_pricing_model(setup_gam_tenant_with_non_cpm_prod start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify adapter returned error response @@ -351,7 +351,7 @@ async def test_gam_accepts_cpm_pricing_model(setup_gam_tenant_with_non_cpm_produ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (adcp v1.2.1 oneOf pattern) @@ -401,7 +401,7 @@ async def test_gam_rejects_cpp_from_multi_pricing_product(setup_gam_tenant_with_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify adapter returned error response @@ -454,7 +454,7 @@ async def test_gam_accepts_cpm_from_multi_pricing_product(setup_gam_tenant_with_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, ) # Verify response (adcp v1.2.1 oneOf pattern) diff --git a/tests/integration/test_generative_creatives.py b/tests/integration/test_generative_creatives.py index 4cd4b3143..5fa96afa3 100644 --- a/tests/integration/test_generative_creatives.py +++ b/tests/integration/test_generative_creatives.py @@ -133,7 +133,7 @@ def test_generative_format_detection_calls_build_creative(self, mock_get_config, context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-001", @@ -209,7 +209,7 @@ def test_static_format_calls_preview_creative(self, mock_get_config, mock_get_re context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "static-creative-001", @@ -257,7 +257,7 @@ def test_missing_gemini_api_key_raises_error(self, mock_get_config, mock_get_reg context = MockContext() result = sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-002", @@ -308,7 +308,7 @@ def test_message_extraction_from_assets(self, mock_get_config, mock_get_registry # Test with "brief" role sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-003", @@ -355,7 +355,7 @@ def test_message_fallback_to_creative_name(self, mock_get_config, mock_get_regis # No message in assets sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-004", @@ -404,7 +404,7 @@ def test_context_id_reuse_for_refinement(self, mock_get_config, mock_get_registr # Create initial creative sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-005", @@ -430,7 +430,7 @@ def test_context_id_reuse_for_refinement(self, mock_get_config, mock_get_registr ) sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-005", # Same ID @@ -483,7 +483,7 @@ def test_promoted_offerings_extraction(self, mock_get_config, mock_get_registry) } sync_fn( - context=context, + ctx=context, creatives=[ { "creative_id": "gen-creative-006", diff --git a/tests/integration/test_list_creatives_auth.py b/tests/integration/test_list_creatives_auth.py index e97374188..3bfef3ce9 100644 --- a/tests/integration/test_list_creatives_auth.py +++ b/tests/integration/test_list_creatives_auth.py @@ -139,7 +139,7 @@ def test_unauthenticated_request_should_fail(self): from fastmcp.exceptions import ToolError with pytest.raises(ToolError, match="Missing x-adcp-auth header"): - core_list_creatives_tool(context=mock_context) + core_list_creatives_tool(ctx=mock_context) def test_authenticated_user_sees_only_own_creatives(self): """Test that authenticated users only see their own creatives. @@ -160,7 +160,7 @@ def test_authenticated_user_sees_only_own_creatives(self): "host": "auth-test.sales-agent.scope3.com", }, ): - response = core_list_creatives_tool(context=mock_context) + response = core_list_creatives_tool(ctx=mock_context) # Verify response structure assert isinstance(response, ListCreativesResponse) @@ -192,7 +192,7 @@ def test_different_principal_sees_different_creatives(self): "host": "auth-test.sales-agent.scope3.com", }, ): - response = core_list_creatives_tool(context=mock_context_b) + response = core_list_creatives_tool(ctx=mock_context_b) # Verify response structure assert isinstance(response, ListCreativesResponse) @@ -228,4 +228,4 @@ def test_invalid_token_should_fail(self): # This should raise ToolError due to invalid authentication with pytest.raises(ToolError, match="INVALID_AUTH_TOKEN"): - core_list_creatives_tool(context=mock_context) + core_list_creatives_tool(ctx=mock_context) diff --git a/tests/integration/test_pricing_models_integration.py b/tests/integration/test_pricing_models_integration.py index 4068a8509..2523a932b 100644 --- a/tests/integration/test_pricing_models_integration.py +++ b/tests/integration/test_pricing_models_integration.py @@ -296,7 +296,8 @@ async def test_create_media_buy_with_cpm_fixed_pricing(setup_tenant_with_pricing start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, + context=None, ) assert response.media_buy_id is not None @@ -340,7 +341,8 @@ async def test_create_media_buy_with_cpm_auction_pricing(setup_tenant_with_prici start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + context=None, + ctx=context, ) assert response.media_buy_id is not None @@ -384,7 +386,8 @@ async def test_create_media_buy_auction_bid_below_floor_fails(setup_tenant_with_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, + context=None, ) # Check for errors in response (AdCP 2.4 compliant) @@ -429,7 +432,8 @@ async def test_create_media_buy_with_cpcv_pricing(setup_tenant_with_pricing_prod start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, + context=None, ) assert response.media_buy_id is not None @@ -472,7 +476,8 @@ async def test_create_media_buy_below_min_spend_fails(setup_tenant_with_pricing_ start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + context=None, + ctx=context, ) # Check for errors in response (AdCP 2.4 compliant) @@ -517,7 +522,8 @@ async def test_create_media_buy_multi_pricing_choose_cpp(setup_tenant_with_prici start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, + context=None, ) assert response.media_buy_id is not None @@ -560,7 +566,8 @@ async def test_create_media_buy_invalid_pricing_model_fails(setup_tenant_with_pr start_time=request.start_time, end_time=request.end_time, budget=request.budget, - context=context, + ctx=context, + context=None, ) # Check for errors in response (AdCP 2.4 compliant) diff --git a/tests/integration/test_update_media_buy_creative_assignment.py b/tests/integration/test_update_media_buy_creative_assignment.py index cc7259553..0e2036e92 100644 --- a/tests/integration/test_update_media_buy_creative_assignment.py +++ b/tests/integration/test_update_media_buy_creative_assignment.py @@ -133,7 +133,7 @@ def test_update_media_buy_assigns_creatives_to_package(integration_db): "creative_ids": ["creative_1", "creative_2"], } ], - context=mock_context, + ctx=mock_context, ) # Verify response @@ -310,7 +310,7 @@ def test_update_media_buy_replaces_creatives(integration_db): "creative_ids": ["creative_2", "creative_3"], } ], - context=mock_context, + ctx=mock_context, ) # Verify response @@ -437,7 +437,7 @@ def test_update_media_buy_rejects_missing_creatives(integration_db): "creative_ids": ["nonexistent_creative"], } ], - context=mock_context, + ctx=mock_context, ) # Verify error response diff --git a/tests/integration/test_update_media_buy_persistence.py b/tests/integration/test_update_media_buy_persistence.py index f49078eba..1ef3d90a7 100644 --- a/tests/integration/test_update_media_buy_persistence.py +++ b/tests/integration/test_update_media_buy_persistence.py @@ -154,7 +154,7 @@ def test_update_media_buy_with_database_persisted_buy(test_tenant_setup): response = _update_media_buy_impl( media_buy_id=media_buy_id, buyer_ref="updated_ref", - context=context, + ctx=context, ) # Verify response @@ -190,5 +190,5 @@ def test_update_media_buy_requires_media_buy_id(): _update_media_buy_impl( media_buy_id=None, buyer_ref="test_ref", - context=context, + ctx=context, ) diff --git a/tests/integration_v2/test_create_media_buy_v24.py b/tests/integration_v2/test_create_media_buy_v24.py index 5d36a0b58..e3b99c040 100644 --- a/tests/integration_v2/test_create_media_buy_v24.py +++ b/tests/integration_v2/test_create_media_buy_v24.py @@ -233,7 +233,7 @@ async def test_create_media_buy_with_package_budget_mcp(self, setup_test_tenant) end_time=datetime.now(UTC) + timedelta(days=31), budget=Budget(total=5000.0, currency="USD"), # REQUIRED per AdCP v2.2.0 po_number="TEST-V24-001", - context=context, + ctx=context, ) # Verify response structure @@ -286,7 +286,7 @@ async def test_create_media_buy_with_targeting_overlay_mcp(self, setup_test_tena end_time=datetime.now(UTC) + timedelta(days=31), budget=Budget(total=8000.0, currency="EUR"), # REQUIRED per AdCP v2.2.0 po_number="TEST-V24-002", - context=context, + ctx=context, ) # Verify response structure @@ -346,7 +346,7 @@ async def test_create_media_buy_multiple_packages_with_budgets_mcp(self, setup_t end_time=datetime.now(UTC) + timedelta(days=31), budget=Budget(total=total_budget_value, currency="USD"), # REQUIRED per AdCP v2.2.0 po_number="TEST-V24-003", - context=context, + ctx=context, ) # Verify all packages serialized correctly @@ -390,7 +390,7 @@ async def test_create_media_buy_with_package_budget_a2a(self, setup_test_tenant) end_time=datetime.now(UTC) + timedelta(days=31), budget=Budget(total=6000.0, currency="USD"), # REQUIRED per AdCP v2.2.0 po_number="TEST-V24-A2A-001", - context=context, + ctx=context, ) # Verify response structure (same as MCP) @@ -431,7 +431,7 @@ async def test_create_media_buy_legacy_format_still_works(self, setup_test_tenan total_budget=4000.0, # Legacy parameter start_date=(datetime.now(UTC) + timedelta(days=1)).date(), # Legacy parameter end_date=(datetime.now(UTC) + timedelta(days=31)).date(), # Legacy parameter - context=context, + ctx=context, ) # Verify response diff --git a/tests/integration_v2/test_creative_lifecycle_mcp.py b/tests/integration_v2/test_creative_lifecycle_mcp.py index c57e92e71..92449d80b 100644 --- a/tests/integration_v2/test_creative_lifecycle_mcp.py +++ b/tests/integration_v2/test_creative_lifecycle_mcp.py @@ -198,7 +198,7 @@ def test_sync_creatives_create_new_creatives(self, mock_context, sample_creative patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Call sync_creatives tool (uses default patch=False for full upsert) - response = core_sync_creatives_tool(creatives=sample_creatives, context=mock_context) + response = core_sync_creatives_tool(creatives=sample_creatives, ctx=mock_context) # Verify response structure (AdCP-compliant domain response) assert isinstance(response, SyncCreativesResponse) @@ -274,7 +274,7 @@ def test_sync_creatives_upsert_existing_creative(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Upsert with patch=False (default): full replacement - response = core_sync_creatives_tool(creatives=updated_creative_data, context=mock_context) + response = core_sync_creatives_tool(creatives=updated_creative_data, ctx=mock_context) # Verify response (domain response has creatives list, not summary/results) assert len(response.creatives) == 1 @@ -312,7 +312,7 @@ def test_sync_creatives_with_package_assignments(self, mock_context, sample_crea response = core_sync_creatives_tool( creatives=creative_data, assignments={creative_id: ["package_1", "package_2"]}, - context=mock_context, + ctx=mock_context, ) # Verify response structure @@ -348,7 +348,7 @@ def test_sync_creatives_with_assignments_lookup(self, mock_context, sample_creat response = core_sync_creatives_tool( creatives=creative_data, assignments={creative_id: ["package_buyer_ref"]}, - context=mock_context, + ctx=mock_context, ) # Verify response structure @@ -386,7 +386,7 @@ def test_sync_creatives_validation_failures(self, mock_context): patch("src.core.helpers.get_principal_id_from_context", return_value=self.test_principal_id), patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): - response = core_sync_creatives_tool(creatives=invalid_creatives, context=mock_context) + response = core_sync_creatives_tool(creatives=invalid_creatives, ctx=mock_context) # Should sync valid creative but fail on invalid one # Domain response has creatives list with action field @@ -443,7 +443,7 @@ def test_list_creatives_no_filters(self, mock_context): patch("src.core.helpers.get_principal_id_from_context", return_value=self.test_principal_id), patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): - response = core_list_creatives_tool(context=mock_context) + response = core_list_creatives_tool(ctx=mock_context) # Verify response structure assert isinstance(response, ListCreativesResponse) @@ -493,7 +493,7 @@ def test_list_creatives_with_status_filter(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Test approved filter - response = core_list_creatives_tool(status="approved", context=mock_context) + response = core_list_creatives_tool(status="approved", ctx=mock_context) assert len(response.creatives) == 3 # Check status field (handle both dict and object) for c in response.creatives: @@ -501,7 +501,7 @@ def test_list_creatives_with_status_filter(self, mock_context): assert status_val == "approved" # Test pending filter - response = core_list_creatives_tool(status="pending", context=mock_context) + response = core_list_creatives_tool(status="pending", ctx=mock_context) assert len(response.creatives) == 2 # Check status field (handle both dict and object) for c in response.creatives: @@ -545,7 +545,7 @@ def test_list_creatives_with_format_filter(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Test display format filter - response = core_list_creatives_tool(format="display_300x250", context=mock_context) + response = core_list_creatives_tool(format="display_300x250", ctx=mock_context) assert len(response.creatives) == 2 # Check format field (may be string, FormatId object, or dict) for c in response.creatives: @@ -563,7 +563,7 @@ def test_list_creatives_with_format_filter(self, mock_context): assert format_id == "display_300x250" # Test video format filter - response = core_list_creatives_tool(format="video_640x480", context=mock_context) + response = core_list_creatives_tool(format="video_640x480", ctx=mock_context) assert len(response.creatives) == 3 # Check format field (may be string, FormatId object, or dict) for c in response.creatives: @@ -621,12 +621,12 @@ def test_list_creatives_with_date_filters(self, mock_context): ): # Test created_after filter created_after = (now - timedelta(days=5)).isoformat() - response = core_list_creatives_tool(created_after=created_after, context=mock_context) + response = core_list_creatives_tool(created_after=created_after, ctx=mock_context) assert len(response.creatives) == 2 # Only recent creatives # Test created_before filter created_before = (now - timedelta(days=5)).isoformat() - response = core_list_creatives_tool(created_before=created_before, context=mock_context) + response = core_list_creatives_tool(created_before=created_before, ctx=mock_context) assert len(response.creatives) == 2 # Only old creatives def test_list_creatives_with_search(self, mock_context): @@ -671,7 +671,7 @@ def test_list_creatives_with_search(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Search for "Holiday" - response = core_list_creatives_tool(search="Holiday", context=mock_context) + response = core_list_creatives_tool(search="Holiday", ctx=mock_context) assert len(response.creatives) == 2 # Check name field (handle both dict and object) for c in response.creatives: @@ -679,7 +679,7 @@ def test_list_creatives_with_search(self, mock_context): assert "Holiday" in name_val # Search for "Banner" - response = core_list_creatives_tool(search="Banner", context=mock_context) + response = core_list_creatives_tool(search="Banner", ctx=mock_context) assert len(response.creatives) == 2 # Check name field (handle both dict and object) for c in response.creatives: @@ -711,7 +711,7 @@ def test_list_creatives_pagination_and_sorting(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Test first page - response = core_list_creatives_tool(page=1, limit=10, context=mock_context) + response = core_list_creatives_tool(page=1, limit=10, ctx=mock_context) assert len(response.creatives) == 10 assert response.query_summary.total_matching == 25 assert response.query_summary.returned == 10 @@ -719,21 +719,21 @@ def test_list_creatives_pagination_and_sorting(self, mock_context): assert response.pagination.current_page == 1 # Test second page - response = core_list_creatives_tool(page=2, limit=10, context=mock_context) + response = core_list_creatives_tool(page=2, limit=10, ctx=mock_context) assert len(response.creatives) == 10 assert response.query_summary.returned == 10 assert response.pagination.has_more is True assert response.pagination.current_page == 2 # Test last page - response = core_list_creatives_tool(page=3, limit=10, context=mock_context) + response = core_list_creatives_tool(page=3, limit=10, ctx=mock_context) assert len(response.creatives) == 5 assert response.query_summary.returned == 5 assert response.pagination.has_more is False assert response.pagination.current_page == 3 # Test name sorting ascending - response = core_list_creatives_tool(sort_by="name", sort_order="asc", limit=5, context=mock_context) + response = core_list_creatives_tool(sort_by="name", sort_order="asc", limit=5, ctx=mock_context) creative_names = [c.get("name") if isinstance(c, dict) else c.name for c in response.creatives] assert creative_names == sorted(creative_names) @@ -780,14 +780,14 @@ def test_list_creatives_with_media_buy_assignments(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Filter by media_buy_id - should only return assigned creative - response = core_list_creatives_tool(media_buy_id=self.test_media_buy_id, context=mock_context) + response = core_list_creatives_tool(media_buy_id=self.test_media_buy_id, ctx=mock_context) assert len(response.creatives) == 1 creative = response.creatives[0] creative_id = creative.get("creative_id") if isinstance(creative, dict) else creative.creative_id assert creative_id == "assignment_test_1" # Filter by buyer_ref - should also work - response = core_list_creatives_tool(buyer_ref=self.test_buyer_ref, context=mock_context) + response = core_list_creatives_tool(buyer_ref=self.test_buyer_ref, ctx=mock_context) assert len(response.creatives) == 1 creative = response.creatives[0] creative_id = creative.get("creative_id") if isinstance(creative, dict) else creative.creative_id @@ -803,7 +803,7 @@ def test_sync_creatives_authentication_required(self, sample_creatives): from fastmcp.exceptions import ToolError with pytest.raises((ToolError, ValueError, RuntimeError)): - core_sync_creatives_tool(creatives=sample_creatives, context=mock_context) + core_sync_creatives_tool(creatives=sample_creatives, ctx=mock_context) def test_list_creatives_authentication_optional(self, mock_context): """Test list_creatives authentication behavior.""" @@ -814,12 +814,12 @@ def test_list_creatives_authentication_optional(self, mock_context): # Test 1: Invalid token should raise error mock_context = MockContext("invalid-token") with pytest.raises((ToolError, ValueError, RuntimeError)): - core_list_creatives_tool(context=mock_context) + core_list_creatives_tool(ctx=mock_context) # Test 2: No token also requires auth (list_creatives is not anonymous) mock_context_no_auth = MockContext(None) with pytest.raises((ToolError, ValueError, RuntimeError)): - core_list_creatives_tool(context=mock_context_no_auth) + core_list_creatives_tool(ctx=mock_context_no_auth) def test_sync_creatives_missing_tenant(self, mock_context, sample_creatives): """Test sync_creatives when tenant lookup succeeds even with None mocked. @@ -834,7 +834,7 @@ def test_sync_creatives_missing_tenant(self, mock_context, sample_creatives): patch("src.core.main.get_current_tenant", return_value=None), ): # The function still works because principal lookup finds the tenant - response = core_sync_creatives_tool(creatives=sample_creatives, context=mock_context) + response = core_sync_creatives_tool(creatives=sample_creatives, ctx=mock_context) assert isinstance(response, SyncCreativesResponse) def test_list_creatives_empty_results(self, mock_context): @@ -845,7 +845,7 @@ def test_list_creatives_empty_results(self, mock_context): patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): # Query with filters that match nothing - response = core_list_creatives_tool(status="rejected", context=mock_context) # No rejected creatives exist + response = core_list_creatives_tool(status="rejected", ctx=mock_context) # No rejected creatives exist assert len(response.creatives) == 0 assert response.query_summary.total_matching == 0 @@ -860,7 +860,7 @@ async def test_create_media_buy_with_creative_ids(self, mock_context, sample_cre patch("src.core.helpers.get_principal_id_from_context", return_value=self.test_principal_id), patch("src.core.main.get_current_tenant", return_value={"tenant_id": self.test_tenant_id}), ): - sync_response = core_sync_creatives_tool(creatives=sample_creatives, context=mock_context) + sync_response = core_sync_creatives_tool(creatives=sample_creatives, ctx=mock_context) assert len(sync_response.creatives) == 3 # Import create_media_buy tool @@ -950,7 +950,7 @@ async def test_create_media_buy_with_creative_ids(self, mock_context, sample_cre end_time=datetime.now(UTC) + timedelta(days=30), budget=Budget(total=5000.0, currency="USD"), po_number="PO-TEST-123", - context=mock_context, + ctx=mock_context, ) # Verify response (domain response doesn't have status field) diff --git a/tests/integration_v2/test_error_paths.py b/tests/integration_v2/test_error_paths.py index 7b82d35b1..2c7082e3b 100644 --- a/tests/integration_v2/test_error_paths.py +++ b/tests/integration_v2/test_error_paths.py @@ -163,6 +163,7 @@ async def test_missing_principal_returns_authentication_error(self, test_tenant_ po_number="error_test_po", brand_manifest={"name": "Test campaign"}, buyer_ref="test_buyer", + context={"trace_id": "auth-missing-principal"}, packages=[ { "package_id": "pkg1", @@ -173,7 +174,7 @@ async def test_missing_principal_returns_authentication_error(self, test_tenant_ start_time=future_start.isoformat(), end_time=future_end.isoformat(), budget={"total": 5000.0, "currency": "USD"}, - context=context, + ctx=context, ) # Verify response structure - error cases return CreateMediaBuyError @@ -188,6 +189,8 @@ async def test_missing_principal_returns_authentication_error(self, test_tenant_ assert isinstance(error, Error) assert error.code == "authentication_error" assert "principal" in error.message.lower() or "not found" in error.message.lower() + # Context echoed back + assert response.context == {"trace_id": "auth-missing-principal"} async def test_start_time_in_past_returns_validation_error(self, test_tenant_with_principal): """Test that start_time in past returns Error response with validation_error code. @@ -211,6 +214,7 @@ async def test_start_time_in_past_returns_validation_error(self, test_tenant_wit po_number="error_test_po", brand_manifest={"name": "Test campaign"}, buyer_ref="test_buyer", + context={"trace_id": "past-start"}, packages=[ { "package_id": "pkg1", @@ -221,7 +225,7 @@ async def test_start_time_in_past_returns_validation_error(self, test_tenant_wit start_time=past_start.isoformat(), end_time=past_end.isoformat(), budget={"total": 5000.0, "currency": "USD"}, - context=context, + ctx=context, ) # Verify response structure - error cases return CreateMediaBuyError @@ -234,6 +238,8 @@ async def test_start_time_in_past_returns_validation_error(self, test_tenant_wit error = response.errors[0] assert isinstance(error, Error) assert error.code == "validation_error" + # Context echoed back + assert response.context == {"trace_id": "past-start"} assert "past" in error.message.lower() or "start" in error.message.lower() async def test_end_time_before_start_returns_validation_error(self, test_tenant_with_principal): @@ -263,7 +269,7 @@ async def test_end_time_before_start_returns_validation_error(self, test_tenant_ start_time=start.isoformat(), end_time=end.isoformat(), budget={"total": 5000.0, "currency": "USD"}, - context=context, + ctx=context, ) # Verify response structure - error cases return CreateMediaBuyError @@ -310,7 +316,7 @@ async def test_negative_budget_raises_tool_error(self, test_tenant_with_principa start_time=future_start.isoformat(), end_time=future_end.isoformat(), budget={"total": -1000.0, "currency": "USD"}, - context=context, + ctx=context, ) error_message = str(exc_info.value) @@ -338,7 +344,7 @@ async def test_missing_packages_returns_validation_error(self, test_tenant_with_ start_time=future_start.isoformat(), end_time=future_end.isoformat(), budget={"total": 5000.0, "currency": "USD"}, - context=context, + ctx=context, ) # Verify response structure - error cases return CreateMediaBuyError @@ -394,7 +400,7 @@ async def test_invalid_creative_format_returns_error(self, integration_db): try: response = await sync_creatives_raw( creatives=invalid_creatives, - context=context, + ctx=context, ) # If it returns, check for errors assert response is not None @@ -439,7 +445,7 @@ async def test_invalid_date_format_returns_error(self, integration_db): with pytest.raises(ToolError) as exc_info: await list_creatives_raw( created_after="not-a-date", # Invalid format - context=context, + ctx=context, ) # Verify it's a proper ToolError, not NameError diff --git a/tests/integration_v2/test_get_products_filters.py b/tests/integration_v2/test_get_products_filters.py index 8a1886011..84140b99e 100644 --- a/tests/integration_v2/test_get_products_filters.py +++ b/tests/integration_v2/test_get_products_filters.py @@ -202,7 +202,7 @@ async def test_filter_by_delivery_type_guaranteed(self): result = await get_products( brand_manifest={"name": "Nike Air Jordan 2025 basketball shoes"}, brief="", - context=context, + ctx=context, ) # Verify we got products (baseline test) @@ -226,7 +226,7 @@ async def test_no_filter_returns_all_products(self, mock_context): result = await get_products( brand_manifest={"name": "Nike Air Jordan 2025 basketball shoes"}, brief="", - context=context, + ctx=context, ) # Should return all 5 products created in fixture @@ -250,7 +250,7 @@ async def test_products_have_correct_structure(self, mock_context): result = await get_products( brand_manifest={"name": "Nike Air Jordan 2025 basketball shoes"}, brief="", - context=context, + ctx=context, ) # Check first product has all required fields diff --git a/tests/integration_v2/test_minimum_spend_validation.py b/tests/integration_v2/test_minimum_spend_validation.py index d555fec8b..22fff86e2 100644 --- a/tests/integration_v2/test_minimum_spend_validation.py +++ b/tests/integration_v2/test_minimum_spend_validation.py @@ -279,7 +279,7 @@ async def test_currency_minimum_spend_enforced(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=500.0, currency="USD"), # Explicit USD - context=context, + ctx=context, ) # Verify validation failed @@ -314,7 +314,7 @@ async def test_product_override_enforced(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=3000.0, currency="USD"), - context=context, + ctx=context, ) # Verify validation failed @@ -349,7 +349,7 @@ async def test_lower_override_allows_smaller_spend(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=750.0, currency="USD"), - context=context, + ctx=context, ) # Should succeed - verify we got a media_buy_id @@ -380,7 +380,7 @@ async def test_minimum_spend_met_success(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=2000.0, currency="USD"), - context=context, + ctx=context, ) # Should succeed - verify we got a media_buy_id @@ -414,7 +414,7 @@ async def test_unsupported_currency_rejected(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=100000.0, currency="USD"), - context=context, + ctx=context, ) # Verify the error message indicates adapter rejection @@ -447,7 +447,7 @@ async def test_different_currency_different_minimum(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=800.0, currency="USD"), # Changed to USD to match actual behavior - context=context, + ctx=context, ) # Verify validation failed with USD minimum @@ -492,7 +492,7 @@ async def test_no_minimum_when_not_set(self, setup_test_data): start_time=start_time.isoformat(), end_time=end_time.isoformat(), budget=Budget(total=100.0, currency="GBP"), - context=context, + ctx=context, ) # Should succeed - verify we got a media_buy_id diff --git a/tests/integration_v2/test_signals_agent_workflow.py b/tests/integration_v2/test_signals_agent_workflow.py index 114831354..bbdc335f2 100644 --- a/tests/integration_v2/test_signals_agent_workflow.py +++ b/tests/integration_v2/test_signals_agent_workflow.py @@ -135,7 +135,7 @@ async def test_get_products_without_signals_config(self, tenant_without_signals_ brand_manifest={"name": "BMW M3 2025 sports sedan"}, brief="sports", # Match "Database Sports Package" filters=None, - context=context, + ctx=context, ) # Should return database products only @@ -183,7 +183,7 @@ async def test_get_products_with_signals_success( brand_manifest={"name": "Porsche 911 Turbo S 2025"}, brief="automotive", # Match "Database Automotive Package" filters=None, - context=context, + ctx=context, ) # Should return both signals and database products @@ -225,7 +225,7 @@ async def test_get_products_signals_upstream_failure_fallback( brand_manifest={"name": "Test Product 2025"}, brief="sports", # Match database products filters=None, - context=context, + ctx=context, ) # Should still return database products due to fallback @@ -262,7 +262,7 @@ async def test_get_products_no_brief_optimization(self, tenant_with_signals_conf brand_manifest={"name": "Generic Product 2025"}, brief="", # Empty brief - should return all products filters=None, - context=context, + ctx=context, ) # Should return products but no signals call diff --git a/tests/unit/test_auth_requirements.py b/tests/unit/test_auth_requirements.py index 728bfdb4c..ac79aa982 100644 --- a/tests/unit/test_auth_requirements.py +++ b/tests/unit/test_auth_requirements.py @@ -51,7 +51,7 @@ def test_sync_creatives_requires_authentication(self): # Call without context (no auth) with pytest.raises(ToolError) as exc_info: - _sync_creatives_impl(creatives=creatives, context=None) + _sync_creatives_impl(creatives=creatives, ctx=None) error_msg = str(exc_info.value) assert "Authentication required" in error_msg @@ -76,7 +76,7 @@ def test_sync_creatives_with_invalid_auth(self): ] with pytest.raises(ToolError) as exc_info: - _sync_creatives_impl(creatives=creatives, context=invalid_context) + _sync_creatives_impl(creatives=creatives, ctx=invalid_context) assert "Authentication required" in str(exc_info.value) @@ -86,7 +86,7 @@ def test_list_creatives_requires_authentication(self): # Call without context (no auth) with pytest.raises(ToolError) as exc_info: - _list_creatives_impl(context=None) + _list_creatives_impl(ctx=None) error_msg = str(exc_info.value) assert "x-adcp-auth" in error_msg @@ -167,7 +167,7 @@ def test_get_media_buy_delivery_requires_authentication(self): # Call without context (no auth) with pytest.raises((ToolError, ValueError)) as exc_info: - _get_media_buy_delivery_impl(req=req, context=None) + _get_media_buy_delivery_impl(req=req, ctx=None) error_msg = str(exc_info.value) # May raise ToolError for missing auth or ValueError for missing context @@ -190,7 +190,7 @@ def test_update_performance_index_requires_authentication(self): _update_performance_index_impl( media_buy_id="test_buy", performance_data=[{"product_id": "prod1", "performance_index": 0.8}], - context=None, + ctx=None, ) error_msg = str(exc_info.value) @@ -211,9 +211,9 @@ def test_activate_signal_requires_authentication(self): from src.core.tools.signals import _activate_signal_impl - # Call without context (no auth) - function signature: signal_id, campaign_id, media_buy_id, context + # Call without context (no auth) - function signature: signal_id, campaign_id, media_buy_id, ctx with pytest.raises(ToolError) as exc_info: - asyncio.run(_activate_signal_impl(signal_id="test_signal", media_buy_id="test_buy", context=None)) + asyncio.run(_activate_signal_impl(signal_id="test_signal", media_buy_id="test_buy", ctx=None)) error_msg = str(exc_info.value) assert "authentication required" in error_msg.lower() or "principal" in error_msg.lower() @@ -227,14 +227,14 @@ def test_tool_context_with_none_principal_id(self): from src.core.tools.creatives import _sync_creatives_impl # Create ToolContext with None principal_id (invalid token scenario) - context = Mock(spec=ToolContext) - context.principal_id = None - context.tenant_id = "test_tenant" + ctx = Mock(spec=ToolContext) + ctx.principal_id = None + ctx.tenant_id = "test_tenant" creatives = [{"creative_id": "test", "name": "Test", "assets": {}}] with pytest.raises(ToolError) as exc_info: - _sync_creatives_impl(creatives=creatives, context=context) + _sync_creatives_impl(creatives=creatives, ctx=ctx) assert "Authentication required" in str(exc_info.value) @@ -243,14 +243,14 @@ def test_tool_context_with_empty_string_principal_id(self): from src.core.tools.creatives import _sync_creatives_impl # Create ToolContext with empty principal_id - context = Mock(spec=ToolContext) - context.principal_id = "" # Empty string - context.tenant_id = "test_tenant" + ctx = Mock(spec=ToolContext) + ctx.principal_id = "" # Empty string + ctx.tenant_id = "test_tenant" creatives = [{"creative_id": "test", "name": "Test", "assets": {}}] with pytest.raises(ToolError) as exc_info: - _sync_creatives_impl(creatives=creatives, context=context) + _sync_creatives_impl(creatives=creatives, ctx=ctx) assert "Authentication required" in str(exc_info.value) @@ -263,7 +263,7 @@ def test_sync_creatives_error_message_mentions_header(self): from src.core.tools.creatives import _sync_creatives_impl with pytest.raises(ToolError) as exc_info: - _sync_creatives_impl(creatives=[], context=None) + _sync_creatives_impl(creatives=[], ctx=None) error_msg = str(exc_info.value) # Should mention the header name so users know what to fix diff --git a/tests/unit/test_brand_manifest_policy.py b/tests/unit/test_brand_manifest_policy.py index ff9fe8606..11d398e5f 100644 --- a/tests/unit/test_brand_manifest_policy.py +++ b/tests/unit/test_brand_manifest_policy.py @@ -27,6 +27,7 @@ async def test_public_policy_allows_no_brand_manifest(): mock_request.brand_manifest = None mock_request.brief = "Athletic footwear" mock_request.filters = None + mock_request.context = None # Create mock context with public policy mock_context = MagicMock() @@ -89,6 +90,7 @@ async def test_require_brand_policy_rejects_no_brand_manifest(): mock_request.brand_manifest = None mock_request.brief = "Athletic footwear" mock_request.filters = None + mock_request.context = None # Create mock context with require_brand policy mock_context = MagicMock() @@ -130,6 +132,7 @@ async def test_require_brand_policy_accepts_with_brand_manifest(): mock_request.brand_manifest = mock_brand_manifest mock_request.brief = "Athletic footwear" mock_request.filters = None + mock_request.context = None # Create mock context with require_brand policy mock_context = MagicMock() @@ -194,6 +197,7 @@ async def test_require_auth_policy_rejects_no_auth(): mock_request.brand_manifest = mock_brand_manifest mock_request.brief = "Athletic footwear" mock_request.filters = None + mock_request.context = None # Create mock context with require_auth policy mock_context = MagicMock() @@ -229,6 +233,7 @@ async def test_require_auth_policy_accepts_with_auth(): mock_request.brand_manifest = None mock_request.brief = "Athletic footwear" mock_request.filters = None + mock_request.context = None # Create mock context with require_auth policy mock_context = MagicMock() diff --git a/tests/unit/test_raw_function_parameter_validation.py b/tests/unit/test_raw_function_parameter_validation.py index 0e2cc5e14..62a9373b3 100644 --- a/tests/unit/test_raw_function_parameter_validation.py +++ b/tests/unit/test_raw_function_parameter_validation.py @@ -32,7 +32,7 @@ def test_get_products_raw_parameters_valid(self): raw_sig = inspect.signature(get_products_raw) helper_sig = inspect.signature(create_get_products_request) - raw_params = set(raw_sig.parameters.keys()) - {"context"} + raw_params = set(raw_sig.parameters.keys()) - {"ctx"} helper_params = set(helper_sig.parameters.keys()) # Check: All non-context params in raw should either: @@ -57,7 +57,7 @@ def test_get_products_raw_parameters_valid(self): ), f"get_products_raw has parameters not in helper and not documented as valid: {missing_in_helper}" def test_all_raw_functions_have_context_parameter(self): - """All _raw functions should accept a context parameter.""" + """All _raw functions should accept a ctx parameter.""" from src.core import tools raw_functions = [name for name in dir(tools) if name.endswith("_raw") and callable(getattr(tools, name))] @@ -65,7 +65,7 @@ def test_all_raw_functions_have_context_parameter(self): for func_name in raw_functions: func = getattr(tools, func_name) sig = inspect.signature(func) - assert "context" in sig.parameters, f"{func_name} missing 'context' parameter" + assert "ctx" in sig.parameters, f"{func_name} missing 'ctx' parameter" def test_raw_functions_dont_drop_parameters_silently(self): """Test that raw functions don't accept parameters they don't use. @@ -98,7 +98,7 @@ def test_raw_functions_dont_drop_parameters_silently(self): for node in ast.walk(tree): if isinstance(node, ast.FunctionDef) and node.name.endswith("_raw"): func_name = node.name - params = {arg.arg for arg in node.args.args} - {"self", "context"} + params = {arg.arg for arg in node.args.args} - {"self", "ctx"} # Find all names used in function body used_names = set() @@ -130,7 +130,7 @@ def test_create_get_products_request_signature(self): # This test documents what we expect the signature to be # If this fails, it means the helper changed and we need to update callers # Note: promoted_offering removed per adcp v1.2.1 migration - expected_params = ["brief", "brand_manifest", "filters"] + expected_params = ["brief", "brand_manifest", "filters", "context"] assert params == expected_params, ( f"create_get_products_request signature changed!\n" @@ -199,7 +199,7 @@ def test_all_create_helper_signatures(self): # Verify create_get_products_request (the one that caused the bug) assert "create_get_products_request" in signatures # Note: promoted_offering removed per adcp v1.2.1 migration - expected = ["brief", "brand_manifest", "filters"] + expected = ["brief", "brand_manifest", "filters", "context"] actual = signatures["create_get_products_request"] assert actual == expected, ( f"create_get_products_request signature changed!\n" diff --git a/tests/unit/test_sync_creatives_async_fix.py b/tests/unit/test_sync_creatives_async_fix.py index e9506cd2b..02d623be2 100644 --- a/tests/unit/test_sync_creatives_async_fix.py +++ b/tests/unit/test_sync_creatives_async_fix.py @@ -105,7 +105,8 @@ async def test_creative_id_defined_in_error_path(self): # Instead, it should handle the error gracefully result = _sync_creatives_impl( creatives=[invalid_creative], - context=mock_context, + context=None, + ctx=mock_context, ) # Verify the error was captured with the correct creative_id @@ -178,7 +179,8 @@ async def mock_preview(*args, **kwargs): # This should handle the error gracefully with creative_id available result = _sync_creatives_impl( creatives=[creative], - context=mock_context, + context=None, + ctx=mock_context, ) # Verify error was captured with correct creative_id @@ -267,7 +269,8 @@ async def mock_preview(*args, **kwargs): # Before the fix, this would raise RuntimeError about asyncio.run() result = _sync_creatives_impl( creatives=[creative], - context=mock_context, + context=None, + ctx=mock_context, ) # Verify it succeeded diff --git a/uv.lock b/uv.lock index eb0ac29a7..cf6dff885 100644 --- a/uv.lock +++ b/uv.lock @@ -62,7 +62,7 @@ http-server = [ [[package]] name = "adcp" -version = "1.6.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "a2a-sdk" }, @@ -71,9 +71,9 @@ dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e5/6d/678cf21086a790cb4fc52703dfbcb63ef933bf64a7f44ad625a9a69abbd0/adcp-1.6.0.tar.gz", hash = "sha256:bbba5565825f568689cbb7c3c2807ec792ace9f4288b56817da6c4429da01ef6", size = 93287, upload-time = "2025-11-13T13:19:18.431Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/e4/07af06780e4a1daf8b4aac4d2a311d3769ce335b0784c606ab30e4ef1f90/adcp-1.6.1.tar.gz", hash = "sha256:031c18cc0450e925017772e6c4d7c4513b81e9efc238ca7ee683841765bafcf8", size = 93456, upload-time = "2025-11-13T16:24:58.71Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/21/8c1ec5e020853c19030a069365f70cc9b4d01edcbbc2033b87de251c024e/adcp-1.6.0-py3-none-any.whl", hash = "sha256:62dfda46ea62edadd20702a911dba3e32c5198b4dd6c2451c3e3187986cf2d33", size = 73821, upload-time = "2025-11-13T13:19:16.97Z" }, + { url = "https://files.pythonhosted.org/packages/30/3c/fa61864b61c8c2a379296eef2e90715deed700f77f7e04df89908e9bb8b3/adcp-1.6.1-py3-none-any.whl", hash = "sha256:5bce71c89b1455ec6d3d6b163c2cfec9e5c18e4718fddbf1bffaba6c23f8b146", size = 73882, upload-time = "2025-11-13T16:24:57.727Z" }, ] [[package]] @@ -161,7 +161,7 @@ dev = [ requires-dist = [ { name = "a2a-cli", specifier = ">=0.2.0" }, { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.10" }, - { name = "adcp", specifier = "==1.6.0" }, + { name = "adcp", specifier = "==1.6.1" }, { name = "aiohttp", specifier = ">=3.9.0" }, { name = "alembic", specifier = ">=1.13.0" }, { name = "allure-pytest", marker = "extra == 'ui-tests'", specifier = "==2.13.5" },