Skip to content

Commit fa35bfb

Browse files
bokelleyclaude
andauthored
feat(validation): ADCP_VALIDATION_MODE env var + outputSchema on tools/list (#391)
Two JS-parity gaps in validation: 1. ADCP_VALIDATION_MODE env var. The TS SDK supports ADCP_VALIDATION_MODE=strict|warn|off to override defaults at call time. Python only honored ADCP_ENV=prod to flip the response default to "warn". Now resolves in this order: explicit ValidationHookConfig > ADCP_VALIDATION_MODE > ADCP_ENV flip > defaults. Read at call time so tests can mutate via patch.dict. 2. outputSchema on tools/list. ADCP_TOOL_DEFINITIONS shipped inputSchema only; the TS SDK advertises both. Generates response schemas alongside requests via a shared _model_to_json_schema helper that allows root-level anyOf for discriminated response unions (CreateMediaBuyResponse success / error / pending). FastMCP serializes Tool.fn_metadata.output_schema into the wire response. Also fixes a stale docstring on ADCPClient.__init__ that falsely claimed PYTHON_ENV / ENV / ENVIRONMENT were consulted. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac7a61f commit fa35bfb

6 files changed

Lines changed: 637 additions & 38 deletions

File tree

src/adcp/client.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,13 @@ def __init__(
377377
JSON schemas. Defaults (matching the TS port): requests
378378
in ``warn`` mode (drift logged but not blocked — partial
379379
payloads in error-path tests still work) and responses
380-
in ``strict`` mode (agent drift fails the task). The
381-
response mode flips to ``warn`` when any of ``ADCP_ENV``
382-
/ ``PYTHON_ENV`` / ``ENV`` / ``ENVIRONMENT`` is set to
383-
``production`` / ``prod``. Storyboards and compliance
380+
in ``strict`` mode (agent drift fails the task).
381+
``ADCP_VALIDATION_MODE=strict|warn|off`` overrides both
382+
sides at call time (matches the TS port); ``ADCP_ENV``
383+
set to ``production`` / ``prod`` flips only the response
384+
default to ``warn``. Generic ``ENV`` / ``ENVIRONMENT`` /
385+
``PYTHON_ENV`` are deliberately ignored — they collide
386+
with unrelated tooling. Storyboards and compliance
384387
runners that want hard-stop enforcement everywhere pass
385388
``validation=ValidationHookConfig(requests="strict",
386389
responses="strict")``; high-throughput callers can set

src/adcp/server/mcp_tools.py

Lines changed: 229 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1173,6 +1173,49 @@ def _resolve(node: Any, seen: frozenset[str]) -> Any:
11731173
return result
11741174

11751175

1176+
def _model_to_json_schema(
1177+
model_type: Any, *, allow_root_union: bool = False
1178+
) -> dict[str, Any] | None:
1179+
"""Generate a flat JSON Schema for a Pydantic model or union.
1180+
1181+
* Plain ``BaseModel`` subclasses use ``model_json_schema()``.
1182+
* Union / Optional types use ``TypeAdapter`` so discriminated unions
1183+
and aliases (``CreateMediaBuyResponse = ...Response1 | ...Response2``)
1184+
generate as ``anyOf``.
1185+
* ``$ref`` nodes are inlined (see :func:`_inline_refs`) so MCP
1186+
clients that don't resolve references see the full surface.
1187+
1188+
When ``allow_root_union`` is ``False`` (the default — used for input
1189+
schemas), schemas with a root-level ``anyOf`` / ``$ref`` return
1190+
``None`` so the caller falls back to a hand-crafted shape.
1191+
Input schemas need ``type: "object"`` at the root so MCP clients can
1192+
render the form. Output schemas can validly be a discriminated
1193+
union, so ``allow_root_union=True`` keeps the ``anyOf``.
1194+
1195+
Returns ``None`` on any failure — callers fall back to skipping.
1196+
"""
1197+
try:
1198+
from pydantic import TypeAdapter
1199+
1200+
if isinstance(model_type, type) and hasattr(model_type, "model_json_schema"):
1201+
schema = model_type.model_json_schema()
1202+
else:
1203+
adapter = TypeAdapter(model_type)
1204+
schema = adapter.json_schema()
1205+
except Exception:
1206+
return None
1207+
1208+
schema.pop("title", None)
1209+
1210+
if not allow_root_union and ("anyOf" in schema or "$ref" in schema):
1211+
return None
1212+
1213+
try:
1214+
return _inline_refs(schema)
1215+
except Exception:
1216+
return None
1217+
1218+
11761219
def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]:
11771220
"""Generate JSON schemas from Pydantic request models.
11781221
@@ -1188,8 +1231,6 @@ def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]:
11881231
against that regression by asserting every tool has an entry here.
11891232
"""
11901233
try:
1191-
from pydantic import TypeAdapter
1192-
11931234
from adcp.types import (
11941235
AcquireRightsRequest,
11951236
ActivateSignalRequest,
@@ -1331,49 +1372,212 @@ def _generate_pydantic_schemas() -> dict[str, dict[str, Any]]:
13311372

13321373
schemas: dict[str, dict[str, Any]] = {}
13331374
for tool_name, request_type in _tool_to_request.items():
1334-
try:
1335-
# Handle union types (e.g. PreviewCreativeRequest, ComplyTestControllerRequest)
1336-
if isinstance(request_type, type) and hasattr(request_type, "model_json_schema"):
1337-
schema = request_type.model_json_schema()
1338-
else:
1339-
# Union types need TypeAdapter
1340-
adapter = TypeAdapter(request_type)
1341-
schema = adapter.json_schema()
1375+
# Input schemas must be flat ``type: "object"`` — root-level
1376+
# ``anyOf`` / ``$ref`` schemas are skipped so the hand-crafted
1377+
# stub stays in place.
1378+
schema = _model_to_json_schema(request_type, allow_root_union=False)
1379+
if schema is None:
1380+
logger.debug(
1381+
"Pydantic input-schema generation skipped for %s, using hand-crafted schema",
1382+
tool_name,
1383+
)
1384+
continue
1385+
schemas[tool_name] = schema
13421386

1343-
schema.pop("title", None)
1387+
return schemas
13441388

1345-
# Union types produce anyOf with $ref at root — these can't
1346-
# be represented as flat MCP schemas. Keep hand-crafted.
1347-
if "anyOf" in schema or "$ref" in schema:
1348-
continue
13491389

1350-
# Inline every $ref into its $defs body so MCP clients that
1351-
# don't resolve JSON-Schema references (a surprisingly large
1352-
# slice of the ecosystem) still see the full tool surface.
1353-
# Spec-wise the schema is equivalent — just flat.
1354-
schema = _inline_refs(schema)
1390+
def _generate_pydantic_output_schemas() -> dict[str, dict[str, Any]]:
1391+
"""Generate JSON schemas from Pydantic response models.
13551392
1356-
schemas[tool_name] = schema
1357-
except Exception:
1393+
Mirror of :func:`_generate_pydantic_schemas` for the response side.
1394+
Each AdCP tool has a corresponding ``Response`` type — for plain
1395+
success responses this is a single ``BaseModel`` subclass; for tools
1396+
that distinguish success / error / pending / rejected on the wire
1397+
(``CreateMediaBuyResponse``, ``AcquireRightsResponse``, etc.) it's a
1398+
union alias.
1399+
1400+
Output schemas advertise the structured-content shape on
1401+
``tools/list`` (matches the TS port) so MCP clients can validate
1402+
``structuredContent`` without a separate spec lookup.
1403+
1404+
Unlike input schemas, root-level ``anyOf`` is allowed — discriminated
1405+
response unions are valid JSON Schema and clients that consume
1406+
``outputSchema`` already handle them.
1407+
"""
1408+
try:
1409+
from adcp.types import (
1410+
AcquireRightsResponse,
1411+
ActivateSignalResponse,
1412+
BuildCreativeResponse,
1413+
CalibrateContentResponse,
1414+
CheckGovernanceResponse,
1415+
ComplyTestControllerResponse,
1416+
ContextMatchResponse,
1417+
CreateCollectionListResponse,
1418+
CreateContentStandardsResponse,
1419+
CreateMediaBuyResponse,
1420+
CreatePropertyListResponse,
1421+
DeleteCollectionListResponse,
1422+
DeletePropertyListResponse,
1423+
GetAccountFinancialsResponse,
1424+
GetAdcpCapabilitiesResponse,
1425+
GetBrandIdentityResponse,
1426+
GetCollectionListResponse,
1427+
GetContentStandardsResponse,
1428+
GetCreativeDeliveryResponse,
1429+
GetCreativeFeaturesResponse,
1430+
GetMediaBuyArtifactsResponse,
1431+
GetMediaBuyDeliveryResponse,
1432+
GetMediaBuysResponse,
1433+
GetPlanAuditLogsResponse,
1434+
GetProductsResponse,
1435+
GetPropertyListResponse,
1436+
GetRightsResponse,
1437+
GetSignalsResponse,
1438+
IdentityMatchResponse,
1439+
ListAccountsResponse,
1440+
ListCollectionListsResponse,
1441+
ListContentStandardsResponse,
1442+
ListCreativeFormatsResponse,
1443+
ListCreativesResponse,
1444+
ListPropertyListsResponse,
1445+
LogEventResponse,
1446+
PreviewCreativeResponse,
1447+
ProvidePerformanceFeedbackResponse,
1448+
ReportPlanOutcomeResponse,
1449+
ReportUsageResponse,
1450+
SiGetOfferingResponse,
1451+
SiInitiateSessionResponse,
1452+
SiSendMessageResponse,
1453+
SiTerminateSessionResponse,
1454+
SyncAccountsResponse,
1455+
SyncAudiencesResponse,
1456+
SyncCatalogsResponse,
1457+
SyncCreativesResponse,
1458+
SyncEventSourcesResponse,
1459+
SyncGovernanceResponse,
1460+
SyncPlansResponse,
1461+
UpdateCollectionListResponse,
1462+
UpdateContentStandardsResponse,
1463+
UpdateMediaBuyResponse,
1464+
UpdatePropertyListResponse,
1465+
UpdateRightsResponse,
1466+
ValidateContentDeliveryResponse,
1467+
)
1468+
except ImportError:
1469+
return {}
1470+
1471+
_tool_to_response: dict[str, Any] = {
1472+
# Catalog
1473+
"get_products": GetProductsResponse,
1474+
"list_creative_formats": ListCreativeFormatsResponse,
1475+
# Creative
1476+
"sync_creatives": SyncCreativesResponse,
1477+
"list_creatives": ListCreativesResponse,
1478+
"build_creative": BuildCreativeResponse,
1479+
"preview_creative": PreviewCreativeResponse,
1480+
"get_creative_delivery": GetCreativeDeliveryResponse,
1481+
# Media Buy
1482+
"create_media_buy": CreateMediaBuyResponse,
1483+
"update_media_buy": UpdateMediaBuyResponse,
1484+
"get_media_buy_delivery": GetMediaBuyDeliveryResponse,
1485+
"get_media_buys": GetMediaBuysResponse,
1486+
# Signals
1487+
"get_signals": GetSignalsResponse,
1488+
"activate_signal": ActivateSignalResponse,
1489+
# Account
1490+
"list_accounts": ListAccountsResponse,
1491+
"sync_accounts": SyncAccountsResponse,
1492+
"get_account_financials": GetAccountFinancialsResponse,
1493+
"report_usage": ReportUsageResponse,
1494+
# Events & Catalogs
1495+
"log_event": LogEventResponse,
1496+
"sync_event_sources": SyncEventSourcesResponse,
1497+
"sync_audiences": SyncAudiencesResponse,
1498+
"sync_catalogs": SyncCatalogsResponse,
1499+
"sync_governance": SyncGovernanceResponse,
1500+
# Feedback
1501+
"provide_performance_feedback": ProvidePerformanceFeedbackResponse,
1502+
# Protocol Discovery
1503+
"get_adcp_capabilities": GetAdcpCapabilitiesResponse,
1504+
# Compliance
1505+
"comply_test_controller": ComplyTestControllerResponse,
1506+
# Content Standards
1507+
"create_content_standards": CreateContentStandardsResponse,
1508+
"get_content_standards": GetContentStandardsResponse,
1509+
"list_content_standards": ListContentStandardsResponse,
1510+
"update_content_standards": UpdateContentStandardsResponse,
1511+
"calibrate_content": CalibrateContentResponse,
1512+
"validate_content_delivery": ValidateContentDeliveryResponse,
1513+
"get_media_buy_artifacts": GetMediaBuyArtifactsResponse,
1514+
# Governance
1515+
"get_creative_features": GetCreativeFeaturesResponse,
1516+
"sync_plans": SyncPlansResponse,
1517+
"check_governance": CheckGovernanceResponse,
1518+
"report_plan_outcome": ReportPlanOutcomeResponse,
1519+
"get_plan_audit_logs": GetPlanAuditLogsResponse,
1520+
# Property Lists
1521+
"create_property_list": CreatePropertyListResponse,
1522+
"get_property_list": GetPropertyListResponse,
1523+
"list_property_lists": ListPropertyListsResponse,
1524+
"update_property_list": UpdatePropertyListResponse,
1525+
"delete_property_list": DeletePropertyListResponse,
1526+
# Collection Lists
1527+
"create_collection_list": CreateCollectionListResponse,
1528+
"get_collection_list": GetCollectionListResponse,
1529+
"list_collection_lists": ListCollectionListsResponse,
1530+
"update_collection_list": UpdateCollectionListResponse,
1531+
"delete_collection_list": DeleteCollectionListResponse,
1532+
# Sponsored Intelligence
1533+
"si_get_offering": SiGetOfferingResponse,
1534+
"si_initiate_session": SiInitiateSessionResponse,
1535+
"si_send_message": SiSendMessageResponse,
1536+
"si_terminate_session": SiTerminateSessionResponse,
1537+
# Brand
1538+
"get_brand_identity": GetBrandIdentityResponse,
1539+
"get_rights": GetRightsResponse,
1540+
"acquire_rights": AcquireRightsResponse,
1541+
"update_rights": UpdateRightsResponse,
1542+
# TMP
1543+
"context_match": ContextMatchResponse,
1544+
"identity_match": IdentityMatchResponse,
1545+
}
1546+
1547+
schemas: dict[str, dict[str, Any]] = {}
1548+
for tool_name, response_type in _tool_to_response.items():
1549+
schema = _model_to_json_schema(response_type, allow_root_union=True)
1550+
if schema is None:
13581551
logger.debug(
1359-
"Pydantic schema generation failed for %s, using hand-crafted schema",
1552+
"Pydantic output-schema generation failed for %s",
13601553
tool_name,
1361-
exc_info=True,
13621554
)
1555+
continue
1556+
schemas[tool_name] = schema
13631557

13641558
return schemas
13651559

13661560

13671561
# Generate schemas once at import time
13681562
_PYDANTIC_SCHEMAS = _generate_pydantic_schemas()
1563+
_PYDANTIC_OUTPUT_SCHEMAS = _generate_pydantic_output_schemas()
13691564

13701565

13711566
def _apply_pydantic_schemas() -> None:
1372-
"""Replace hand-crafted inputSchemas with Pydantic-generated ones."""
1567+
"""Apply Pydantic-generated input + output schemas to tool definitions.
1568+
1569+
* ``inputSchema``: replaced when a Pydantic-generated schema is
1570+
available (handles drift between hand-crafted stubs and the spec).
1571+
* ``outputSchema``: added so ``tools/list`` advertises the structured
1572+
response shape — matches the TS port and lets MCP clients validate
1573+
``structuredContent`` without a separate spec lookup.
1574+
"""
13731575
for tool_def in ADCP_TOOL_DEFINITIONS:
13741576
name = tool_def["name"]
13751577
if name in _PYDANTIC_SCHEMAS:
13761578
tool_def["inputSchema"] = _PYDANTIC_SCHEMAS[name]
1579+
if name in _PYDANTIC_OUTPUT_SCHEMAS:
1580+
tool_def["outputSchema"] = _PYDANTIC_OUTPUT_SCHEMAS[name]
13771581

13781582

13791583
_apply_pydantic_schemas()

src/adcp/server/serve.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1310,6 +1310,7 @@ def _register_handler_tools(
13101310
continue
13111311
description = tool_def.get("description", "")
13121312
input_schema = tool_def.get("inputSchema", {"type": "object", "properties": {}})
1313+
output_schema = tool_def.get("outputSchema")
13131314
caller = create_tool_caller(handler, tool_name, validation=validation)
13141315
_register_tool(
13151316
mcp,
@@ -1319,6 +1320,7 @@ def _register_handler_tools(
13191320
caller,
13201321
context_factory=context_factory,
13211322
middleware=middleware_tuple,
1323+
output_schema=output_schema,
13221324
)
13231325
registered.append(tool_name)
13241326

@@ -1339,6 +1341,7 @@ def _register_tool(
13391341
*,
13401342
context_factory: ContextFactory | None = None,
13411343
middleware: tuple[SkillMiddleware, ...] = (),
1344+
output_schema: dict[str, Any] | None = None,
13421345
) -> None:
13431346
"""Register a single ADCP tool on a FastMCP server.
13441347
@@ -1449,9 +1452,18 @@ def model_dump_one_level(self) -> dict[str, Any]:
14491452
result.update(self.model_extra)
14501453
return result
14511454

1455+
# Advertise the spec response schema on ``tools/list`` when one is
1456+
# available. FastMCP serializes ``Tool.output_schema`` (which reads
1457+
# ``fn_metadata.output_schema``) into the ``outputSchema`` field of
1458+
# the ``tools/list`` response — matches the TS port. Falls back to
1459+
# the auto-derived shape from the ``fn`` return annotation when no
1460+
# spec schema is mapped (e.g. handler-only custom tools).
1461+
effective_output_schema = (
1462+
output_schema if output_schema is not None else tool.fn_metadata.output_schema
1463+
)
14521464
tool.fn_metadata = FuncMetadata(
14531465
arg_model=_AdcpArgs,
1454-
output_schema=tool.fn_metadata.output_schema,
1466+
output_schema=effective_output_schema,
14551467
output_model=tool.fn_metadata.output_model,
14561468
wrap_output=False,
14571469
)

0 commit comments

Comments
 (0)