diff --git a/Dockerfile.fly b/Dockerfile.fly
index 7f2c3b3..6fa70ff 100644
--- a/Dockerfile.fly
+++ b/Dockerfile.fly
@@ -7,6 +7,7 @@ FROM public.ecr.aws/docker/library/python:3.12-slim
# Install dependencies
RUN apt-get update && apt-get install -y \
curl \
+ git \
&& rm -rf /var/lib/apt/lists/*
# Install uv for package management
diff --git a/pyproject.toml b/pyproject.toml
index b2bdc9a..6f61bdb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,14 +9,13 @@ dependencies = [
"fastmcp>=2.11.0",
"google-genai>=1.0.0",
"pillow>=10.0.0",
- "fastapi>=0.100.0",
"uvicorn>=0.23.0",
"httpx>=0.28.1",
"pydantic>=2.0.0",
"boto3>=1.35.0",
"markdown>=3.6",
"bleach>=6.3.0",
- "adcp>=3.2.0", # Official ADCP Python client with template format support
+ "adcp>=3.5.0",
]
[project.scripts]
@@ -32,6 +31,9 @@ packages = ["src/creative_agent"]
[tool.uv]
package = true
+[tool.uv.sources]
+adcp = { git = "https://github.com/adcontextprotocol/adcp-client-python", tag = "v3.5.0" }
+
[dependency-groups]
dev = [
"mypy>=1.18.2",
diff --git a/src/creative_agent/api_server.py b/src/creative_agent/api_server.py
deleted file mode 100644
index 11f470e..0000000
--- a/src/creative_agent/api_server.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""FastAPI HTTP server for AdCP Creative Agent (Fly.io deployment)."""
-
-import os
-import uuid
-from typing import Any
-
-from fastapi import FastAPI, HTTPException
-from fastapi.middleware.cors import CORSMiddleware
-from pydantic import AnyUrl, BaseModel
-
-from .data.standard_formats import STANDARD_FORMATS, get_format_by_id
-
-app = FastAPI(title="AdCP Creative Agent", version="1.0.0")
-
-# Enable CORS
-app.add_middleware(
- CORSMiddleware,
- allow_origins=["*"],
- allow_credentials=True,
- allow_methods=["*"],
- allow_headers=["*"],
-)
-
-
-class PreviewRequest(BaseModel):
- """Request to generate creative preview."""
-
- format_id: str
- width: int | None = None
- height: int | None = None
- assets: dict[str, Any]
-
-
-@app.get("/")
-async def root() -> dict[str, Any]:
- """Root endpoint."""
- return {
- "name": "AdCP Creative Agent",
- "version": "1.0.0",
- "endpoints": {
- "formats": "/formats",
- "preview": "/preview (POST)",
- },
- }
-
-
-@app.get("/health")
-async def health() -> dict[str, str]:
- """Health check endpoint."""
- return {"status": "ok"}
-
-
-@app.get("/formats")
-async def list_formats() -> list[dict[str, Any]]:
- """List all available creative formats."""
- return [fmt.model_dump(mode="json", exclude_none=True) for fmt in STANDARD_FORMATS]
-
-
-@app.get("/formats/{format_id}")
-async def get_format(format_id: str) -> dict[str, Any]:
- """Get a specific format by ID (assumes this agent's formats)."""
-
- from adcp import FormatId
-
- from .data.standard_formats import AGENT_URL
-
- # Convert string ID to FormatId object (assume our agent)
- fmt_id = FormatId(agent_url=AnyUrl(AGENT_URL), id=format_id)
- fmt = get_format_by_id(fmt_id)
- if not fmt:
- raise HTTPException(status_code=404, detail=f"Format {format_id} not found")
- result: dict[str, Any] = fmt.model_dump(mode="json", exclude_none=True)
- return result
-
-
-@app.post("/preview")
-async def preview_creative(request: PreviewRequest) -> dict[str, Any]:
- """Generate preview from creative manifest."""
-
- from adcp import FormatId
-
- from .data.standard_formats import AGENT_URL
-
- # Convert string ID to FormatId object (assume our agent)
- fmt_id = FormatId(agent_url=AnyUrl(AGENT_URL), id=request.format_id)
- fmt = get_format_by_id(fmt_id)
- if not fmt:
- raise HTTPException(status_code=404, detail=f"Format {request.format_id} not found")
-
- # Generate preview ID
- preview_id = str(uuid.uuid4())
-
- # Build iframe HTML based on format type
- type_value = fmt.type.value if hasattr(fmt.type, "value") else fmt.type
-
- if type_value == "display":
- # Get dimensions from request or format renders
- width = request.width
- height = request.height
-
- if not width or not height:
- # Get dimensions from format renders
- if fmt.renders and len(fmt.renders) > 0:
- render = fmt.renders[0]
- if render.dimensions and render.dimensions.width and render.dimensions.height:
- width = width or int(render.dimensions.width)
- height = height or int(render.dimensions.height)
-
- # If still missing dimensions, return error
- if not width or not height:
- raise HTTPException(
- status_code=400,
- detail=f"Format {fmt_id.id} has no fixed dimensions and dimensions were not provided in request",
- )
-
- image_url = request.assets.get("image", "")
- click_url = request.assets.get("click_url", "#")
-
- iframe_html = f"""
-
-""".strip()
-
- elif type_value == "video":
- width = request.width or 640
- height = request.height or 360
- video_url = request.assets.get("video", "")
-
- iframe_html = f"""
-
-""".strip()
-
- else:
- # Generic HTML preview
- iframe_html = f"
Preview for {fmt.name} (format_id: {request.format_id})
"
-
- return {
- "preview_id": preview_id,
- "format_id": request.format_id,
- "preview_url": f"https://creative.adcontextprotocol.org/previews/{preview_id}",
- "iframe_html": iframe_html,
- "manifest": request.model_dump(),
- }
-
-
-if __name__ == "__main__":
- import uvicorn
-
- port = int(os.getenv("PORT", "8080"))
- uvicorn.run(app, host="0.0.0.0", port=port)
diff --git a/src/creative_agent/compat.py b/src/creative_agent/compat.py
deleted file mode 100644
index b60c9c3..0000000
--- a/src/creative_agent/compat.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""DEPRECATED: Compatibility layer for adcp library types.
-
-## Deprecation Notice
-
-As of adcp 2.2.0, semantic type aliases are provided directly by the library.
-This module is no longer needed and will be removed in a future version.
-
-**Instead of:**
-```python
-from creative_agent.compat import UrlPreviewRender
-```
-
-**Use:**
-```python
-from adcp.types import UrlPreviewRender
-```
-
-## Migration
-
-All imports from this module should be updated to import directly from `adcp.types`:
-- `UrlPreviewRender`, `HtmlPreviewRender`, `BothPreviewRender`
-- `UrlVastAsset`, `InlineVastAsset`
-- `UrlDaastAsset`, `InlineDaastAsset`
-- `MediaSubAsset`, `TextSubAsset`
-
-This module now re-exports from the library for backward compatibility.
-"""
-
-import warnings
-
-# Re-export from library for backward compatibility
-from adcp.types import (
- BothPreviewRender,
- HtmlPreviewRender,
- InlineDaastAsset,
- InlineVastAsset,
- MediaSubAsset,
- TextSubAsset,
- UrlDaastAsset,
- UrlPreviewRender,
- UrlVastAsset,
-)
-
-# Emit deprecation warning on import
-warnings.warn(
- "creative_agent.compat is deprecated. Import type aliases directly from adcp.types instead. "
- "This module will be removed in a future version.",
- DeprecationWarning,
- stacklevel=2,
-)
-
-__all__ = [
- # DAAST assets
- "BothPreviewRender",
- "HtmlPreviewRender",
- "InlineDaastAsset",
- "InlineVastAsset",
- # SubAssets
- "MediaSubAsset",
- "TextSubAsset",
- # VAST assets
- "UrlDaastAsset",
- "UrlPreviewRender",
- "UrlVastAsset",
-]
diff --git a/src/creative_agent/data/format_types.py b/src/creative_agent/data/format_types.py
index 039d001..f979c7a 100644
--- a/src/creative_agent/data/format_types.py
+++ b/src/creative_agent/data/format_types.py
@@ -41,7 +41,6 @@ class AssetType(Enum):
javascript = "javascript"
url = "url"
webhook = "webhook"
- promoted_offerings = "promoted_offerings"
class Unit(Enum):
diff --git a/src/creative_agent/data/standard_formats.py b/src/creative_agent/data/standard_formats.py
index 808af02..118c403 100644
--- a/src/creative_agent/data/standard_formats.py
+++ b/src/creative_agent/data/standard_formats.py
@@ -3,10 +3,11 @@
# mypy: disable-error-code="call-arg"
# Pydantic models with extra='forbid' trigger false positives when optional fields aren't passed
+import typing
from typing import Any
-from adcp import FormatCategory, FormatId, get_required_assets
-from adcp.types.generated_poc.core.format import Assets as LibAssets
+from adcp import CatalogRequirements, CatalogType, FormatCategory, FormatId, get_required_assets
+from adcp.types.generated_poc.core.format import Format as _LibFormat
from adcp.types.generated_poc.core.format import Renders as LibRender
from adcp.types.generated_poc.enums.format_id_parameter import FormatIdParameter
from pydantic import AnyUrl
@@ -16,13 +17,50 @@
AssetType,
)
+# Build asset type -> class mappings dynamically from the Format model's discriminated union.
+# This avoids hardcoding numbered class names (Assets, Assets5, etc.) which change between
+# adcp library versions.
+_list_type = typing.get_args(typing.get_args(_LibFormat.model_fields["assets"].annotation)[0])
+_union_members = typing.get_args(_list_type[0]) if _list_type else ()
+
+_INDIVIDUAL_ASSET_MAP: dict[str, type] = {}
+_REPEATABLE_GROUP_CLASS: type | None = None
+_INNER_ASSET_MAP: dict[str, type] = {}
+
+for _cls in _union_members:
+ if not hasattr(_cls, "model_fields"):
+ continue
+ if "asset_group_id" in _cls.model_fields:
+ _REPEATABLE_GROUP_CLASS = _cls
+ # Build inner asset mapping from the group's assets field
+ _inner_ann = _cls.model_fields["assets"].annotation
+ _inner_args = typing.get_args(_inner_ann)
+ _inner_union = typing.get_args(_inner_args[0]) if _inner_args else ()
+ for _inner_cls in _inner_union:
+ if hasattr(_inner_cls, "model_fields") and "asset_type" in _inner_cls.model_fields:
+ _lit = typing.get_args(_inner_cls.model_fields["asset_type"].annotation)
+ if _lit:
+ _INNER_ASSET_MAP[_lit[0]] = _inner_cls
+ continue
+ at_field = _cls.model_fields.get("asset_type")
+ if at_field:
+ _lit = typing.get_args(at_field.annotation)
+ if _lit:
+ _INDIVIDUAL_ASSET_MAP[_lit[0]] = _cls
+
+# Fail fast if the introspection didn't find expected types
+if not _INDIVIDUAL_ASSET_MAP:
+ raise ImportError("No individual asset classes found in adcp Format schema")
+if _REPEATABLE_GROUP_CLASS is None:
+ raise ImportError("No repeatable group class found in adcp Format schema")
+
# Agent configuration
AGENT_URL = "https://creative.adcontextprotocol.org"
AGENT_NAME = "AdCP Standard Creative Agent"
AGENT_CAPABILITIES = ["validation", "assembly", "generation", "preview"]
# Common macros supported across all formats
-COMMON_MACROS = [
+COMMON_MACROS: list[str | Any] = [
"MEDIA_BUY_ID",
"CREATIVE_ID",
"CACHEBUSTER",
@@ -44,28 +82,67 @@ def create_asset(
asset_id: str,
asset_type: AssetType,
required: bool = True,
- requirements: dict[str, str | int | float | bool | list[str]] | None = None,
-) -> LibAssets:
- """Create an asset entry using the library's Assets Pydantic model.
+ requirements: Any = None,
+) -> Any:
+ """Create an individual asset using the correct typed model for the given asset_type.
- This creates assets for the new 'assets' field (adcp-client-python 2.18.0+).
- The library model automatically handles exclude_none serialization and
- includes the item_type discriminator for union types.
+ The adcp library uses a discriminated union for assets — each asset_type has its own
+ Pydantic model with type-specific requirements. This function resolves the correct
+ class dynamically from the Format schema.
"""
- from adcp import AssetContentType as LibAssetType
-
- lib_asset_type = LibAssetType(asset_type.value)
+ cls = _INDIVIDUAL_ASSET_MAP.get(asset_type.value)
+ if cls is None:
+ raise ValueError(f"Unknown asset type: {asset_type.value}")
- return LibAssets(
+ return cls(
asset_id=asset_id,
- asset_type=lib_asset_type,
+ asset_type=asset_type.value,
required=required,
requirements=requirements,
- item_type="individual", # Required discriminator for union types
+ item_type="individual",
+ )
+
+
+def create_repeatable_group(
+ asset_group_id: str,
+ asset_type: AssetType,
+ required: bool,
+ min_count: int,
+ max_count: int,
+ requirements: Any = None,
+) -> Any:
+ """Create a repeatable group asset that allows multiple values of the same type.
+
+ Used for asset pools like headlines (up to 15) or images (up to 20), where
+ publishers pick the best combination for each placement.
+
+ Each group instance contains a single inner asset whose asset_id matches the
+ content type string (e.g. "text", "image", "video").
+ """
+ if _REPEATABLE_GROUP_CLASS is None:
+ raise RuntimeError("Repeatable group class not found in adcp format schema")
+
+ inner_cls = _INNER_ASSET_MAP.get(asset_type.value)
+ if inner_cls is None:
+ raise ValueError(f"Unknown inner asset type: {asset_type.value}")
+
+ inner = inner_cls(
+ asset_id=asset_type.value,
+ asset_type=asset_type.value,
+ required=True,
+ requirements=requirements,
+ )
+ return _REPEATABLE_GROUP_CLASS(
+ asset_group_id=asset_group_id,
+ assets=[inner],
+ item_type="repeatable_group",
+ min_count=min_count,
+ max_count=max_count,
+ required=required,
)
-def create_impression_tracker_asset() -> LibAssets:
+def create_impression_tracker_asset() -> Any:
"""Create an optional impression tracker asset for 3rd party tracking.
This creates a URL asset with url_type='tracker_pixel' that can be used
@@ -82,7 +159,7 @@ def create_impression_tracker_asset() -> LibAssets:
)
-def create_click_url_asset() -> LibAssets:
+def create_click_url_asset() -> Any:
"""Create a required clickthrough URL asset.
This creates a URL asset with url_type='clickthrough' for the landing page
@@ -146,9 +223,9 @@ def create_responsive_render(
# Generative Formats - AI-powered creative generation
-# These use promoted_offerings asset type which provides brand context and product info
-# Template format that accepts dimension parameters (for new integrations)
-# Plus concrete formats (for backward compatibility)
+# Generative formats use catalog_requirements to declare what offering data they need.
+# The AI generates creative from the catalog context + a generation prompt.
+# Template format accepts dimension parameters; concrete formats are for backward compatibility.
GENERATIVE_FORMATS = [
# Template format - supports any dimensions
CreativeFormat(
@@ -158,13 +235,13 @@ def create_responsive_render(
description="AI-generated display banner from brand context and prompt (supports any dimensions)",
accepts_parameters=[FormatIdParameter.dimensions],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -183,13 +260,13 @@ def create_responsive_render(
renders=[create_fixed_render(300, 250)],
output_format_ids=[create_format_id("display_300x250_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -207,13 +284,13 @@ def create_responsive_render(
renders=[create_fixed_render(728, 90)],
output_format_ids=[create_format_id("display_728x90_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -231,13 +308,13 @@ def create_responsive_render(
renders=[create_fixed_render(320, 50)],
output_format_ids=[create_format_id("display_320x50_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -255,13 +332,13 @@ def create_responsive_render(
renders=[create_fixed_render(160, 600)],
output_format_ids=[create_format_id("display_160x600_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -279,13 +356,13 @@ def create_responsive_render(
renders=[create_fixed_render(336, 280)],
output_format_ids=[create_format_id("display_336x280_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -303,13 +380,13 @@ def create_responsive_render(
renders=[create_fixed_render(300, 600)],
output_format_ids=[create_format_id("display_300x600_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -327,13 +404,13 @@ def create_responsive_render(
renders=[create_fixed_render(970, 250)],
output_format_ids=[create_format_id("display_970x250_image")],
supported_macros=COMMON_MACROS,
- assets=[
- create_asset(
- asset_id="promoted_offerings",
- asset_type=AssetType.promoted_offerings,
- required=True,
- requirements={"description": "Brand manifest and product offerings for AI generation"},
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.offering,
+ required_fields=["name"],
),
+ ],
+ assets=[
create_asset(
asset_id="generation_prompt",
asset_type=AssetType.text,
@@ -1489,7 +1566,7 @@ def matches_format_id(fmt: CreativeFormat, search_id: tuple[str, str, int | None
# fmt.type is always a Type enum (adcp 2.1.0+)
if isinstance(type, str):
# Compare enum value to string
- results = [fmt for fmt in results if fmt.type.value == type]
+ results = [fmt for fmt in results if fmt.type is not None and fmt.type.value == type]
else:
# Compare enum to enum
results = [fmt for fmt in results if fmt.type == type]
@@ -1589,18 +1666,17 @@ def has_asset_type(req: Any, target_type: AssetType | str) -> bool:
# Compare string values
target_str = target_type.value if isinstance(target_type, AssetType) else target_type
- # assets_required are always Pydantic models (adcp 2.2.0+)
- req_asset_type = req.asset_type
- # Handle enum type
- if hasattr(req_asset_type, "value"):
- req_asset_type = req_asset_type.value
- if req_asset_type == target_str:
- return True
- # Check if it's a grouped asset requirement with assets array
+ # Individual assets have asset_type directly; repeatable groups do not
+ req_asset_type = getattr(req, "asset_type", None)
+ if req_asset_type is not None:
+ if hasattr(req_asset_type, "value"):
+ req_asset_type = req_asset_type.value
+ if req_asset_type == target_str:
+ return True
+ # Repeatable groups carry inner assets — check those
if hasattr(req, "assets"):
for asset in req.assets:
asset_type: Any = getattr(asset, "asset_type", None)
- # Handle enum type
if asset_type is not None and hasattr(asset_type, "value"):
asset_type = asset_type.value
if asset_type == target_str:
diff --git a/src/creative_agent/http_server.py b/src/creative_agent/http_server.py
deleted file mode 100644
index 4222f08..0000000
--- a/src/creative_agent/http_server.py
+++ /dev/null
@@ -1,12 +0,0 @@
-"""HTTP server for AdCP Creative Agent (for Fly.io deployment)."""
-
-import os
-
-from .server import mcp
-
-# Get port from environment (Fly.io sets this)
-PORT = int(os.getenv("PORT", "8080"))
-
-if __name__ == "__main__":
- # Run in HTTP mode for production deployment
- mcp.run(transport="sse", port=PORT, host="0.0.0.0")
diff --git a/src/creative_agent/schemas/__init__.py b/src/creative_agent/schemas/__init__.py
index ba0e37f..7c30a20 100644
--- a/src/creative_agent/schemas/__init__.py
+++ b/src/creative_agent/schemas/__init__.py
@@ -12,20 +12,7 @@
from adcp import CreativeManifest, ListCreativeFormatsResponse
from adcp import Format as CreativeFormat
-# Build schemas (agent-specific, not part of AdCP)
-from .build import (
- AssetReference,
- BuildCreativeRequest,
- BuildCreativeResponse,
- CreativeOutput,
- PreviewContext,
- PreviewOptions,
-)
-
-# Format helpers (agent-specific, not part of AdCP)
-from .format_helpers import AssetRequirement, FormatRequirements
-
-# Manifest/Preview schemas - these need manual definitions as they're agent-specific
+# Manifest/Preview schemas - agent-specific definitions
from .manifest import (
PreviewCreativeRequest,
PreviewCreativeResponse,
@@ -36,21 +23,13 @@
)
__all__ = [
- "AssetReference",
- "AssetRequirement",
- "BuildCreativeRequest",
- "BuildCreativeResponse",
"CreativeFormat",
"CreativeManifest",
- "CreativeOutput",
- "FormatRequirements",
"ListCreativeFormatsResponse",
- "PreviewContext",
"PreviewCreativeRequest",
"PreviewCreativeResponse",
"PreviewEmbedding",
"PreviewHints",
"PreviewInput",
- "PreviewOptions",
"PreviewVariant",
]
diff --git a/src/creative_agent/schemas/brand_card.py b/src/creative_agent/schemas/brand_card.py
deleted file mode 100644
index bbe4e06..0000000
--- a/src/creative_agent/schemas/brand_card.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""Brand card schema for AdCP creative agent."""
-
-from pydantic import BaseModel, HttpUrl
-
-
-class BrandAsset(BaseModel):
- """Brand asset with URL and metadata."""
-
- asset_id: str
- asset_type: str # "image", "video", "logo", etc.
- url: HttpUrl
- tags: list[str] = []
- width: int | None = None
- height: int | None = None
- description: str | None = None
-
-
-class BrandCard(BaseModel):
- """Brand card containing brand identity and assets."""
-
- brand_url: HttpUrl
- brand_name: str | None = None
- brand_description: str | None = None
- assets: list[BrandAsset] = []
- product_catalog_url: HttpUrl | None = None
- primary_color: str | None = None
- secondary_color: str | None = None
diff --git a/src/creative_agent/schemas/build.py b/src/creative_agent/schemas/build.py
deleted file mode 100644
index f7464c5..0000000
--- a/src/creative_agent/schemas/build.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""Build creative schemas for AdCP."""
-
-from typing import Any, Literal
-
-from pydantic import BaseModel, HttpUrl
-
-
-class AssetReference(BaseModel):
- """Reference to an asset library."""
-
- library_id: str
- asset_ids: list[str] | None = None
- tags: list[str] | None = None
- filters: dict[str, Any] | None = None
-
-
-class PreviewContext(BaseModel):
- """Context for preview generation."""
-
- name: str
- user_data: dict[str, Any]
-
-
-class PreviewOptions(BaseModel):
- """Preview generation options."""
-
- contexts: list[PreviewContext] | None = None
- template_id: str | None = None
-
-
-class BuildCreativeRequest(BaseModel):
- """Request to build a creative (AdCP spec)."""
-
- message: str # Creative brief or refinement instructions
- target_format_id: str
- format_source: HttpUrl | None = None
- output_mode: Literal["manifest", "code"] = "manifest"
- context_id: str | None = None # For iterative refinement
- assets: list[AssetReference] = []
- preview_options: PreviewOptions | None = None
- finalize: bool = False
-
-
-class CreativeOutput(BaseModel):
- """Creative output (manifest or code)."""
-
- type: Literal["creative_manifest", "creative_code"]
- format_id: str
- output_format_ids: list[str] | None = None # For generative formats: the output format IDs produced
- data: dict[str, Any] # Manifest structure or code
-
-
-class BuildCreativeResponse(BaseModel):
- """Response from build_creative (AdCP spec)."""
-
- message: str # Agent's description
- context_id: str
- status: Literal["draft", "ready", "finalized"]
- creative_output: CreativeOutput
- preview: HttpUrl | None = None
- refinement_suggestions: list[str] = []
diff --git a/src/creative_agent/schemas/format_helpers.py b/src/creative_agent/schemas/format_helpers.py
deleted file mode 100644
index 7de384c..0000000
--- a/src/creative_agent/schemas/format_helpers.py
+++ /dev/null
@@ -1,28 +0,0 @@
-"""Helper classes for defining creative formats (not part of AdCP spec)."""
-
-from pydantic import BaseModel
-
-
-class AssetRequirement(BaseModel):
- """Requirement for a single asset in a format."""
-
- asset_role: str # e.g., "hero_image", "headline"
- asset_type: str # image, video, audio, text, etc.
- required: bool = True
- width: int | None = None
- height: int | None = None
- duration_seconds: int | None = None
- max_file_size_mb: float | None = None
- acceptable_formats: list[str] | None = None
- description: str | None = None
-
-
-class FormatRequirements(BaseModel):
- """Technical requirements for a format."""
-
- duration_seconds: int | None = None
- max_file_size_mb: float | None = None
- acceptable_formats: list[str] | None = None
- aspect_ratios: list[str] | None = None
- min_bitrate_kbps: int | None = None
- max_bitrate_kbps: int | None = None
diff --git a/src/creative_agent/schemas/library.py b/src/creative_agent/schemas/library.py
deleted file mode 100644
index bd3b5f2..0000000
--- a/src/creative_agent/schemas/library.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""Manage creative library schemas for AdCP."""
-
-from typing import Any, Literal
-
-from pydantic import BaseModel, HttpUrl
-
-
-class AssetMetadata(BaseModel):
- """Asset metadata."""
-
- width: int | None = None
- height: int | None = None
- duration: float | None = None # seconds
- file_size: int | None = None # bytes
- mime_type: str | None = None
- alt_text: str | None = None
-
-
-class Asset(BaseModel):
- """Creative asset."""
-
- asset_id: str | None = None # Auto-generated if not provided
- name: str
- type: Literal["image", "video", "audio", "text", "logo"]
- url: HttpUrl | None = None
- content: str | None = None # For text assets
- metadata: AssetMetadata | None = None
- tags: list[str] = []
- usage_rights: Literal["unlimited", "limited", "exclusive"] | None = None
- expires_at: str | None = None # ISO date
-
-
-class SearchFilters(BaseModel):
- """Filters for asset search."""
-
- type: str | None = None
- tags: list[str] = []
- usage_rights: str | None = None
-
-
-class ManageCreativeLibraryRequest(BaseModel):
- """Request to manage creative library (AdCP spec)."""
-
- action: Literal["add", "update", "remove", "list", "search"]
- library_id: str
- asset: Asset | None = None # Required for add/update
- assets: list[Asset] | None = None # For bulk add
- asset_id: str | None = None # Required for update/remove
- tags: list[str] = [] # For filtering/updating
- search_query: str | None = None
- filters: SearchFilters | None = None
-
-
-class ManageCreativeLibraryResponse(BaseModel):
- """Response from manage_creative_library (AdCP spec)."""
-
- message: str
- success: bool
- result: Any # Varies by action: asset, list of assets, etc.
diff --git a/src/creative_agent/schemas/manifest.py b/src/creative_agent/schemas/manifest.py
index 8e8c9e8..65743a2 100644
--- a/src/creative_agent/schemas/manifest.py
+++ b/src/creative_agent/schemas/manifest.py
@@ -21,7 +21,7 @@ class PreviewCreativeRequest(BaseModel):
"""Request for preview_creative task."""
format_id: FormatId
- creative_manifest: dict[str, Any] # AdCP CreativeAsset structure (including promoted_offerings if required)
+ creative_manifest: dict[str, Any] # AdCP CreativeAsset structure (assets, catalogs, etc.)
inputs: list[PreviewInput] | None = None
template_id: str | None = None
diff --git a/src/creative_agent/schemas/preview.py b/src/creative_agent/schemas/preview.py
deleted file mode 100644
index 556107a..0000000
--- a/src/creative_agent/schemas/preview.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""Preview creative schemas for AdCP."""
-
-from pydantic import BaseModel, HttpUrl
-
-from .brand_card import BrandCard
-
-
-class PreviewVariant(BaseModel):
- """Request for a specific preview variant."""
-
- variant_id: str
- device_type: str | None = None # "desktop", "mobile", "tablet"
- country: str | None = None
- region: str | None = None
- context_description: str | None = None
- macro_values: dict[str, str] = {}
-
-
-class PreviewCreativeRequest(BaseModel):
- """Request to preview a creative with variants."""
-
- format_id: str
- brand_card: BrandCard
- promoted_products: list[str] = [] # SKUs or product IDs
- creative_html: str | None = None # If providing existing creative
- creative_url: HttpUrl | None = None # If creative is hosted
- variants: list[PreviewVariant] = []
- ai_api_key: str | None = None # For AI-generated previews
-
-
-class PreviewResponse(BaseModel):
- """Single preview response."""
-
- variant_id: str
- preview_url: HttpUrl
- preview_html: str | None = None
- screenshot_url: HttpUrl | None = None
- macros_applied: dict[str, str] = {}
-
-
-class PreviewCreativeResponse(BaseModel):
- """Response with multiple preview variants."""
-
- format_id: str
- previews: list[PreviewResponse]
diff --git a/src/creative_agent/server.py b/src/creative_agent/server.py
index 7921541..fac2285 100644
--- a/src/creative_agent/server.py
+++ b/src/creative_agent/server.py
@@ -1,7 +1,9 @@
"""AdCP Creative Agent MCP Server - Spec Compliant Implementation."""
import json
+import logging
import os
+import re
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -27,6 +29,8 @@
PreviewCreativeRequest,
)
+logger = logging.getLogger(__name__)
+
mcp = FastMCP("adcp-creative-agent")
@@ -77,10 +81,13 @@ def _format_to_human_readable(fmt: Any) -> str:
macros = fmt.supported_macros or []
macro_count_str = f"{len(macros)} supported macros" if macros else "no macros"
- # Extract assets info using adcp 2.18.0 utilities
- # Filter to individual assets (Assets) which have asset_id, skip repeatable groups (Assets1)
- required_assets = [a.asset_id for a in get_required_assets(fmt) if hasattr(a, "asset_id")]
- optional_assets = [a.asset_id for a in get_optional_assets(fmt) if hasattr(a, "asset_id")]
+ # Extract assets info using adcp utilities — handles both individual (asset_id) and repeatable groups (asset_group_id)
+ required_assets: list[str] = [
+ x for a in get_required_assets(fmt) if (x := getattr(a, "asset_id", None) or getattr(a, "asset_group_id", None))
+ ]
+ optional_assets: list[str] = [
+ x for a in get_optional_assets(fmt) if (x := getattr(a, "asset_id", None) or getattr(a, "asset_group_id", None))
+ ]
asset_req_str = ", ".join(required_assets[:5])
if len(required_assets) > 5:
@@ -188,9 +195,12 @@ def list_creative_formats(
response_json = response.model_dump(mode="json", exclude_none=True)
# Add assets_required for backward compatibility with 2.5.x clients
+ # Only include individual assets (asset_id present); repeatable groups are not understood by old clients
for fmt_json in response_json.get("formats", []):
if fmt_json.get("assets"):
- fmt_json["assets_required"] = [asset for asset in fmt_json["assets"] if asset.get("required", False)]
+ fmt_json["assets_required"] = [
+ asset for asset in fmt_json["assets"] if asset.get("required", False) and "asset_id" in asset
+ ]
if formats:
format_details = [_format_to_human_readable(fmt) for fmt in formats]
@@ -209,9 +219,8 @@ def list_creative_formats(
structured_content=error_response,
)
except Exception as e:
- import traceback
-
- error_response = {"error": f"Server error: {e}", "traceback": traceback.format_exc()[-500:]}
+ logger.exception("Server error in list_creative_formats")
+ error_response = {"error": f"Server error: {e}"}
return ToolResult(
content=[TextContent(type="text", text=f"Error: Server error - {e}")],
structured_content=error_response,
@@ -244,6 +253,16 @@ def preview_creative(
Returns:
ToolResult with human-readable message and structured preview data
"""
+ valid_output_formats = {"url", "html"}
+ if output_format not in valid_output_formats:
+ error_msg = (
+ f"Invalid output_format '{output_format}'. Must be one of: {', '.join(sorted(valid_output_formats))}"
+ )
+ return ToolResult(
+ content=[TextContent(type="text", text=f"Error: {error_msg}")],
+ structured_content={"error": error_msg},
+ )
+
try:
# Determine mode: batch or single
is_batch_mode = requests is not None
@@ -272,12 +291,11 @@ def preview_creative(
structured_content={"error": error_msg},
)
except Exception as e:
- import traceback
-
+ logger.exception("Preview generation failed")
error_msg = f"Preview generation failed: {e}"
return ToolResult(
content=[TextContent(type="text", text=f"Error: {error_msg}")],
- structured_content={"error": error_msg, "traceback": traceback.format_exc()[-500:]},
+ structured_content={"error": error_msg},
)
@@ -393,17 +411,7 @@ def _handle_single_preview(
# Calculate expiration
expires_at = datetime.now(UTC) + timedelta(hours=24)
- from pydantic import ValidationError
-
- # Prepare response - validation happens when creating PreviewCreativeResponse
- try:
- interactive_url = f"{AGENT_URL}/preview/{preview_id}/interactive"
- except ValidationError as e:
- error_msg = f"Invalid URL construction: {e}"
- return ToolResult(
- content=[TextContent(type="text", text=f"Error: {error_msg}")],
- structured_content={"error": error_msg},
- )
+ interactive_url = f"{AGENT_URL}/preview/{preview_id}/interactive"
# Build response dict for single mode
response_dict = {
@@ -430,7 +438,7 @@ def _handle_batch_preview(
) -> ToolResult:
"""Handle batch preview requests."""
- if not requests or len(requests) == 0:
+ if not requests:
error_msg = "Batch mode requires at least one request"
return ToolResult(
content=[TextContent(type="text", text=f"Error: {error_msg}")],
@@ -567,7 +575,7 @@ def _generate_preview_variant(
embedding = {
"recommended_sandbox": "allow-scripts allow-same-origin",
"requires_https": False,
- "supports_fullscreen": format_obj.type in ["video", "rich_media"],
+ "supports_fullscreen": format_obj.type is not None and format_obj.type.value in ("video", "rich_media"),
}
# Create the single render (all formats render as HTML pages)
@@ -618,13 +626,15 @@ def build_creative(
target_format_id: str | dict[str, Any],
creative_manifest: dict[str, Any] | None = None,
message: str | None = None,
+ brand: str | dict[str, Any] | None = None,
) -> ToolResult:
"""Transform or generate a creative manifest using AI.
Args:
target_format_id: Format ID to generate (string or FormatId object with agent_url and id)
- creative_manifest: Source creative manifest with input assets (e.g., promoted_offerings for generative formats)
+ creative_manifest: Source creative manifest with input assets and catalogs for generative formats
message: Natural language instructions for transformation or generation
+ brand: Brand reference — domain string (e.g. "acme.com") or {"domain": "acme.com", "brand_id": "..."}
Returns:
ToolResult with creative_manifest in structured_content
@@ -688,8 +698,6 @@ def build_creative(
# Extract input assets from manifest
input_assets = creative_manifest.get("assets", {})
- # Extract promoted_offerings if present
- promoted_offerings = input_assets.get("promoted_offerings")
generation_prompt_asset = input_assets.get("generation_prompt")
# Build generation prompt
@@ -710,7 +718,7 @@ def build_creative(
# Build prompt
format_spec = f"""Format: {output_fmt.name}
-Type: {output_fmt.type.value}
+Type: {output_fmt.type.value if output_fmt.type else "unknown"}
Description: {output_fmt.description}
"""
@@ -727,17 +735,27 @@ def build_creative(
if asset_id:
format_spec += f"- {asset_id} ({asset_type})\n"
- # Add brand context if provided
+ # Build context from brand reference and catalog
brand_context = ""
- if promoted_offerings:
- brand_context = "\n\nBrand Context:\n"
- brand_manifest = promoted_offerings.get("brand_manifest", {})
- if "name" in brand_manifest:
- brand_context += f"Brand: {brand_manifest['name']}\n"
- if "description" in brand_manifest:
- brand_context += f"Description: {brand_manifest['description']}\n"
- if "tagline" in brand_manifest:
- brand_context += f"Tagline: {brand_manifest['tagline']}\n"
+ if brand:
+ brand_domain = brand if isinstance(brand, str) else brand.get("domain", "")
+ if brand_domain:
+ brand_context += f"\n\nBrand: {brand_domain}\n"
+
+ catalogs = creative_manifest.get("catalogs", []) if creative_manifest else []
+ if catalogs and isinstance(catalogs, list):
+ catalog = catalogs[0]
+ if isinstance(catalog, dict):
+ items = catalog.get("items", [])
+ if items and isinstance(items, list):
+ brand_context += "\n\nCatalog Context:\n" if not brand_context else "\nCatalog Items:\n"
+ for item in items[:3]:
+ if isinstance(item, dict):
+ if "name" in item:
+ brand_context += f"- {item['name']}"
+ if "description" in item:
+ brand_context += f": {item['description']}"
+ brand_context += "\n"
prompt = f"""You are a creative generation AI for advertising. Generate a creative manifest for the following request:
@@ -770,8 +788,6 @@ def build_creative(
generated_text += part.text
# Extract JSON from response
- import re
-
json_match = re.search(r"```json\s*(.*?)\s*```", generated_text, re.DOTALL)
if json_match:
manifest_json = json_match.group(1)
@@ -823,15 +839,11 @@ def build_creative(
structured_content={"error": error_msg},
)
except Exception as e:
- import traceback
-
+ logger.exception("Creative generation failed")
error_msg = f"Creative generation failed: {e}"
return ToolResult(
content=[TextContent(type="text", text=f"Error: {error_msg}")],
- structured_content={
- "error": error_msg,
- "traceback": traceback.format_exc()[-500:],
- },
+ structured_content={"error": error_msg},
)
@@ -883,9 +895,8 @@ def get_adcp_capabilities(
structured_content=error_response,
)
except Exception as e:
- import traceback
-
- error_response = {"error": f"Server error: {e}", "traceback": traceback.format_exc()[-500:]}
+ logger.exception("Server error in get_adcp_capabilities")
+ error_response = {"error": f"Server error: {e}"}
return ToolResult(
content=[TextContent(type="text", text=f"Error: Server error - {e}")],
structured_content=error_response,
diff --git a/src/creative_agent/validation.py b/src/creative_agent/validation.py
index a12405b..9a0dd79 100644
--- a/src/creative_agent/validation.py
+++ b/src/creative_agent/validation.py
@@ -112,15 +112,13 @@ def validate_url(url: str) -> None:
try:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
- # Allow data URIs for images
- if url_lower.startswith("data:image/"):
- validate_data_uri(url)
- return
raise AssetValidationError("URL must have scheme and host")
if parsed.scheme not in ["http", "https"]:
raise AssetValidationError(f"URL scheme must be http or https, got: {parsed.scheme}")
+ except AssetValidationError:
+ raise
except Exception as e:
raise AssetValidationError(f"Invalid URL format: {e}") from e
@@ -150,9 +148,9 @@ def validate_data_uri(uri: str) -> None:
if not any(mime_part == mime for mime in allowed_image_mimes):
raise AssetValidationError(f"Data URI MIME type not allowed: {mime_part}")
- # Check size (limit to 10MB for data URIs)
+ # Check size (limit to ~10MB of base64-encoded data)
if len(data) > 10 * 1024 * 1024:
- raise AssetValidationError("Data URI exceeds 10MB size limit")
+ raise AssetValidationError("Data URI exceeds size limit")
def validate_image_url(url: str, check_mime: bool = False) -> None:
@@ -316,45 +314,173 @@ def validate_asset(asset_data: dict[str, Any], asset_type: str, check_remote_mim
raise AssetValidationError("Webhook url must be a string")
validate_url(url)
- elif asset_type == "promoted_offerings":
- # Promoted offerings validation - used for generative creative formats
- # Contains brand manifest and product selectors
- # Per spec: can be inline object OR URL reference
-
- # Check if there's a brand_manifest field (can be URL or object)
- brand_manifest = asset_data.get("brand_manifest")
- if brand_manifest:
- if isinstance(brand_manifest, str):
- # URL reference to hosted manifest
- validate_url(brand_manifest)
- elif isinstance(brand_manifest, dict):
- # Inline brand manifest - must have url OR name
- url = brand_manifest.get("url")
- name = brand_manifest.get("name")
- if not url and not name:
- raise AssetValidationError("Inline brand manifest must have either url or name")
- if url:
- if not isinstance(url, str):
- raise AssetValidationError("Brand manifest url must be a string")
- validate_url(url)
- if name and not isinstance(name, str):
- raise AssetValidationError("Brand manifest name must be a string")
- else:
- raise AssetValidationError("brand_manifest must be a URL string or object")
-
else:
raise AssetValidationError(f"Unknown asset_type: {asset_type}")
+def validate_catalog(
+ catalog_data: dict[str, Any],
+ catalog_requirements: dict[str, Any] | None = None,
+ check_remote_mime: bool = False,
+) -> list[str]:
+ """Validate a catalog entry against optional requirements.
+
+ Args:
+ catalog_data: Catalog dictionary with type, items, url, etc.
+ catalog_requirements: CatalogRequirements dict to validate against (optional)
+ check_remote_mime: If True, verify MIME types for remote URLs
+
+ Returns:
+ List of validation error messages (empty if all valid)
+ """
+ errors: list[str] = []
+
+ # type is required on Catalog
+ catalog_type = catalog_data.get("type")
+ if not catalog_type:
+ errors.append("Catalog must have a 'type' field")
+ return errors
+
+ from adcp import CatalogType
+
+ valid_types = {ct.value for ct in CatalogType}
+ if catalog_type not in valid_types:
+ errors.append(f"Invalid catalog type: '{catalog_type}' (valid: {', '.join(sorted(valid_types))})")
+
+ # Validate URL if provided
+ catalog_url = catalog_data.get("url")
+ if catalog_url:
+ if not isinstance(catalog_url, str):
+ errors.append("Catalog url must be a string")
+ else:
+ try:
+ validate_url(catalog_url)
+ except AssetValidationError as e:
+ errors.append(f"Catalog url: {e}")
+
+ # Validate optional enum fields
+ feed_format = catalog_data.get("feed_format")
+ if feed_format:
+ from adcp import FeedFormat
+
+ valid_feeds = {ff.value for ff in FeedFormat}
+ if feed_format not in valid_feeds:
+ errors.append(f"Invalid feed_format: '{feed_format}' (valid: {', '.join(sorted(valid_feeds))})")
+
+ content_id_type = catalog_data.get("content_id_type")
+ if content_id_type:
+ from adcp import ContentIdType
+
+ valid_cid = {c.value for c in ContentIdType}
+ if content_id_type not in valid_cid:
+ errors.append(f"Invalid content_id_type: '{content_id_type}' (valid: {', '.join(sorted(valid_cid))})")
+
+ update_frequency = catalog_data.get("update_frequency")
+ if update_frequency:
+ from adcp import UpdateFrequency
+
+ valid_uf = {u.value for u in UpdateFrequency}
+ if update_frequency not in valid_uf:
+ errors.append(f"Invalid update_frequency: '{update_frequency}' (valid: {', '.join(sorted(valid_uf))})")
+
+ conversion_events = catalog_data.get("conversion_events")
+ if conversion_events is not None:
+ if not isinstance(conversion_events, list):
+ errors.append("conversion_events must be a list")
+ else:
+ from adcp.types.generated_poc.enums.event_type import EventType
+
+ valid_events = {e.value for e in EventType}
+ for i, evt in enumerate(conversion_events):
+ if evt not in valid_events:
+ errors.append(f"Invalid conversion_event[{i}]: '{evt}'")
+
+ # Validate items if present
+ items = catalog_data.get("items")
+ if items is not None:
+ if not isinstance(items, list):
+ errors.append("Catalog items must be a list")
+ elif catalog_requirements:
+ # Check required_fields on each item
+ required_fields = catalog_requirements.get("required_fields", []) or []
+ for i, item in enumerate(items):
+ if not isinstance(item, dict):
+ errors.append(f"Catalog item[{i}] must be a dictionary")
+ continue
+ for field in required_fields:
+ if field not in item:
+ errors.append(f"Catalog item[{i}]: missing required field '{field}'")
+
+ # Validate against requirements
+ if catalog_requirements:
+ # min_items constraint
+ min_items = catalog_requirements.get("min_items")
+ if min_items is not None and items is not None and isinstance(items, list):
+ if len(items) < min_items:
+ errors.append(f"Catalog requires at least {min_items} items, got {len(items)}")
+
+ # offering_asset_constraints: validate item-level assets
+ constraints = catalog_requirements.get("offering_asset_constraints", []) or []
+ if constraints and items and isinstance(items, list):
+ for i, item in enumerate(items):
+ if not isinstance(item, dict):
+ continue
+ item_assets = item.get("assets", {})
+ if not isinstance(item_assets, dict):
+ continue
+
+ for constraint in constraints:
+ group_id = constraint.get("asset_group_id")
+ if not group_id:
+ continue
+
+ is_required = constraint.get("required", True)
+ group_data = item_assets.get(group_id)
+
+ if group_data is None:
+ if is_required:
+ errors.append(f"Catalog item[{i}]: missing required asset group '{group_id}'")
+ continue
+
+ if not isinstance(group_data, list):
+ group_data = [group_data]
+
+ min_count = constraint.get("min_count")
+ max_count = constraint.get("max_count")
+ if min_count is not None and len(group_data) < min_count:
+ errors.append(
+ f"Catalog item[{i}] asset '{group_id}': requires at least {min_count} items, got {len(group_data)}"
+ )
+ if max_count is not None and len(group_data) > max_count:
+ errors.append(
+ f"Catalog item[{i}] asset '{group_id}': allows at most {max_count} items, got {len(group_data)}"
+ )
+
+ # Validate each item against the constraint's asset_type
+ constraint_type = constraint.get("asset_type")
+ if constraint_type:
+ ct_str = constraint_type.value if hasattr(constraint_type, "value") else str(constraint_type)
+ for j, asset_item in enumerate(group_data):
+ if not isinstance(asset_item, dict):
+ errors.append(f"Catalog item[{i}] asset '{group_id}[{j}]': must be a dictionary")
+ continue
+ try:
+ validate_asset(asset_item, ct_str, check_remote_mime=check_remote_mime)
+ except AssetValidationError as e:
+ errors.append(f"Catalog item[{i}] asset '{group_id}[{j}]': {e}")
+
+ return errors
+
+
def validate_manifest_assets(
manifest: Any,
check_remote_mime: bool = False,
format_obj: Any = None,
) -> list[str]:
- """Validate all assets in a creative manifest.
+ """Validate all assets and catalogs in a creative manifest.
Args:
- manifest: Creative manifest (should be dictionary with assets field)
+ manifest: Creative manifest (should be dictionary with assets and/or catalogs field)
check_remote_mime: If True, verify MIME types for remote URLs (slower)
format_obj: Format object to validate required assets against (optional)
@@ -366,62 +492,161 @@ def validate_manifest_assets(
if not isinstance(manifest, dict):
return ["Manifest must be a dictionary"]
+ # Check whether format requires catalogs
+ has_catalog_requirements = False
+ catalog_req_list: list[dict[str, Any]] = []
+ if format_obj:
+ raw_reqs = getattr(format_obj, "catalog_requirements", None) or []
+ for req in raw_reqs:
+ req_dict = req.model_dump(exclude_none=True) if hasattr(req, "model_dump") else dict(req)
+ catalog_req_list.append(req_dict)
+ has_catalog_requirements = len(catalog_req_list) > 0
+
+ # Validate catalogs if format requires them
+ if has_catalog_requirements:
+ catalogs = manifest.get("catalogs", [])
+ if not catalogs:
+ for req in catalog_req_list:
+ is_required = req.get("required", True)
+ if is_required is not False:
+ errors.append(f"Required catalog missing for type: {req.get('catalog_type', 'unknown')}")
+ elif isinstance(catalogs, list):
+ # Build lookup of catalogs by type
+ catalog_by_type: dict[str, dict[str, Any]] = {}
+ for catalog in catalogs:
+ if isinstance(catalog, dict) and "type" in catalog:
+ catalog_by_type[catalog["type"]] = catalog
+
+ for req in catalog_req_list:
+ req_type = req.get("catalog_type")
+ if not req_type:
+ continue
+ req_type_str = req_type.value if hasattr(req_type, "value") else str(req_type)
+ is_required = req.get("required", True)
+
+ matching_catalog = catalog_by_type.get(req_type_str)
+ if not matching_catalog:
+ if is_required is not False:
+ errors.append(f"Required catalog missing for type: {req_type_str}")
+ continue
+
+ catalog_errors = validate_catalog(matching_catalog, req, check_remote_mime=check_remote_mime)
+ errors.extend(catalog_errors)
+
+ # Check feed_format compatibility
+ req_feed_formats = req.get("feed_formats")
+ catalog_feed = matching_catalog.get("feed_format")
+ if req_feed_formats and catalog_feed:
+ allowed = [ff.value if hasattr(ff, "value") else str(ff) for ff in req_feed_formats]
+ if catalog_feed not in allowed:
+ errors.append(f"Catalog feed_format '{catalog_feed}' not in required formats: {allowed}")
+
+ # Validate any catalogs not covered by requirements
+ req_types: set[str] = set()
+ for r in catalog_req_list:
+ ct = r.get("catalog_type")
+ if ct is not None:
+ req_types.add(ct.value if hasattr(ct, "value") else str(ct))
+ for catalog in catalogs:
+ if isinstance(catalog, dict):
+ ct = catalog.get("type")
+ if ct and ct not in req_types:
+ catalog_errors = validate_catalog(catalog, check_remote_mime=check_remote_mime)
+ errors.extend(catalog_errors)
+
assets = manifest.get("assets")
- if not assets:
+ if not assets and not has_catalog_requirements:
return ["Manifest must contain assets field"]
+ if not assets:
+ return errors
+
if not isinstance(assets, dict):
- return ["Manifest assets must be a dictionary"]
+ errors.append("Manifest assets must be a dictionary")
+ return errors
+
+ # Build maps from format spec if provided:
+ # asset_type_map: (asset_id or asset_group_id) -> content type string
+ # group_counts: asset_group_id -> (min_count, max_count)
+ asset_type_map: dict[str, str] = {}
+ group_counts: dict[str, tuple[int | None, int | None]] = {}
- # Build a map of asset_id -> asset_type from format if provided
- # Uses adcp utilities which handle both new `assets` and deprecated `assets_required` fields
- asset_type_map = {}
if format_obj:
from adcp import get_format_assets, get_required_assets
- # Build asset type map from all format assets
for asset in get_format_assets(format_obj):
asset_id = getattr(asset, "asset_id", None)
asset_type = getattr(asset, "asset_type", None)
-
if asset_id and asset_type:
- # Handle enum or string asset_type
- if hasattr(asset_type, "value"):
- asset_type_map[asset_id] = asset_type.value
- else:
- asset_type_map[asset_id] = str(asset_type)
+ asset_type_map[asset_id] = asset_type.value if hasattr(asset_type, "value") else str(asset_type)
+ continue
+
+ asset_group_id = getattr(asset, "asset_group_id", None)
+ if asset_group_id:
+ inner_assets = getattr(asset, "assets", [])
+ if inner_assets:
+ inner_type = getattr(inner_assets[0], "asset_type", None)
+ if inner_type:
+ asset_type_map[asset_group_id] = (
+ inner_type.value if hasattr(inner_type, "value") else str(inner_type)
+ )
+ group_counts[asset_group_id] = (
+ getattr(asset, "min_count", None),
+ getattr(asset, "max_count", None),
+ )
# Check required assets are present in manifest
for required_asset in get_required_assets(format_obj):
- asset_id = getattr(required_asset, "asset_id", None)
+ asset_id = getattr(required_asset, "asset_id", None) or getattr(required_asset, "asset_group_id", None)
if asset_id and asset_id not in assets:
errors.append(f"Required asset missing: {asset_id}")
# Validate each asset
for asset_id, asset_data in assets.items():
- # Get expected asset type from format spec
expected_type = asset_type_map.get(asset_id)
- if not expected_type:
- # No format provided or asset not in format spec
- # Try to infer type from asset data (for backward compatibility during transition)
- if "url" in asset_data and "width" in asset_data and "height" in asset_data:
- expected_type = "image"
- elif "url" in asset_data and "duration_seconds" in asset_data:
- expected_type = "video" # or audio, hard to distinguish
- elif "content" in asset_data:
- expected_type = "text" # could be html/js/css too
- elif "url" in asset_data:
- expected_type = "url"
- else:
- errors.append(
- f"Asset '{asset_id}': Cannot determine asset type (format not provided or asset_id not in format spec)"
- )
- continue
-
- try:
- validate_asset(asset_data, expected_type, check_remote_mime=check_remote_mime)
- except AssetValidationError as e:
- errors.append(f"Asset '{asset_id}': {e}")
+ if isinstance(asset_data, list):
+ # Repeatable group: validate count constraints and each item
+ min_c, max_c = group_counts.get(asset_id, (None, None))
+ if min_c is not None and len(asset_data) < min_c:
+ errors.append(f"Asset '{asset_id}': requires at least {min_c} items, got {len(asset_data)}")
+ if max_c is not None and len(asset_data) > max_c:
+ errors.append(f"Asset '{asset_id}': allows at most {max_c} items, got {len(asset_data)}")
+
+ for i, item in enumerate(asset_data):
+ if not isinstance(item, dict) or len(item) != 1:
+ errors.append(
+ f"Asset '{asset_id}[{i}]': each item must be a single-key dict like {{\"text\": {{...}}}}"
+ )
+ continue
+ item_type_key, item_data = next(iter(item.items()))
+ validate_type = expected_type or item_type_key
+ try:
+ validate_asset(item_data, validate_type, check_remote_mime=check_remote_mime)
+ except AssetValidationError as e:
+ errors.append(f"Asset '{asset_id}[{i}]': {e}")
+
+ else:
+ # Individual asset
+ if not expected_type:
+ # Try to infer type from asset data
+ if "url" in asset_data and "width" in asset_data and "height" in asset_data:
+ expected_type = "image"
+ elif "url" in asset_data and "duration_seconds" in asset_data:
+ expected_type = "video"
+ elif "content" in asset_data:
+ expected_type = "text"
+ elif "url" in asset_data:
+ expected_type = "url"
+ else:
+ errors.append(
+ f"Asset '{asset_id}': Cannot determine asset type (format not provided or asset_id not in format spec)"
+ )
+ continue
+
+ try:
+ validate_asset(asset_data, expected_type, check_remote_mime=check_remote_mime)
+ except AssetValidationError as e:
+ errors.append(f"Asset '{asset_id}': {e}")
return errors
diff --git a/tests/integration/test_catalog_formats.py b/tests/integration/test_catalog_formats.py
new file mode 100644
index 0000000..d9fb36d
--- /dev/null
+++ b/tests/integration/test_catalog_formats.py
@@ -0,0 +1,103 @@
+"""Integration tests for catalog-aware format definitions.
+
+Generative formats declare catalog_requirements instead of a promoted_offerings
+asset. This verifies the format definitions are correctly structured per ADCP 3.5.0.
+"""
+
+from adcp import CatalogType, FormatId, get_format_assets
+from pydantic import AnyUrl
+
+from creative_agent.data.standard_formats import AGENT_URL, STANDARD_FORMATS, get_format_by_id
+
+# All generative format IDs (template + concrete)
+GENERATIVE_TEMPLATE_ID = "display_generative"
+GENERATIVE_CONCRETE_IDS = [
+ "display_300x250_generative",
+ "display_728x90_generative",
+ "display_160x600_generative",
+ "display_320x50_generative",
+ "display_336x280_generative",
+ "display_300x600_generative",
+ "display_970x250_generative",
+]
+ALL_GENERATIVE_IDS = [GENERATIVE_TEMPLATE_ID, *GENERATIVE_CONCRETE_IDS]
+
+
+class TestGenerativeFormatCatalogRequirements:
+ """Generative formats must declare catalog_requirements."""
+
+ def test_generative_formats_have_catalog_requirements(self):
+ """Each generative format must have catalog_requirements."""
+ for fmt_id_str in ALL_GENERATIVE_IDS:
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str)
+ fmt = get_format_by_id(fmt_id)
+ assert fmt is not None, f"{fmt_id_str} not found"
+
+ reqs = getattr(fmt, "catalog_requirements", None)
+ assert reqs is not None, f"{fmt_id_str}: must have catalog_requirements"
+ assert len(reqs) > 0, f"{fmt_id_str}: catalog_requirements must not be empty"
+
+ def test_catalog_requirements_type_is_offering(self):
+ """Generative formats require an offering catalog."""
+ for fmt_id_str in ALL_GENERATIVE_IDS:
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str)
+ fmt = get_format_by_id(fmt_id)
+
+ reqs = fmt.catalog_requirements
+ assert reqs[0].catalog_type == CatalogType.offering, f"{fmt_id_str}: catalog_type must be 'offering'"
+
+ def test_catalog_requirements_required_fields(self):
+ """Offering catalog should require at least the 'name' field."""
+ for fmt_id_str in ALL_GENERATIVE_IDS:
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str)
+ fmt = get_format_by_id(fmt_id)
+
+ reqs = fmt.catalog_requirements
+ required_fields = reqs[0].required_fields or []
+ assert "name" in required_fields, f"{fmt_id_str}: catalog should require 'name' field"
+
+
+class TestGenerativeFormatAssets:
+ """Generative formats must still have generation_prompt and impression_tracker."""
+
+ def test_generative_formats_have_generation_prompt(self):
+ """Each generative format must have a generation_prompt asset."""
+ for fmt_id_str in ALL_GENERATIVE_IDS:
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str)
+ fmt = get_format_by_id(fmt_id)
+
+ asset_ids = {getattr(a, "asset_id", None) for a in get_format_assets(fmt)}
+ assert "generation_prompt" in asset_ids, f"{fmt_id_str}: must have generation_prompt asset"
+
+ def test_generative_formats_have_impression_tracker(self):
+ """Each generative format must have an impression_tracker asset."""
+ for fmt_id_str in ALL_GENERATIVE_IDS:
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=fmt_id_str)
+ fmt = get_format_by_id(fmt_id)
+
+ asset_ids = {getattr(a, "asset_id", None) for a in get_format_assets(fmt)}
+ assert "impression_tracker" in asset_ids, f"{fmt_id_str}: must have impression_tracker asset"
+
+ def test_generative_formats_do_not_have_promoted_offerings(self):
+ """No format should have a promoted_offerings asset."""
+ for fmt in STANDARD_FORMATS:
+ asset_ids = {getattr(a, "asset_id", None) for a in get_format_assets(fmt)}
+ assert "promoted_offerings" not in asset_ids, f"{fmt.format_id.id}: must NOT have promoted_offerings asset"
+
+
+class TestCatalogRequirementsSerialization:
+ """catalog_requirements must serialize correctly for ADCP wire format."""
+
+ def test_catalog_requirements_roundtrip(self):
+ """catalog_requirements should serialize and validate."""
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id=GENERATIVE_TEMPLATE_ID)
+ fmt = get_format_by_id(fmt_id)
+
+ fmt_dict = fmt.model_dump(mode="json", exclude_none=True)
+
+ assert "catalog_requirements" in fmt_dict
+ reqs = fmt_dict["catalog_requirements"]
+ assert isinstance(reqs, list)
+ assert len(reqs) == 1
+ assert reqs[0]["catalog_type"] == "offering"
+ assert "name" in reqs[0].get("required_fields", [])
diff --git a/tests/integration/test_preview_creative.py.bak b/tests/integration/test_preview_creative.py.bak
deleted file mode 100644
index 9e3b1c2..0000000
--- a/tests/integration/test_preview_creative.py.bak
+++ /dev/null
@@ -1,356 +0,0 @@
-"""Integration tests for preview_creative tool.
-
-All tests use generated Pydantic schemas to ensure 100% ADCP spec compliance.
-"""
-
-from datetime import UTC, datetime
-
-import pytest
-from pytest_mock import MockerFixture
-
-from creative_agent import server
-from creative_agent.data.standard_formats import AGENT_URL
-from adcp.types.generated import FormatId
-from creative_agent.schemas import CreativeManifest
-
-# Get the actual function from the FastMCP wrapper
-preview_creative = server.preview_creative.fn
-
-
-@pytest.fixture
-def mock_s3_upload(mocker: MockerFixture):
- """Mock S3 upload to avoid network calls."""
- mock_upload = mocker.patch("creative_agent.storage.upload_preview_html")
- mock_upload.return_value = "https://adcp-previews.fly.storage.tigris.dev/previews/test-id/desktop.html"
- return mock_upload
-
-
-class TestPreviewCreativeIntegration:
- """Integration tests for the preview_creative tool using spec-compliant schemas."""
-
- def test_preview_creative_with_spec_compliant_manifest(self, mock_s3_upload):
- """Test preview_creative tool with fully spec-compliant manifest."""
- # Create spec-compliant Pydantic manifest
- manifest = CreativeManifest(
- creative_id="test-1",
- name="Test Creative",
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- "url": "https://example.com/banner.png",
- "width": 300,
- "height": 250,
- "format": "png",
- },
- "click_url": {
- "url": "https://example.com/landing",
- },
- },
- )
-
- # Convert to dict as server.py does
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Verify response structure
- assert "previews" in structured
- assert isinstance(structured["previews"], list)
- assert len(structured["previews"]) == 3 # desktop, mobile, tablet
-
- # Verify each preview variant per ADCP spec
- for preview in structured["previews"]:
- assert "preview_id" in preview, "preview_id is required per spec"
- assert "renders" in preview, "renders is required per spec"
- assert len(preview["renders"]) > 0, "must have at least one render"
- assert "input" in preview, "input is required per spec"
-
- # Verify S3 upload was called
- assert mock_s3_upload.call_count == 3
-
- def test_preview_creative_with_custom_inputs(self, mock_s3_upload):
- """Test preview_creative with custom input variants."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- inputs=[
- {"name": "US Desktop", "macros": {"COUNTRY": "US", "DEVICE": "desktop"}},
- {"name": "UK Mobile", "macros": {"COUNTRY": "UK", "DEVICE": "mobile"}},
- ],
- )
-
- structured = result.structured_content
-
- assert len(structured["previews"]) == 2
- assert structured["previews"][0]["input"]["name"] == "US Desktop"
- assert structured["previews"][1]["input"]["name"] == "UK Mobile"
-
- def test_preview_creative_validates_format_id_mismatch(self, mock_s3_upload):
- """Test that preview_creative rejects manifest with mismatched format_id."""
- # Create manifest with DIFFERENT format_id than request
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_728x90_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=728,
- height=90,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image", # Different!
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "error" in structured
- assert "does not match" in structured["error"]
- # Verify error message contains the actual IDs for clarity
- assert "display_728x90_image" in structured["error"]
- assert "display_300x250_image" in structured["error"]
-
- def test_preview_creative_accepts_format_id_as_dict(self, mock_s3_upload):
- """Test that preview_creative accepts format_id as FormatId object (dict)."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- # Pass format_id as dict (FormatId object)
- result = preview_creative(
- format_id={"agent_url": str(AGENT_URL), "id": "display_300x250_image"},
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "previews" in structured
- assert len(structured["previews"]) == 3
-
- def test_preview_creative_validates_malicious_urls(self, mock_s3_upload):
- """Test that preview_creative validates and sanitizes malicious URLs."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- ).model_dump(mode="json")
-
- # Inject malicious URL after validation
- manifest["assets"]["banner_image"]["url"] = "javascript:alert('xss')"
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest,
- )
-
- structured = result.structured_content
- assert "error" in structured
- assert "validation" in structured["error"].lower()
-
- def test_preview_creative_returns_interactive_url(self, mock_s3_upload):
- """Test that preview response includes interactive_url."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "interactive_url" in structured
- assert "preview/" in structured["interactive_url"]
-
- def test_preview_creative_returns_expiration(self, mock_s3_upload):
- """Test that preview response includes expires_at timestamp."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "expires_at" in structured
- # Should be ISO 8601 format
- assert "T" in structured["expires_at"]
- assert "Z" in structured["expires_at"] or "+" in structured["expires_at"]
-
- def test_preview_creative_rejects_unknown_format(self, mock_s3_upload):
- """Test that preview_creative rejects unknown format_id."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="unknown_format_999",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "error" in structured
- assert "not found" in structured["error"].lower()
-
- def test_preview_creative_returns_spec_compliant_response(self, mock_s3_upload):
- """Test that response matches ADCP PreviewCreativeResponse spec exactly."""
- from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_response_json import (
- PreviewCreativeResponse,
- )
-
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": UrlAsset(url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Validate against actual ADCP spec
- response = PreviewCreativeResponse.model_validate(structured)
-
- # For single mode response, access via .root
- assert hasattr(response.root, "previews")
- assert response.root.previews is not None
- assert response.root.expires_at is not None
- assert len(response.root.previews) == 3 # desktop, mobile, tablet
-
- def test_preview_expiration_is_valid_iso8601_timestamp(self, mock_s3_upload):
- """Test that expires_at is a valid ISO 8601 timestamp in the future."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- format="png",
- ),
- "click_url": UrlAsset(
- url="https://example.com/landing",
- ),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Verify expires_at exists and is a string
- assert "expires_at" in structured
- expires_at_str = structured["expires_at"]
- assert isinstance(expires_at_str, str)
-
- # Verify it's valid ISO 8601 and in the future
- expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
- now = datetime.now(UTC)
- assert expires_at > now, "expires_at must be in the future"
-
- # Verify reasonable expiration duration (between 1 min and 48 hours)
- time_until_expiry = (expires_at - now).total_seconds()
- assert 60 <= time_until_expiry <= 48 * 3600, "expiration should be reasonable duration"
-
- def test_preview_creative_fails_with_missing_required_asset(self, mock_s3_upload):
- """Test that preview_creative returns clear error when required asset is missing."""
- # Create manifest missing required click_url
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": ImageAsset(
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- # Missing required click_url!
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Must return error, not crash
- assert "error" in structured, "Should return error for missing required asset"
- assert "validation" in structured["error"].lower(), "Error should mention validation"
-
- # Should have specific validation errors
- assert "validation_errors" in structured, "Should include validation_errors array"
- errors = structured["validation_errors"]
- assert len(errors) > 0, "Should have at least one validation error"
-
- # Should mention the missing asset
- error_messages = [str(err) for err in errors]
- assert any("click_url" in str(msg).lower() for msg in error_messages), "Should mention missing click_url"
diff --git a/tests/integration/test_preview_creative.py.bak2 b/tests/integration/test_preview_creative.py.bak2
deleted file mode 100644
index a10ad5d..0000000
--- a/tests/integration/test_preview_creative.py.bak2
+++ /dev/null
@@ -1,356 +0,0 @@
-"""Integration tests for preview_creative tool.
-
-All tests use generated Pydantic schemas to ensure 100% ADCP spec compliance.
-"""
-
-from datetime import UTC, datetime
-
-import pytest
-from pytest_mock import MockerFixture
-
-from creative_agent import server
-from creative_agent.data.standard_formats import AGENT_URL
-from adcp.types.generated import FormatId
-from creative_agent.schemas import CreativeManifest
-
-# Get the actual function from the FastMCP wrapper
-preview_creative = server.preview_creative.fn
-
-
-@pytest.fixture
-def mock_s3_upload(mocker: MockerFixture):
- """Mock S3 upload to avoid network calls."""
- mock_upload = mocker.patch("creative_agent.storage.upload_preview_html")
- mock_upload.return_value = "https://adcp-previews.fly.storage.tigris.dev/previews/test-id/desktop.html"
- return mock_upload
-
-
-class TestPreviewCreativeIntegration:
- """Integration tests for the preview_creative tool using spec-compliant schemas."""
-
- def test_preview_creative_with_spec_compliant_manifest(self, mock_s3_upload):
- """Test preview_creative tool with fully spec-compliant manifest."""
- # Create spec-compliant Pydantic manifest
- manifest = CreativeManifest(
- creative_id="test-1",
- name="Test Creative",
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- "url": "https://example.com/banner.png",
- "width": 300,
- "height": 250,
- "format": "png",
- },
- "click_url": {
- "url": "https://example.com/landing",
- },
- },
- )
-
- # Convert to dict as server.py does
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Verify response structure
- assert "previews" in structured
- assert isinstance(structured["previews"], list)
- assert len(structured["previews"]) == 3 # desktop, mobile, tablet
-
- # Verify each preview variant per ADCP spec
- for preview in structured["previews"]:
- assert "preview_id" in preview, "preview_id is required per spec"
- assert "renders" in preview, "renders is required per spec"
- assert len(preview["renders"]) > 0, "must have at least one render"
- assert "input" in preview, "input is required per spec"
-
- # Verify S3 upload was called
- assert mock_s3_upload.call_count == 3
-
- def test_preview_creative_with_custom_inputs(self, mock_s3_upload):
- """Test preview_creative with custom input variants."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- inputs=[
- {"name": "US Desktop", "macros": {"COUNTRY": "US", "DEVICE": "desktop"}},
- {"name": "UK Mobile", "macros": {"COUNTRY": "UK", "DEVICE": "mobile"}},
- ],
- )
-
- structured = result.structured_content
-
- assert len(structured["previews"]) == 2
- assert structured["previews"][0]["input"]["name"] == "US Desktop"
- assert structured["previews"][1]["input"]["name"] == "UK Mobile"
-
- def test_preview_creative_validates_format_id_mismatch(self, mock_s3_upload):
- """Test that preview_creative rejects manifest with mismatched format_id."""
- # Create manifest with DIFFERENT format_id than request
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_728x90_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=728,
- height=90,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image", # Different!
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "error" in structured
- assert "does not match" in structured["error"]
- # Verify error message contains the actual IDs for clarity
- assert "display_728x90_image" in structured["error"]
- assert "display_300x250_image" in structured["error"]
-
- def test_preview_creative_accepts_format_id_as_dict(self, mock_s3_upload):
- """Test that preview_creative accepts format_id as FormatId object (dict)."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- # Pass format_id as dict (FormatId object)
- result = preview_creative(
- format_id={"agent_url": str(AGENT_URL), "id": "display_300x250_image"},
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "previews" in structured
- assert len(structured["previews"]) == 3
-
- def test_preview_creative_validates_malicious_urls(self, mock_s3_upload):
- """Test that preview_creative validates and sanitizes malicious URLs."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- ).model_dump(mode="json")
-
- # Inject malicious URL after validation
- manifest["assets"]["banner_image"]["url"] = "javascript:alert('xss')"
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest,
- )
-
- structured = result.structured_content
- assert "error" in structured
- assert "validation" in structured["error"].lower()
-
- def test_preview_creative_returns_interactive_url(self, mock_s3_upload):
- """Test that preview response includes interactive_url."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "interactive_url" in structured
- assert "preview/" in structured["interactive_url"]
-
- def test_preview_creative_returns_expiration(self, mock_s3_upload):
- """Test that preview response includes expires_at timestamp."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "expires_at" in structured
- # Should be ISO 8601 format
- assert "T" in structured["expires_at"]
- assert "Z" in structured["expires_at"] or "+" in structured["expires_at"]
-
- def test_preview_creative_rejects_unknown_format(self, mock_s3_upload):
- """Test that preview_creative rejects unknown format_id."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="unknown_format_999",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
- assert "error" in structured
- assert "not found" in structured["error"].lower()
-
- def test_preview_creative_returns_spec_compliant_response(self, mock_s3_upload):
- """Test that response matches ADCP PreviewCreativeResponse spec exactly."""
- from creative_agent.schemas_generated._schemas_v1_creative_preview_creative_response_json import (
- PreviewCreativeResponse,
- )
-
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- "click_url": {url="https://example.com/landing"),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Validate against actual ADCP spec
- response = PreviewCreativeResponse.model_validate(structured)
-
- # For single mode response, access via .root
- assert hasattr(response.root, "previews")
- assert response.root.previews is not None
- assert response.root.expires_at is not None
- assert len(response.root.previews) == 3 # desktop, mobile, tablet
-
- def test_preview_expiration_is_valid_iso8601_timestamp(self, mock_s3_upload):
- """Test that expires_at is a valid ISO 8601 timestamp in the future."""
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- format="png",
- ),
- "click_url": {
- url="https://example.com/landing",
- ),
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Verify expires_at exists and is a string
- assert "expires_at" in structured
- expires_at_str = structured["expires_at"]
- assert isinstance(expires_at_str, str)
-
- # Verify it's valid ISO 8601 and in the future
- expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
- now = datetime.now(UTC)
- assert expires_at > now, "expires_at must be in the future"
-
- # Verify reasonable expiration duration (between 1 min and 48 hours)
- time_until_expiry = (expires_at - now).total_seconds()
- assert 60 <= time_until_expiry <= 48 * 3600, "expiration should be reasonable duration"
-
- def test_preview_creative_fails_with_missing_required_asset(self, mock_s3_upload):
- """Test that preview_creative returns clear error when required asset is missing."""
- # Create manifest missing required click_url
- manifest = CreativeManifest(
- format_id=FormatId(agent_url=AGENT_URL, id="display_300x250_image"),
- assets={
- "banner_image": {
- url="https://example.com/banner.png",
- width=300,
- height=250,
- ),
- # Missing required click_url!
- },
- )
-
- result = preview_creative(
- format_id="display_300x250_image",
- creative_manifest=manifest.model_dump(mode="json"),
- )
-
- structured = result.structured_content
-
- # Must return error, not crash
- assert "error" in structured, "Should return error for missing required asset"
- assert "validation" in structured["error"].lower(), "Error should mention validation"
-
- # Should have specific validation errors
- assert "validation_errors" in structured, "Should include validation_errors array"
- errors = structured["validation_errors"]
- assert len(errors) > 0, "Should have at least one validation error"
-
- # Should mention the missing asset
- error_messages = [str(err) for err in errors]
- assert any("click_url" in str(msg).lower() for msg in error_messages), "Should mention missing click_url"
diff --git a/tests/integration/test_template_formats.py b/tests/integration/test_template_formats.py
index bfc6a35..cfe1a51 100644
--- a/tests/integration/test_template_formats.py
+++ b/tests/integration/test_template_formats.py
@@ -258,10 +258,10 @@ def test_concrete_formats_have_explicit_requirements(self):
# Should have explicit width/height requirements
requirements = getattr(image_asset, "requirements", None)
assert requirements is not None
- assert "width" in requirements, "Concrete format should have explicit width"
- assert "height" in requirements, "Concrete format should have explicit height"
- assert requirements["width"] == 300
- assert requirements["height"] == 250
+ assert hasattr(requirements, "width"), "Concrete format should have explicit width"
+ assert hasattr(requirements, "height"), "Concrete format should have explicit height"
+ assert requirements.width == 300
+ assert requirements.height == 250
class TestTemplateFormatSerialization:
@@ -325,8 +325,9 @@ def test_template_with_dimensions_has_no_output_format_ids(self):
if accepts_params and FormatIdParameter.dimensions in accepts_params:
output_formats = getattr(fmt, "output_format_ids", None)
- # Generative template might have output formats, but dimension templates shouldn't
- if fmt.format_id.id not in ["display_generative"]:
+ # Generative templates (e.g. display_generative) have output_format_ids by design;
+ # pure dimension templates should not
+ if fmt.format_id.id != "display_generative":
assert output_formats is None or len(output_formats) == 0, (
f"{fmt.format_id.id}: dimension template should not have output_format_ids"
)
diff --git a/tests/integration/test_tool_response_formats.py b/tests/integration/test_tool_response_formats.py
index 4b2f7db..42bf58b 100644
--- a/tests/integration/test_tool_response_formats.py
+++ b/tests/integration/test_tool_response_formats.py
@@ -122,7 +122,7 @@ def test_no_extra_wrapper_fields(self):
)
def test_assets_have_asset_id(self):
- """Per ADCP PR #135, all assets must have asset_id field."""
+ """Per ADCP PR #135, all assets must have asset_id or asset_group_id field."""
result = list_creative_formats()
response = ListCreativeFormatsResponse.model_validate(result.structured_content)
@@ -131,10 +131,11 @@ def test_assets_have_asset_id(self):
for fmt in formats_with_assets:
for asset in get_required_assets(fmt):
- # Access asset_id - will raise AttributeError if missing
asset_dict = asset.model_dump() if hasattr(asset, "model_dump") else dict(asset)
- assert "asset_id" in asset_dict, f"Format {fmt.format_id.id} has asset without asset_id: {asset_dict}"
- assert asset_dict["asset_id"], f"Format {fmt.format_id.id} has empty asset_id: {asset_dict}"
+ has_id = "asset_id" in asset_dict or "asset_group_id" in asset_dict
+ assert has_id, f"Format {fmt.format_id.id} has asset without asset_id or asset_group_id: {asset_dict}"
+ identifier = asset_dict.get("asset_id") or asset_dict.get("asset_group_id")
+ assert identifier, f"Format {fmt.format_id.id} has empty asset identifier: {asset_dict}"
def test_backward_compat_assets_required_field(self):
"""For 2.5.x client compatibility, formats must include assets_required field."""
diff --git a/tests/validation/test_asset_validation.py b/tests/validation/test_asset_validation.py
index 33cdc0b..1c48e0b 100644
--- a/tests/validation/test_asset_validation.py
+++ b/tests/validation/test_asset_validation.py
@@ -179,10 +179,26 @@ def test_size_limit_fails(self):
"""Data URI exceeding size limit should fail."""
large_data = "x" * (11 * 1024 * 1024) # 11MB
uri = f"data:image/png;base64,{large_data}"
- with pytest.raises(AssetValidationError, match="exceeds 10MB"):
+ with pytest.raises(AssetValidationError, match="exceeds size limit"):
validate_data_uri(uri)
+class TestDataURIInValidateURL:
+ """Verify data URIs are rejected by validate_url (only allowed via validate_image_url)."""
+
+ def test_data_uri_rejected_by_validate_url(self):
+ """data: URIs should not pass through the general validate_url function."""
+ uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
+ with pytest.raises(AssetValidationError):
+ validate_url(uri)
+
+ def test_data_uri_svg_rejected_by_validate_url(self):
+ """data:image/svg+xml URIs should not pass through validate_url."""
+ uri = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg=="
+ with pytest.raises(AssetValidationError):
+ validate_url(uri)
+
+
class TestImageURLValidation:
"""Test image URL validation."""
@@ -444,39 +460,3 @@ def test_invalid_manifest_type_fails(self):
errors = validate_manifest_assets("not a dict")
assert len(errors) == 1
assert "must be a dictionary" in errors[0]
-
- def test_valid_promoted_offerings_with_url(self):
- """Valid promoted offerings with brand manifest URL should pass."""
- asset = {
- "brand_manifest": "https://brand.example.com/manifest.json",
- }
- validate_asset(asset, "promoted_offerings")
-
- def test_valid_promoted_offerings_with_inline_manifest(self):
- """Valid promoted offerings with inline brand manifest should pass."""
- asset = {
- "brand_manifest": {
- "url": "https://brand.example.com",
- "name": "ACME Corp",
- },
- }
- validate_asset(asset, "promoted_offerings")
-
- def test_valid_promoted_offerings_with_name_only(self):
- """Valid promoted offerings with name-only brand manifest should pass."""
- asset = {
- "brand_manifest": {
- "name": "ACME Corp",
- },
- }
- validate_asset(asset, "promoted_offerings")
-
- def test_promoted_offerings_inline_manifest_without_url_or_name_fails(self):
- """Promoted offerings with inline manifest missing url and name should fail."""
- asset = {
- "brand_manifest": {
- "colors": {"primary": "#FF0000"},
- },
- }
- with pytest.raises(AssetValidationError, match="must have either url or name"):
- validate_asset(asset, "promoted_offerings")
diff --git a/tests/validation/test_catalog_validation.py b/tests/validation/test_catalog_validation.py
new file mode 100644
index 0000000..126e6d5
--- /dev/null
+++ b/tests/validation/test_catalog_validation.py
@@ -0,0 +1,387 @@
+"""Validation tests for catalog entries in creative manifests.
+
+Tests validate_catalog() and the catalog path in validate_manifest_assets()
+per ADCP 3.5.0 Catalog model.
+"""
+
+from creative_agent.validation import validate_catalog, validate_manifest_assets
+
+
+class TestValidateCatalog:
+ """Unit tests for validate_catalog()."""
+
+ def test_valid_catalog_passes(self):
+ catalog = {"type": "offering", "items": [{"name": "Widget"}]}
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+ def test_missing_type_fails(self):
+ catalog = {"items": [{"name": "Widget"}]}
+ errors = validate_catalog(catalog)
+ assert any("'type' field" in e for e in errors)
+
+ def test_invalid_type_fails(self):
+ catalog = {"type": "bogus"}
+ errors = validate_catalog(catalog)
+ assert any("Invalid catalog type" in e for e in errors)
+
+ def test_valid_catalog_with_url(self):
+ catalog = {"type": "product", "url": "https://feeds.example.com/products.json"}
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+ def test_invalid_catalog_url_fails(self):
+ catalog = {"type": "product", "url": "not-a-url"}
+ errors = validate_catalog(catalog)
+ assert any("url" in e.lower() for e in errors)
+
+ def test_items_must_be_list(self):
+ catalog = {"type": "offering", "items": "not a list"}
+ errors = validate_catalog(catalog)
+ assert any("items must be a list" in e for e in errors)
+
+ def test_items_must_be_dicts(self):
+ catalog = {"type": "offering", "items": ["not a dict"]}
+ requirements = {"required_fields": ["name"]}
+ errors = validate_catalog(catalog, requirements)
+ assert any("must be a dictionary" in e for e in errors)
+
+
+class TestCatalogRequiredFields:
+ """Tests for required_fields enforcement on catalog items."""
+
+ def test_required_fields_present(self):
+ catalog = {"type": "offering", "items": [{"name": "Widget", "price": "9.99"}]}
+ requirements = {"required_fields": ["name", "price"]}
+ errors = validate_catalog(catalog, requirements)
+ assert errors == []
+
+ def test_missing_required_field_fails(self):
+ catalog = {"type": "offering", "items": [{"price": "9.99"}]}
+ requirements = {"required_fields": ["name"]}
+ errors = validate_catalog(catalog, requirements)
+ assert any("missing required field 'name'" in e for e in errors)
+
+ def test_multiple_items_validated(self):
+ catalog = {
+ "type": "offering",
+ "items": [
+ {"name": "Good"},
+ {"description": "Missing name"},
+ ],
+ }
+ requirements = {"required_fields": ["name"]}
+ errors = validate_catalog(catalog, requirements)
+ # Only second item should fail
+ assert len(errors) == 1
+ assert "item[1]" in errors[0]
+
+
+class TestCatalogMinItems:
+ """Tests for min_items constraint."""
+
+ def test_min_items_satisfied(self):
+ catalog = {"type": "offering", "items": [{"name": "A"}, {"name": "B"}]}
+ requirements = {"min_items": 2}
+ errors = validate_catalog(catalog, requirements)
+ assert errors == []
+
+ def test_min_items_violated(self):
+ catalog = {"type": "offering", "items": [{"name": "A"}]}
+ requirements = {"min_items": 3}
+ errors = validate_catalog(catalog, requirements)
+ assert any("at least 3 items" in e for e in errors)
+
+
+class TestCatalogOfferingAssetConstraints:
+ """Tests for offering_asset_constraints validation on catalog items."""
+
+ def test_valid_offering_assets(self):
+ catalog = {
+ "type": "offering",
+ "items": [
+ {
+ "name": "Widget",
+ "assets": {
+ "product_images": [
+ {"url": "https://cdn.example.com/img1.jpg"},
+ ],
+ },
+ },
+ ],
+ }
+ requirements = {
+ "offering_asset_constraints": [
+ {
+ "asset_group_id": "product_images",
+ "asset_type": "image",
+ "required": True,
+ "min_count": 1,
+ "max_count": 5,
+ },
+ ],
+ }
+ errors = validate_catalog(catalog, requirements)
+ assert errors == []
+
+ def test_missing_required_asset_group_fails(self):
+ catalog = {
+ "type": "offering",
+ "items": [{"name": "Widget", "assets": {}}],
+ }
+ requirements = {
+ "offering_asset_constraints": [
+ {
+ "asset_group_id": "product_images",
+ "asset_type": "image",
+ "required": True,
+ },
+ ],
+ }
+ errors = validate_catalog(catalog, requirements)
+ assert any("missing required asset group 'product_images'" in e for e in errors)
+
+ def test_optional_asset_group_not_required(self):
+ catalog = {
+ "type": "offering",
+ "items": [{"name": "Widget", "assets": {}}],
+ }
+ requirements = {
+ "offering_asset_constraints": [
+ {
+ "asset_group_id": "product_images",
+ "asset_type": "image",
+ "required": False,
+ },
+ ],
+ }
+ errors = validate_catalog(catalog, requirements)
+ assert errors == []
+
+ def test_max_count_exceeded_fails(self):
+ catalog = {
+ "type": "offering",
+ "items": [
+ {
+ "name": "Widget",
+ "assets": {
+ "product_images": [
+ {"url": "https://cdn.example.com/1.jpg"},
+ {"url": "https://cdn.example.com/2.jpg"},
+ {"url": "https://cdn.example.com/3.jpg"},
+ ],
+ },
+ },
+ ],
+ }
+ requirements = {
+ "offering_asset_constraints": [
+ {
+ "asset_group_id": "product_images",
+ "asset_type": "image",
+ "required": True,
+ "max_count": 2,
+ },
+ ],
+ }
+ errors = validate_catalog(catalog, requirements)
+ assert any("at most 2 items" in e for e in errors)
+
+ def test_invalid_asset_content_fails(self):
+ catalog = {
+ "type": "offering",
+ "items": [
+ {
+ "name": "Widget",
+ "assets": {
+ "descriptions": [
+ {"content": ""},
+ ],
+ },
+ },
+ ],
+ }
+ requirements = {
+ "offering_asset_constraints": [
+ {
+ "asset_group_id": "descriptions",
+ "asset_type": "text",
+ "required": True,
+ },
+ ],
+ }
+ errors = validate_catalog(catalog, requirements)
+ assert any("cannot be empty" in e.lower() for e in errors)
+
+
+class TestCatalogEnumFields:
+ """Tests for enum field validation on catalogs."""
+
+ def test_valid_feed_format(self):
+ catalog = {"type": "product", "feed_format": "google_merchant_center"}
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+ def test_invalid_feed_format(self):
+ catalog = {"type": "product", "feed_format": "amazon_feed"}
+ errors = validate_catalog(catalog)
+ assert any("Invalid feed_format" in e for e in errors)
+
+ def test_valid_content_id_type(self):
+ catalog = {"type": "product", "content_id_type": "sku"}
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+ def test_invalid_content_id_type(self):
+ catalog = {"type": "product", "content_id_type": "barcode"}
+ errors = validate_catalog(catalog)
+ assert any("Invalid content_id_type" in e for e in errors)
+
+ def test_valid_update_frequency(self):
+ catalog = {"type": "product", "update_frequency": "daily"}
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+ def test_invalid_update_frequency(self):
+ catalog = {"type": "product", "update_frequency": "every_5_minutes"}
+ errors = validate_catalog(catalog)
+ assert any("Invalid update_frequency" in e for e in errors)
+
+ def test_valid_conversion_events(self):
+ catalog = {"type": "offering", "conversion_events": ["purchase", "add_to_cart"]}
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+ def test_invalid_conversion_event(self):
+ catalog = {"type": "offering", "conversion_events": ["purchase", "bogus_event"]}
+ errors = validate_catalog(catalog)
+ assert any("Invalid conversion_event[1]" in e for e in errors)
+
+ def test_conversion_events_must_be_list(self):
+ catalog = {"type": "offering", "conversion_events": "purchase"}
+ errors = validate_catalog(catalog)
+ assert any("conversion_events must be a list" in e for e in errors)
+
+ def test_all_enum_fields_valid_together(self):
+ catalog = {
+ "type": "product",
+ "feed_format": "shopify",
+ "content_id_type": "sku",
+ "update_frequency": "hourly",
+ "conversion_events": ["purchase", "view_content"],
+ }
+ errors = validate_catalog(catalog)
+ assert errors == []
+
+
+class TestCatalogFeedFormatCompatibility:
+ """Tests for feed_format compatibility between catalog and requirements."""
+
+ def _generative_format(self):
+ from adcp import FormatId
+ from pydantic import AnyUrl
+
+ from creative_agent.data.standard_formats import AGENT_URL, get_format_by_id
+
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id="display_generative")
+ return get_format_by_id(fmt_id)
+
+ def test_feed_format_compatible(self):
+ """Catalog feed_format matches one of the requirement's feed_formats."""
+ from adcp import CatalogRequirements, CatalogType, FeedFormat, Format, FormatId
+ from pydantic import AnyUrl
+
+ fmt = Format(
+ format_id=FormatId(agent_url=AnyUrl("https://test.example.com"), id="test_fmt"),
+ name="Test Format",
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.product,
+ feed_formats=[FeedFormat.google_merchant_center, FeedFormat.shopify],
+ ),
+ ],
+ )
+ manifest = {
+ "assets": {"banner": {"url": "https://cdn.example.com/img.jpg", "width": 300, "height": 250}},
+ "catalogs": [{"type": "product", "feed_format": "google_merchant_center"}],
+ }
+ errors = validate_manifest_assets(manifest, format_obj=fmt)
+ assert not any("feed_format" in e for e in errors)
+
+ def test_feed_format_incompatible(self):
+ """Catalog feed_format does not match any of the requirement's feed_formats."""
+ from adcp import CatalogRequirements, CatalogType, FeedFormat, Format, FormatId
+ from pydantic import AnyUrl
+
+ fmt = Format(
+ format_id=FormatId(agent_url=AnyUrl("https://test.example.com"), id="test_fmt"),
+ name="Test Format",
+ catalog_requirements=[
+ CatalogRequirements(
+ catalog_type=CatalogType.product,
+ feed_formats=[FeedFormat.google_merchant_center],
+ ),
+ ],
+ )
+ manifest = {
+ "assets": {"banner": {"url": "https://cdn.example.com/img.jpg", "width": 300, "height": 250}},
+ "catalogs": [{"type": "product", "feed_format": "shopify"}],
+ }
+ errors = validate_manifest_assets(manifest, format_obj=fmt)
+ assert any("feed_format" in e and "shopify" in e for e in errors)
+
+
+class TestManifestCatalogValidation:
+ """Tests for catalog validation within validate_manifest_assets()."""
+
+ def _generative_format(self):
+ """Get a generative format with catalog_requirements."""
+ from adcp import FormatId
+ from pydantic import AnyUrl
+
+ from creative_agent.data.standard_formats import AGENT_URL, get_format_by_id
+
+ fmt_id = FormatId(agent_url=AnyUrl(str(AGENT_URL)), id="display_generative")
+ return get_format_by_id(fmt_id)
+
+ def test_manifest_with_catalog_passes(self):
+ fmt = self._generative_format()
+ manifest = {
+ "format_id": {"agent_url": "https://creative.example.com", "id": "display_generative"},
+ "assets": {
+ "generation_prompt": {"content": "Create a banner ad"},
+ "impression_tracker": {"url": "https://track.example.com/imp"},
+ },
+ "catalogs": [
+ {"type": "offering", "items": [{"name": "Widget"}]},
+ ],
+ }
+ errors = validate_manifest_assets(manifest, format_obj=fmt)
+ assert errors == [], f"Unexpected errors: {errors}"
+
+ def test_manifest_missing_required_catalog_fails(self):
+ fmt = self._generative_format()
+ manifest = {
+ "format_id": {"agent_url": "https://creative.example.com", "id": "display_generative"},
+ "assets": {
+ "generation_prompt": {"content": "Create a banner ad"},
+ "impression_tracker": {"url": "https://track.example.com/imp"},
+ },
+ }
+ errors = validate_manifest_assets(manifest, format_obj=fmt)
+ assert any("Required catalog missing" in e for e in errors)
+
+ def test_manifest_catalog_wrong_type_fails(self):
+ fmt = self._generative_format()
+ manifest = {
+ "format_id": {"agent_url": "https://creative.example.com", "id": "display_generative"},
+ "assets": {
+ "generation_prompt": {"content": "Create a banner ad"},
+ "impression_tracker": {"url": "https://track.example.com/imp"},
+ },
+ "catalogs": [
+ {"type": "job", "items": [{"title": "Engineer"}]},
+ ],
+ }
+ errors = validate_manifest_assets(manifest, format_obj=fmt)
+ assert any("Required catalog missing for type: offering" in e for e in errors)
diff --git a/tests/validation/test_template_parameter_validation.py b/tests/validation/test_template_parameter_validation.py
index 4c55626..7877c1f 100644
--- a/tests/validation/test_template_parameter_validation.py
+++ b/tests/validation/test_template_parameter_validation.py
@@ -163,11 +163,12 @@ def test_concrete_format_has_explicit_dimensions(self):
fmt = get_format_by_id(format_id)
image_asset = next(a for a in get_required_assets(fmt) if a.asset_id == "banner_image")
- requirements = getattr(image_asset, "requirements", {})
+ requirements = getattr(image_asset, "requirements", None)
+ assert requirements is not None
# Concrete format has explicit values in requirements
- assert requirements.get("width") == 300
- assert requirements.get("height") == 250
+ assert requirements.width == 300
+ assert requirements.height == 250
class TestParameterValidationEdgeCases:
diff --git a/uv.lock b/uv.lock
index a98f55a..1fe1f64 100644
--- a/uv.lock
+++ b/uv.lock
@@ -24,8 +24,8 @@ wheels = [
[[package]]
name = "adcp"
-version = "3.2.0"
-source = { registry = "https://pypi.org/simple" }
+version = "3.5.0"
+source = { git = "https://github.com/adcontextprotocol/adcp-client-python?tag=v3.5.0#5576bf42f26f0b0748587be65930c4c4cbeb7c30" }
dependencies = [
{ name = "a2a-sdk" },
{ name = "email-validator" },
@@ -34,10 +34,6 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/08/9b/283f9efa8d904d8edb111a922ad2c415ee50134f4f5b3a6db3049c8e9b66/adcp-3.2.0.tar.gz", hash = "sha256:2113bfda4f4dd1fe690ed698ed0ec5b30447be3a5328a4ad5ab5a02c308f6083", size = 245028, upload-time = "2026-02-04T02:06:07.139Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/b7/4f0eaad37a01aab12fef1a09900a7ca0ae947c2cc9f7964c208c689d054c/adcp-3.2.0-py3-none-any.whl", hash = "sha256:e3ed195ad9bce525e976d148d9d092ce705f7246ef54f1cc3e7aea30186dd483", size = 308217, upload-time = "2026-02-04T02:06:05.531Z" },
-]
[[package]]
name = "adcp-creative-agent"
@@ -47,7 +43,6 @@ dependencies = [
{ name = "adcp" },
{ name = "bleach" },
{ name = "boto3" },
- { name = "fastapi" },
{ name = "fastmcp" },
{ name = "google-genai" },
{ name = "httpx" },
@@ -73,10 +68,9 @@ dev = [
[package.metadata]
requires-dist = [
- { name = "adcp", specifier = ">=3.2.0" },
+ { name = "adcp", git = "https://github.com/adcontextprotocol/adcp-client-python?tag=v3.5.0" },
{ name = "bleach", specifier = ">=6.3.0" },
{ name = "boto3", specifier = ">=1.35.0" },
- { name = "fastapi", specifier = ">=0.100.0" },
{ name = "fastmcp", specifier = ">=2.11.0" },
{ name = "google-genai", specifier = ">=1.0.0" },
{ name = "httpx", specifier = ">=0.28.1" },
@@ -549,20 +543,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
]
-[[package]]
-name = "fastapi"
-version = "0.118.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pydantic" },
- { name = "starlette" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ff/1b/6cbc5bc6d7a07a506c2275d443e4517adb4e02ab42e0a6486568e1749896/fastapi-0.118.1.tar.gz", hash = "sha256:063f9d4ff5bcdfd1ef6e4e6b44ed5fb5f4bf370b39cdce1c9aed22413c371cfe", size = 311185, upload-time = "2025-10-08T09:07:24.199Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fb/29/9bf43b2fe09854a4c743eed5ef9b6a84bab39b05bfd46aea7ce6c7bf97f1/fastapi-0.118.1-py3-none-any.whl", hash = "sha256:be88c15c995464d14d2be1d7059860551aeffb9df889688bcea7050c9635badf", size = 97933, upload-time = "2025-10-08T09:07:22.003Z" },
-]
-
[[package]]
name = "fastmcp"
version = "2.12.4"