Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ readme = "README.md"
requires-python = ">=3.12"

dependencies = [
"adcp==1.6.0", # Official AdCP Python client for external agent communication and adagents.json validation
"adcp==1.6.1", # Official AdCP Python client for external agent communication and adagents.json validation
"fastmcp>=2.13.0", # Required for context.get_http_request() support
"google-generativeai>=0.5.4",
"google-cloud-iam>=2.19.1",
Expand Down
34 changes: 22 additions & 12 deletions src/a2a_server/adcp_a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1325,6 +1325,7 @@ async def _handle_get_products_skill(self, parameters: dict, auth_token: str) ->
min_exposures = parameters.get("min_exposures", None)
adcp_version = parameters.get("adcp_version", "1.0.0")
strategy_id = parameters.get("strategy_id", None)
context = parameters.get("context", None)

# Normalize brand_manifest to dict format (adcp v1.2.1 requirement)
brand_manifest: dict | None = None
Expand Down Expand Up @@ -1355,7 +1356,8 @@ async def _handle_get_products_skill(self, parameters: dict, auth_token: str) ->
min_exposures=min_exposures,
adcp_version=adcp_version,
strategy_id=strategy_id,
context=self._tool_context_to_mcp_context(tool_context),
context=context,
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Convert response to dict
Expand Down Expand Up @@ -1434,7 +1436,8 @@ async def _handle_create_media_buy_skill(self, parameters: dict, auth_token: str
budget=parameters.get("budget"), # Optional legacy field - ignored if provided
targeting_overlay=parameters.get("custom_targeting", {}),
push_notification_config=parameters.get("push_notification_config"),
context=self._tool_context_to_mcp_context(tool_context),
context=parameters.get("context"),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Convert response to dict and add A2A success wrapper
Expand Down Expand Up @@ -1502,7 +1505,8 @@ async def _handle_sync_creatives_skill(self, parameters: dict, auth_token: str)
dry_run=parameters.get("dry_run", False),
validation_mode=parameters.get("validation_mode", "strict"),
push_notification_config=parameters.get("push_notification_config"),
context=self._tool_context_to_mcp_context(tool_context),
context=parameters.get("context"),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Convert response to dict
Expand Down Expand Up @@ -1536,7 +1540,7 @@ async def _handle_list_creatives_skill(self, parameters: dict, auth_token: str)
auth_token=auth_token,
tool_name="list_creatives",
)

# Call core function with optional parameters (fixing original validation bug)
response = core_list_creatives_tool(
media_buy_id=parameters.get("media_buy_id"),
Expand All @@ -1551,7 +1555,8 @@ async def _handle_list_creatives_skill(self, parameters: dict, auth_token: str)
limit=parameters.get("limit", 50),
sort_by=parameters.get("sort_by", "created_date"),
sort_order=parameters.get("sort_order", "desc"),
context=self._tool_context_to_mcp_context(tool_context),
context=parameters.get("context"),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Convert response to dict
Expand Down Expand Up @@ -1696,6 +1701,7 @@ async def _handle_get_signals_skill(self, parameters: dict, auth_token: str) ->
deliver_to=parameters["deliver_to"],
filters=parameters.get("filters"),
max_results=parameters.get("max_results"),
context=parameters.get("context") or None,
)

# Call core function directly
Expand Down Expand Up @@ -1764,10 +1770,11 @@ async def _handle_list_creative_formats_skill(self, parameters: dict, auth_token
standard_only=parameters.get("standard_only"),
category=parameters.get("category"),
format_ids=parameters.get("format_ids"),
context=parameters.get("context"),
)

# Call core function with request
response = core_list_creative_formats_tool(req=req, context=self._tool_context_to_mcp_context(tool_context))
response = core_list_creative_formats_tool(req=req, ctx=self._tool_context_to_mcp_context(tool_context))

# Convert response to dict
if isinstance(response, dict):
Expand Down Expand Up @@ -1825,12 +1832,12 @@ def __init__(self, headers):
# Map A2A parameters to ListAuthorizedPropertiesRequest
from src.core.schema_adapters import ListAuthorizedPropertiesRequest as SchemaAdapterRequest

request = SchemaAdapterRequest(tags=parameters.get("tags", []))
request = SchemaAdapterRequest(tags=parameters.get("tags", []), context=parameters.get("context"))

# Call core function directly
# Context can be None for unauthenticated calls - tenant will be detected from headers
# MinimalContext is not compatible with ToolContext type, but works at runtime
response = core_list_authorized_properties_tool(req=request, context=tool_context) # type: ignore[arg-type]
response = core_list_authorized_properties_tool(req=request, ctx=tool_context) # type: ignore[arg-type]

# Return spec-compliant response (no extra fields)
# Per AdCP v2.4 spec: only publisher_domains, primary_channels, primary_countries,
Expand Down Expand Up @@ -1882,7 +1889,8 @@ async def _handle_update_media_buy_skill(self, parameters: dict, auth_token: str
budget=parameters.get("budget"),
packages=packages,
push_notification_config=parameters.get("push_notification_config"),
context=self._tool_context_to_mcp_context(tool_context),
context=parameters.get("context"),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Return spec-compliant response (no extra fields)
Expand Down Expand Up @@ -1937,7 +1945,8 @@ async def _handle_get_media_buy_delivery_skill(self, parameters: dict, auth_toke
status_filter=status_filter,
start_date=start_date,
end_date=end_date,
context=self._tool_context_to_mcp_context(tool_context),
context=parameters.get("context"),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Convert response to dict for A2A format
Expand Down Expand Up @@ -1972,7 +1981,8 @@ async def _handle_update_performance_index_skill(self, parameters: dict, auth_to
response = core_update_performance_index_tool(
media_buy_id=parameters["media_buy_id"],
performance_data=parameters["performance_data"],
context=self._tool_context_to_mcp_context(tool_context),
context=parameters.get("context"),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Return spec-compliant response (no extra fields)
Expand Down Expand Up @@ -2012,7 +2022,7 @@ async def _get_products(self, query: str, auth_token: str | None) -> dict:
response = await core_get_products_tool(
brief=query,
brand_manifest=brand_manifest,
context=self._tool_context_to_mcp_context(tool_context),
ctx=self._tool_context_to_mcp_context(tool_context),
)

# Convert to A2A response format
Expand Down
2 changes: 1 addition & 1 deletion src/core/helpers/creative_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ def process_and_upload_package_creatives(
dry_run=testing_ctx.dry_run if testing_ctx else False,
validation_mode="strict",
push_notification_config=None,
context=context, # For principal_id extraction
ctx=context, # For principal_id extraction
)

# Extract creative IDs from response
Expand Down
20 changes: 14 additions & 6 deletions src/core/mcp_context_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,10 @@ def wrapper(*args, **kwargs):

def _extract_fastmcp_context(self, args: tuple, kwargs: dict) -> FastMCPContext | None:
"""Extract FastMCP Context from function arguments."""
# Check kwargs first
if "context" in kwargs and isinstance(kwargs["context"], FastMCPContext):
return kwargs["context"]
# Check kwargs for any value that is a FastMCPContext (supports 'ctx' or other param names)
for k, v in kwargs.items():
if isinstance(v, FastMCPContext):
return v

# Check positional args
for arg in args:
Expand Down Expand Up @@ -259,9 +260,16 @@ def _create_tool_context(self, fastmcp_context: FastMCPContext, tool_name: str)

def _replace_context_in_args(self, args: tuple, kwargs: dict, tool_context: ToolContext) -> tuple[tuple, dict]:
"""Replace FastMCP Context with ToolContext in arguments."""
# Replace in kwargs
if "context" in kwargs:
kwargs = {**kwargs, "context": tool_context}
# Replace in kwargs: set on whichever key carried the FastMCP context (supports 'ctx' or others)
new_kwargs = {}
replaced = False
for k, v in kwargs.items():
if isinstance(v, FastMCPContext):
new_kwargs[k] = tool_context
replaced = True
else:
new_kwargs[k] = v
kwargs = new_kwargs

# Replace in positional args
new_args = []
Expand Down
22 changes: 21 additions & 1 deletion src/core/schema_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class GetProductsRequest(BaseModel):
min_exposures: int | None = Field(None, description="Minimum exposures needed for measurement validity")
strategy_id: str | None = Field(None, description="Optional strategy ID for linking operations")
webhook_url: str | None = Field(None, description="URL for async task completion notifications")
context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request")

@model_validator(mode="before")
@classmethod
Expand Down Expand Up @@ -276,6 +277,7 @@ class CreateMediaBuyRequest(BaseModel):
start_time: datetime | str = Field(..., description="Campaign start time or 'asap'")
end_time: datetime = Field(..., description="Campaign end time")
budget: dict[str, Any] = Field(..., description="Budget configuration")
context: dict[str, Any] | None = Field(None, description="Application-level context echoed from the request")

# Optional fields
promoted_offering: str | None = Field(None, description="DEPRECATED: Use brand_manifest")
Expand Down Expand Up @@ -503,6 +505,9 @@ class ListCreativeFormatsRequest(BaseModel):
format_ids: list[FormatId] | None = Field(
None, description="Return only these specific format IDs (e.g., from get_products response)"
)
context: dict[str, Any] | None = Field(
None, description="Application-level context provided by the client"
)

def to_generated(self) -> _GeneratedListCreativeFormatsRequest:
"""Convert to generated schema for protocol validation."""
Expand All @@ -513,6 +518,9 @@ class ListAuthorizedPropertiesRequest(BaseModel):
"""Adapter for ListAuthorizedPropertiesRequest - simple pass-through to generated schema."""

tags: list[str] | None = Field(None, description="Filter properties by specific tags")
context: dict[str, Any] | None = Field(
None, description="Application-level context provided by the client"
)

def to_generated(self) -> _GeneratedListAuthorizedPropertiesRequest:
"""Convert to generated schema for protocol validation."""
Expand Down Expand Up @@ -550,8 +558,11 @@ class CreateMediaBuyResponse(AdCPBaseModel):
# Optional AdCP domain fields
media_buy_id: str | None = None
creative_deadline: Any | None = None
packages: list[Any] | None = Field(default_factory=list)
packages: list[Any] = Field(default_factory=list)
errors: list[Any] | None = None
context: dict[str, Any] | None = Field(
None, description="Application-level context echoed from the request"
)

# Internal fields (excluded from AdCP responses)
workflow_step_id: str | None = None
Expand Down Expand Up @@ -594,6 +605,9 @@ class UpdateMediaBuyResponse(AdCPBaseModel):
implementation_date: str | None = Field(None, description="ISO 8601 date when changes will take effect")
affected_packages: list[Any] | None = Field(default_factory=list)
errors: list[Any] | None = None
context: dict[str, Any] | None = Field(
None, description="Application-level context echoed from the request"
)

def __str__(self) -> str:
"""Return human-readable message for protocol layer."""
Expand Down Expand Up @@ -624,6 +638,9 @@ class SyncCreativesResponse(AdCPBaseModel):

# Optional fields (per official spec)
dry_run: bool | None = Field(None, description="Whether this was a dry run (no actual changes made)")
context: dict[str, Any] | None = Field(
None, description="Application-level context echoed from the request"
)

def __str__(self) -> str:
"""Return human-readable summary message for protocol envelope."""
Expand Down Expand Up @@ -738,6 +755,9 @@ class ActivateSignalResponse(AdCPBaseModel):
estimated_activation_duration_minutes: float | None = None
deployed_at: str | None = None
errors: list[Any] | None = None
context: dict[str, Any] | None = Field(
None, description="Application-level context echoed from the request"
)

def __str__(self) -> str:
"""Return human-readable message for protocol layer."""
Expand Down
4 changes: 4 additions & 0 deletions src/core/schema_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def create_get_products_request(
brief: str = "",
brand_manifest: dict[str, Any] | None = None,
filters: dict[str, Any] | None = None,
context: dict[str, Any] | None = None,
) -> GetProductsRequest:
"""Create GetProductsRequest aligned with adcp v1.2.1 spec.

Expand All @@ -55,12 +56,14 @@ def create_get_products_request(
brand_manifest=brand_manifest,
brief=brief or None,
filters=filters,
context=context,
)


def create_get_products_response(
products: list[Product | dict[str, Any]],
errors: list | None = None,
request_context: dict[str, Any] | None = None,
) -> GetProductsResponse:
"""Create GetProductsResponse.

Expand All @@ -77,6 +80,7 @@ def create_get_products_response(
return GetProductsResponse(
products=products, # type: ignore[arg-type]
errors=errors,
context=request_context,
)


Expand Down
Loading