Skip to content
This repository was archived by the owner on Mar 15, 2026. It is now read-only.

Commit 6f51048

Browse files
bokelleyclaude
andauthored
Add get_adcp_capabilities tool and upgrade to ADCP 3.2.0 (#46)
* feat: add get_adcp_capabilities tool and upgrade to ADCP 3.2.0 - Add get_adcp_capabilities MCP tool returning agent's ADCP protocol version and supported protocols (creative) - Upgrade ADCP library from 3.1.0 to 3.2.0 for creative protocol support - Remove obsolete assets_required backfill since 3.2.0 uses unified assets field with required flag - Add backward compatibility for 2.5.x clients by including assets_required in list_creative_formats response - Add spec-first tests for get_adcp_capabilities with 7 test cases validating ADCP schema compliance Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix: address code review feedback - Remove orphaned comment in standard_formats.py - Fix docstring to mention creative protocol instead of media_buy - Add test for unsupported protocol filter edge case Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix: use MajorVersion type for mypy compliance The Adcp type expects MajorVersion objects, not raw integers. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent f4c8635 commit 6f51048

5 files changed

Lines changed: 207 additions & 40 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
"boto3>=1.35.0",
1717
"markdown>=3.6",
1818
"bleach>=6.3.0",
19-
"adcp>=3.1.0", # Official ADCP Python client with template format support
19+
"adcp>=3.2.0", # Official ADCP Python client with template format support
2020
]
2121

2222
[project.scripts]

src/creative_agent/data/standard_formats.py

Lines changed: 0 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
from adcp import FormatCategory, FormatId, get_required_assets
99
from adcp.types.generated_poc.core.format import Assets as LibAssets
10-
from adcp.types.generated_poc.core.format import AssetsRequired as LibAssetsRequired
1110
from adcp.types.generated_poc.core.format import Renders as LibRender
1211
from adcp.types.generated_poc.enums.format_id_parameter import FormatIdParameter
1312
from pydantic import AnyUrl
@@ -1493,39 +1492,6 @@ def create_responsive_render(
14931492
)
14941493

14951494

1496-
def _backfill_deprecated_assets_required() -> list[CreativeFormat]:
1497-
"""Backfill the deprecated assets_required field for backward compatibility.
1498-
1499-
The assets_required field is deprecated in adcp-client-python 2.18.0+ in favor
1500-
of the new assets field. This function derives assets_required from assets
1501-
using adcp's get_required_assets utility to maintain backward compatibility
1502-
with code that still uses assets_required.
1503-
1504-
Since Pydantic models are frozen, we rebuild them with the backfilled field.
1505-
"""
1506-
from adcp import get_required_assets
1507-
1508-
rebuilt = []
1509-
1510-
for fmt in STANDARD_FORMATS:
1511-
if not fmt.assets:
1512-
rebuilt.append(fmt)
1513-
continue
1514-
1515-
# Use adcp utility to get required assets, then convert to AssetsRequired type
1516-
required_assets = get_required_assets(fmt)
1517-
fmt_dict = fmt.model_dump()
1518-
fmt_dict["assets_required"] = [LibAssetsRequired.model_validate(a.model_dump()) for a in required_assets]
1519-
1520-
rebuilt.append(CreativeFormat.model_validate(fmt_dict))
1521-
1522-
return rebuilt
1523-
1524-
1525-
# Backfill deprecated assets_required field for backward compatibility
1526-
STANDARD_FORMATS = _backfill_deprecated_assets_required() # type: ignore[misc]
1527-
1528-
15291495
def get_format_by_id(format_id: FormatId) -> CreativeFormat | None:
15301496
"""Get format by FormatId object.
15311497

src/creative_agent/server.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
from typing import Any
88

99
from adcp import FormatId, get_optional_assets, get_required_assets
10-
from adcp.types import Capability
10+
from adcp.types import Capability, GetAdcpCapabilitiesResponse
1111
from adcp.types.generated_poc.media_buy.list_creative_formats_response import CreativeAgent
12+
from adcp.types.generated_poc.protocol.get_adcp_capabilities_response import Adcp, MajorVersion, SupportedProtocol
1213
from fastmcp import FastMCP
1314
from fastmcp.tools.tool import ToolResult
1415
from mcp.types import TextContent
@@ -186,6 +187,11 @@ def list_creative_formats(
186187
# Build human-readable format details for LLM consumption
187188
response_json = response.model_dump(mode="json", exclude_none=True)
188189

190+
# Add assets_required for backward compatibility with 2.5.x clients
191+
for fmt_json in response_json.get("formats", []):
192+
if fmt_json.get("assets"):
193+
fmt_json["assets_required"] = [asset for asset in fmt_json["assets"] if asset.get("required", False)]
194+
189195
if formats:
190196
format_details = [_format_to_human_readable(fmt) for fmt in formats]
191197
full_message = f"{message}:\n\n" + "\n".join(format_details)
@@ -829,6 +835,63 @@ def build_creative(
829835
)
830836

831837

838+
@mcp.tool()
839+
def get_adcp_capabilities(
840+
protocols: list[str] | None = None,
841+
) -> ToolResult:
842+
"""Get the ADCP capabilities supported by this agent.
843+
844+
Returns information about the ADCP protocol version and which domain protocols
845+
(e.g., creative, media_buy, signals) this agent supports.
846+
847+
Args:
848+
protocols: Optional list of specific protocols to query capabilities for.
849+
If omitted, returns capabilities for all supported protocols.
850+
851+
Returns:
852+
ToolResult with human-readable message and structured ADCP capabilities data
853+
"""
854+
try:
855+
# This creative agent supports the creative protocol
856+
# Note: ADCP library uses lowercase enum names (creative, not CREATIVE)
857+
supported = [SupportedProtocol.creative]
858+
859+
# Filter to requested protocols if specified
860+
if protocols:
861+
supported = [p for p in supported if p.value in protocols]
862+
863+
# Build response per ADCP spec
864+
response = GetAdcpCapabilitiesResponse(
865+
adcp=Adcp(major_versions=[MajorVersion(1)]), # ADCP v1
866+
supported_protocols=supported,
867+
)
868+
869+
response_json = response.model_dump(mode="json", exclude_none=True)
870+
871+
# Build human-readable message
872+
protocol_names = [p.value for p in supported]
873+
message = f"ADCP capabilities: supports {', '.join(protocol_names)} protocol(s), ADCP version 1"
874+
875+
return ToolResult(
876+
content=[TextContent(type="text", text=message)],
877+
structured_content=response_json,
878+
)
879+
except ValueError as e:
880+
error_response = {"error": f"Invalid input: {e}"}
881+
return ToolResult(
882+
content=[TextContent(type="text", text=f"Error: Invalid input - {e}")],
883+
structured_content=error_response,
884+
)
885+
except Exception as e:
886+
import traceback
887+
888+
error_response = {"error": f"Server error: {e}", "traceback": traceback.format_exc()[-500:]}
889+
return ToolResult(
890+
content=[TextContent(type="text", text=f"Error: Server error - {e}")],
891+
structured_content=error_response,
892+
)
893+
894+
832895
if __name__ == "__main__":
833896
# Check if we're in production (Fly.io)
834897
if os.getenv("PRODUCTION") == "true":

tests/integration/test_tool_response_formats.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
PreviewCreativeResponse,
1818
get_required_assets,
1919
)
20+
from adcp.types import GetAdcpCapabilitiesResponse
2021

2122
from creative_agent import server
2223
from creative_agent.data.standard_formats import AGENT_URL
@@ -25,6 +26,7 @@
2526
# Get actual functions from FastMCP wrappers
2627
list_creative_formats = server.list_creative_formats.fn
2728
preview_creative = server.preview_creative.fn
29+
get_adcp_capabilities = server.get_adcp_capabilities.fn
2830

2931

3032
class TestListCreativeFormatsResponseFormat:
@@ -134,6 +136,26 @@ def test_assets_have_asset_id(self):
134136
assert "asset_id" in asset_dict, f"Format {fmt.format_id.id} has asset without asset_id: {asset_dict}"
135137
assert asset_dict["asset_id"], f"Format {fmt.format_id.id} has empty asset_id: {asset_dict}"
136138

139+
def test_backward_compat_assets_required_field(self):
140+
"""For 2.5.x client compatibility, formats must include assets_required field."""
141+
result = list_creative_formats()
142+
result_dict = result.structured_content
143+
144+
# Find a format that has assets
145+
formats_with_assets = [f for f in result_dict["formats"] if f.get("assets")]
146+
assert len(formats_with_assets) > 0, "Should have formats with assets"
147+
148+
for fmt in formats_with_assets:
149+
# assets_required must be present for backward compatibility
150+
assert "assets_required" in fmt, (
151+
f"Format {fmt.get('format_id', {}).get('id')} missing assets_required for 2.5.x compatibility"
152+
)
153+
# assets_required should only contain assets where required=True
154+
for asset in fmt["assets_required"]:
155+
assert asset.get("required", False) is True, (
156+
f"assets_required should only contain required assets, got: {asset}"
157+
)
158+
137159
def test_accepts_format_ids_as_dicts(self):
138160
"""Test that list_creative_formats accepts format_ids as FormatId objects (dicts)."""
139161
# Filter by format_ids using dict representation
@@ -333,3 +355,119 @@ def test_structured_content_not_double_encoded(self, mocker):
333355
pytest.fail(f"Found double-encoded JSON in field '{key}': {value[:100]}")
334356
except json.JSONDecodeError:
335357
pass
358+
359+
360+
class TestGetAdcpCapabilitiesResponseFormat:
361+
"""Test that get_adcp_capabilities returns valid ADCP GetAdcpCapabilitiesResponse.
362+
363+
Written by reading the ADCP spec - GetAdcpCapabilitiesResponse schema.
364+
NOT by looking at server.py code.
365+
366+
Required fields per spec:
367+
- adcp: object with major_versions array
368+
- supported_protocols: array of protocol names (media_buy, signals, etc.)
369+
"""
370+
371+
def test_returns_tool_result_with_structured_content(self):
372+
"""Tool must return ToolResult with structured_content."""
373+
result = get_adcp_capabilities()
374+
375+
# Verify ToolResult structure
376+
assert hasattr(result, "content"), "Must return ToolResult with content"
377+
assert hasattr(result, "structured_content"), "Must return ToolResult with structured_content"
378+
assert result.content, "Content must not be empty"
379+
assert result.structured_content, "Structured content must not be empty"
380+
381+
# Verify content is human-readable message
382+
assert result.content[0].type == "text"
383+
assert "capabilit" in result.content[0].text.lower(), "Content should mention capabilities"
384+
385+
def test_structured_content_matches_adcp_schema(self):
386+
"""Structured content must validate against GetAdcpCapabilitiesResponse schema."""
387+
result = get_adcp_capabilities()
388+
389+
# Get structured_content (already a dict, no JSON parsing needed)
390+
result_dict = result.structured_content
391+
392+
# This validates ALL fields, types, constraints per ADCP spec
393+
response = GetAdcpCapabilitiesResponse.model_validate(result_dict)
394+
395+
# Verify required fields per spec
396+
assert response.adcp is not None, "'adcp' field is required per ADCP spec"
397+
assert response.supported_protocols is not None, "'supported_protocols' field is required per ADCP spec"
398+
399+
def test_adcp_field_structure(self):
400+
"""Per spec, adcp must have major_versions array with at least one version."""
401+
result = get_adcp_capabilities()
402+
response = GetAdcpCapabilitiesResponse.model_validate(result.structured_content)
403+
404+
assert response.adcp is not None, "adcp is required"
405+
assert response.adcp.major_versions is not None, "adcp.major_versions is required"
406+
assert isinstance(response.adcp.major_versions, list), "major_versions must be array"
407+
assert len(response.adcp.major_versions) >= 1, "must have at least one major version"
408+
# Version must be positive integer (may be wrapped in MajorVersion type)
409+
for version in response.adcp.major_versions:
410+
# Handle MajorVersion wrapper type that has .root attribute
411+
version_int = version.root if hasattr(version, "root") else version
412+
assert isinstance(version_int, int), f"major_version must be integer, got {type(version_int)}"
413+
assert version_int >= 1, "major_version must be >= 1"
414+
415+
def test_supported_protocols_structure(self):
416+
"""Per spec, supported_protocols must be array of valid protocol names."""
417+
result = get_adcp_capabilities()
418+
response = GetAdcpCapabilitiesResponse.model_validate(result.structured_content)
419+
420+
assert isinstance(response.supported_protocols, list), "supported_protocols must be array"
421+
assert len(response.supported_protocols) >= 1, "must support at least one protocol"
422+
423+
# Valid protocols per ADCP spec
424+
valid_protocols = {"media_buy", "signals", "governance", "sponsored_intelligence", "creative"}
425+
for protocol in response.supported_protocols:
426+
# Handle both string and enum
427+
protocol_str = protocol.value if hasattr(protocol, "value") else str(protocol)
428+
assert protocol_str in valid_protocols, f"Invalid protocol: {protocol_str}"
429+
430+
def test_creative_agent_supports_creative_protocol(self):
431+
"""A creative agent must support the creative protocol."""
432+
result = get_adcp_capabilities()
433+
response = GetAdcpCapabilitiesResponse.model_validate(result.structured_content)
434+
435+
protocol_strs = [p.value if hasattr(p, "value") else str(p) for p in response.supported_protocols]
436+
assert "creative" in protocol_strs, "Creative agent must support creative protocol"
437+
438+
def test_no_extra_wrapper_fields(self):
439+
"""Structured content must match ADCP schema exactly with no wrappers."""
440+
result = get_adcp_capabilities()
441+
result_dict = result.structured_content
442+
443+
# These are common bugs - wrapping valid response in extra structure
444+
assert "result" not in result_dict or not isinstance(result_dict.get("result"), str), (
445+
"structured_content must not have JSON string in 'result' field"
446+
)
447+
assert "data" not in result_dict or result_dict.get("data") != result_dict, (
448+
"structured_content must not be wrapped in 'data' field"
449+
)
450+
451+
# Top-level keys should include required schema keys
452+
required_keys = {"adcp", "supported_protocols"}
453+
actual_keys = set(result_dict.keys())
454+
assert required_keys.issubset(actual_keys), (
455+
f"Response must have required keys {required_keys}, got {actual_keys}"
456+
)
457+
458+
def test_protocols_filter_works(self):
459+
"""If protocols param is provided, should filter to those protocols."""
460+
result = get_adcp_capabilities(protocols=["creative"])
461+
462+
response = GetAdcpCapabilitiesResponse.model_validate(result.structured_content)
463+
# Should still have required fields
464+
assert response.adcp is not None
465+
assert response.supported_protocols is not None
466+
467+
def test_protocols_filter_with_unsupported_protocol_returns_error(self):
468+
"""If protocols param contains only unsupported protocols, returns error."""
469+
result = get_adcp_capabilities(protocols=["media_buy"]) # This agent only supports creative
470+
471+
# ADCP schema requires at least one protocol, so filtering to unsupported
472+
# protocols results in a validation error
473+
assert "error" in result.structured_content

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)