Skip to content

Commit d3cdcaf

Browse files
committed
feat(server): advertise outputSchema on tools/list (JS parity)
`tools/list` now carries `outputSchema` per ADCP tool, matching the TypeScript SDK's behaviour. Pydantic response types from `adcp.types` drive the schemas via `_generate_pydantic_output_schemas()`, parallel to the existing `_generate_pydantic_schemas()` for `inputSchema`. Key design decisions: - anyOf union response types (e.g. CreateMediaBuyResponse) are included — outputSchema is informational and anyOf is valid MCP contract here, unlike the inputSchema generator which skips them. - `_inline_refs` flattens all $ref nodes for client compatibility. - `_register_tool` accepts a new `output_schema` kwarg; the per-tool schema replaces FastMCP's generic dict inference in fn_metadata while the runtime `output_model` (permissive dict acceptor) is preserved so structuredContent keeps working. - `register_test_controller` gains `structured_output=True` so its fn_metadata has a non-None `output_model` required by FastMCP's assertion in `convert_result`. https://claude.ai/code/session_019UpkNNacQ4QS8zcVTRfFQV
1 parent cda07f9 commit d3cdcaf

4 files changed

Lines changed: 342 additions & 13 deletions

File tree

src/adcp/server/mcp_tools.py

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,205 @@ def _apply_pydantic_schemas() -> None:
13791379
_apply_pydantic_schemas()
13801380

13811381

1382+
def _generate_pydantic_output_schemas() -> dict[str, dict[str, Any]]:
1383+
"""Generate JSON output schemas from Pydantic response models.
1384+
1385+
Maps tool names to their corresponding response Pydantic types and
1386+
generates JSON Schema via ``model_json_schema()`` / ``TypeAdapter``.
1387+
1388+
Unlike the input schema generator, this does **not** skip ``anyOf``
1389+
union types — ``outputSchema`` on ``tools/list`` is informational
1390+
and ``anyOf`` is valid there (clients advertise what they can return,
1391+
not what they require as input).
1392+
1393+
The result is applied to ``ADCP_TOOL_DEFINITIONS`` at import time by
1394+
:func:`_apply_pydantic_output_schemas`.
1395+
"""
1396+
try:
1397+
from pydantic import TypeAdapter
1398+
1399+
from adcp.types import (
1400+
AcquireRightsResponse,
1401+
ActivateSignalResponse,
1402+
BuildCreativeResponse,
1403+
CalibrateContentResponse,
1404+
CheckGovernanceResponse,
1405+
ComplyTestControllerResponse,
1406+
ContextMatchResponse,
1407+
CreateCollectionListResponse,
1408+
CreateContentStandardsResponse,
1409+
CreateMediaBuyResponse,
1410+
CreatePropertyListResponse,
1411+
DeleteCollectionListResponse,
1412+
DeletePropertyListResponse,
1413+
GetAccountFinancialsResponse,
1414+
GetAdcpCapabilitiesResponse,
1415+
GetBrandIdentityResponse,
1416+
GetCollectionListResponse,
1417+
GetContentStandardsResponse,
1418+
GetCreativeDeliveryResponse,
1419+
GetCreativeFeaturesResponse,
1420+
GetMediaBuyArtifactsResponse,
1421+
GetMediaBuyDeliveryResponse,
1422+
GetMediaBuysResponse,
1423+
GetPlanAuditLogsResponse,
1424+
GetProductsResponse,
1425+
GetPropertyListResponse,
1426+
GetRightsResponse,
1427+
GetSignalsResponse,
1428+
IdentityMatchResponse,
1429+
ListAccountsResponse,
1430+
ListCollectionListsResponse,
1431+
ListContentStandardsResponse,
1432+
ListCreativeFormatsResponse,
1433+
ListCreativesResponse,
1434+
ListPropertyListsResponse,
1435+
LogEventResponse,
1436+
PreviewCreativeResponse,
1437+
ProvidePerformanceFeedbackResponse,
1438+
ReportPlanOutcomeResponse,
1439+
ReportUsageResponse,
1440+
SiGetOfferingResponse,
1441+
SiInitiateSessionResponse,
1442+
SiSendMessageResponse,
1443+
SiTerminateSessionResponse,
1444+
SyncAccountsResponse,
1445+
SyncAudiencesResponse,
1446+
SyncCatalogsResponse,
1447+
SyncCreativesResponse,
1448+
SyncEventSourcesResponse,
1449+
SyncGovernanceResponse,
1450+
SyncPlansResponse,
1451+
UpdateCollectionListResponse,
1452+
UpdateContentStandardsResponse,
1453+
UpdateMediaBuyResponse,
1454+
UpdatePropertyListResponse,
1455+
UpdateRightsResponse,
1456+
ValidateContentDeliveryResponse,
1457+
)
1458+
except ImportError:
1459+
return {}
1460+
1461+
_tool_to_response: dict[str, Any] = {
1462+
# Catalog
1463+
"get_products": GetProductsResponse,
1464+
"list_creative_formats": ListCreativeFormatsResponse,
1465+
# Creative
1466+
"sync_creatives": SyncCreativesResponse,
1467+
"list_creatives": ListCreativesResponse,
1468+
"build_creative": BuildCreativeResponse,
1469+
"preview_creative": PreviewCreativeResponse,
1470+
"get_creative_delivery": GetCreativeDeliveryResponse,
1471+
# Media Buy
1472+
"create_media_buy": CreateMediaBuyResponse,
1473+
"update_media_buy": UpdateMediaBuyResponse,
1474+
"get_media_buy_delivery": GetMediaBuyDeliveryResponse,
1475+
"get_media_buys": GetMediaBuysResponse,
1476+
# Signals
1477+
"get_signals": GetSignalsResponse,
1478+
"activate_signal": ActivateSignalResponse,
1479+
# Account
1480+
"list_accounts": ListAccountsResponse,
1481+
"sync_accounts": SyncAccountsResponse,
1482+
"get_account_financials": GetAccountFinancialsResponse,
1483+
"report_usage": ReportUsageResponse,
1484+
# Events & Catalogs
1485+
"log_event": LogEventResponse,
1486+
"sync_event_sources": SyncEventSourcesResponse,
1487+
"sync_audiences": SyncAudiencesResponse,
1488+
"sync_catalogs": SyncCatalogsResponse,
1489+
"sync_governance": SyncGovernanceResponse,
1490+
# Feedback
1491+
"provide_performance_feedback": ProvidePerformanceFeedbackResponse,
1492+
# Protocol Discovery
1493+
"get_adcp_capabilities": GetAdcpCapabilitiesResponse,
1494+
# Compliance
1495+
"comply_test_controller": ComplyTestControllerResponse,
1496+
# Content Standards
1497+
"create_content_standards": CreateContentStandardsResponse,
1498+
"get_content_standards": GetContentStandardsResponse,
1499+
"list_content_standards": ListContentStandardsResponse,
1500+
"update_content_standards": UpdateContentStandardsResponse,
1501+
"calibrate_content": CalibrateContentResponse,
1502+
"validate_content_delivery": ValidateContentDeliveryResponse,
1503+
"get_media_buy_artifacts": GetMediaBuyArtifactsResponse,
1504+
# Governance
1505+
"get_creative_features": GetCreativeFeaturesResponse,
1506+
"sync_plans": SyncPlansResponse,
1507+
"check_governance": CheckGovernanceResponse,
1508+
"report_plan_outcome": ReportPlanOutcomeResponse,
1509+
"get_plan_audit_logs": GetPlanAuditLogsResponse,
1510+
# Property Lists
1511+
"create_property_list": CreatePropertyListResponse,
1512+
"get_property_list": GetPropertyListResponse,
1513+
"list_property_lists": ListPropertyListsResponse,
1514+
"update_property_list": UpdatePropertyListResponse,
1515+
"delete_property_list": DeletePropertyListResponse,
1516+
# Collection Lists
1517+
"create_collection_list": CreateCollectionListResponse,
1518+
"get_collection_list": GetCollectionListResponse,
1519+
"list_collection_lists": ListCollectionListsResponse,
1520+
"update_collection_list": UpdateCollectionListResponse,
1521+
"delete_collection_list": DeleteCollectionListResponse,
1522+
# Sponsored Intelligence
1523+
"si_get_offering": SiGetOfferingResponse,
1524+
"si_initiate_session": SiInitiateSessionResponse,
1525+
"si_send_message": SiSendMessageResponse,
1526+
"si_terminate_session": SiTerminateSessionResponse,
1527+
# Brand
1528+
"get_brand_identity": GetBrandIdentityResponse,
1529+
"get_rights": GetRightsResponse,
1530+
"acquire_rights": AcquireRightsResponse,
1531+
"update_rights": UpdateRightsResponse,
1532+
# TMP
1533+
"context_match": ContextMatchResponse,
1534+
"identity_match": IdentityMatchResponse,
1535+
}
1536+
1537+
schemas: dict[str, dict[str, Any]] = {}
1538+
for tool_name, response_type in _tool_to_response.items():
1539+
try:
1540+
if isinstance(response_type, type) and hasattr(response_type, "model_json_schema"):
1541+
schema = response_type.model_json_schema()
1542+
else:
1543+
adapter = TypeAdapter(response_type)
1544+
schema = adapter.json_schema()
1545+
1546+
schema.pop("title", None)
1547+
1548+
# Inline every $ref into its $defs body — same rationale as for
1549+
# inputSchema (MCP clients that don't resolve $ref see empty
1550+
# schemas). For outputSchema, anyOf at root is valid (union
1551+
# responses advertise what a tool may return), so we don't skip
1552+
# union types here.
1553+
schema = _inline_refs(schema)
1554+
1555+
schemas[tool_name] = schema
1556+
except Exception:
1557+
logger.debug(
1558+
"Pydantic output schema generation failed for %s, skipping",
1559+
tool_name,
1560+
exc_info=True,
1561+
)
1562+
1563+
return schemas
1564+
1565+
1566+
# Generate output schemas once at import time
1567+
_PYDANTIC_OUTPUT_SCHEMAS = _generate_pydantic_output_schemas()
1568+
1569+
1570+
def _apply_pydantic_output_schemas() -> None:
1571+
"""Write Pydantic-generated outputSchemas into ADCP_TOOL_DEFINITIONS."""
1572+
for tool_def in ADCP_TOOL_DEFINITIONS:
1573+
name = tool_def["name"]
1574+
if name in _PYDANTIC_OUTPUT_SCHEMAS:
1575+
tool_def["outputSchema"] = _PYDANTIC_OUTPUT_SCHEMAS[name]
1576+
1577+
1578+
_apply_pydantic_output_schemas()
1579+
1580+
13821581
def _is_sdk_base_class(cls_name: str) -> bool:
13831582
"""True when ``cls_name`` is registered in ``_HANDLER_TOOLS``.
13841583

src/adcp/server/serve.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1283,13 +1283,15 @@ def _register_handler_tools(
12831283
continue
12841284
description = tool_def.get("description", "")
12851285
input_schema = tool_def.get("inputSchema", {"type": "object", "properties": {}})
1286+
output_schema = tool_def.get("outputSchema")
12861287
caller = create_tool_caller(handler, tool_name)
12871288
_register_tool(
12881289
mcp,
12891290
tool_name,
12901291
description,
12911292
input_schema,
12921293
caller,
1294+
output_schema=output_schema,
12931295
context_factory=context_factory,
12941296
middleware=middleware_tuple,
12951297
)
@@ -1310,15 +1312,23 @@ def _register_tool(
13101312
input_schema: dict[str, Any],
13111313
caller: Callable[..., Any],
13121314
*,
1315+
output_schema: dict[str, Any] | None = None,
13131316
context_factory: ContextFactory | None = None,
13141317
middleware: tuple[SkillMiddleware, ...] = (),
13151318
) -> None:
13161319
"""Register a single ADCP tool on a FastMCP server.
13171320
13181321
Creates a Tool with a permissive arg model that accepts any fields,
1319-
then overrides the advertised schema with the Pydantic-generated one.
1320-
This ensures MCP clients see the correct schema while the handler
1322+
then overrides the advertised schemas with the Pydantic-generated ones.
1323+
This ensures MCP clients see the correct schemas while the handler
13211324
receives all parameters as a plain dict.
1325+
1326+
``output_schema``, when provided, replaces the generic
1327+
``dict[str, Any]``-derived schema that FastMCP infers from the ``fn``
1328+
return type. The per-tool Pydantic response schema is the one that
1329+
appears in ``tools/list``; the generic ``output_model`` is preserved
1330+
for the runtime ``structuredContent`` path (FastMCP validates the dict
1331+
the handler returns via the permissive model, not the spec schema).
13221332
"""
13231333
from mcp.server.fastmcp.tools import Tool
13241334
from mcp.server.fastmcp.utilities.func_metadata import ArgModelBase, FuncMetadata
@@ -1410,7 +1420,10 @@ async def _call_handler() -> Any:
14101420

14111421
# Override fn_metadata with a permissive model that passes through
14121422
# all fields as individual kwargs (instead of wrapping in a "kwargs" field).
1413-
# Keep the output_schema/output_model so structuredContent is populated.
1423+
# Use the per-tool output_schema for tools/list advertisement; keep the
1424+
# FastMCP-derived output_model (generic dict acceptor) for the runtime
1425+
# structuredContent path so FuncMetadata.convert_result can validate the
1426+
# handler's dict return without applying the stricter spec schema at call time.
14141427
class _AdcpArgs(ArgModelBase):
14151428
model_config = ConfigDict(extra="allow")
14161429

@@ -1422,9 +1435,12 @@ def model_dump_one_level(self) -> dict[str, Any]:
14221435
result.update(self.model_extra)
14231436
return result
14241437

1438+
effective_output_schema = (
1439+
output_schema if output_schema is not None else tool.fn_metadata.output_schema
1440+
)
14251441
tool.fn_metadata = FuncMetadata(
14261442
arg_model=_AdcpArgs,
1427-
output_schema=tool.fn_metadata.output_schema,
1443+
output_schema=effective_output_schema,
14281444
output_model=tool.fn_metadata.output_model,
14291445
wrap_output=False,
14301446
)

src/adcp/server/test_controller.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,10 +729,14 @@ async def comply_test_controller(**kwargs: Any) -> dict[str, Any]:
729729
)
730730
return await _handle_test_controller(store, kwargs, context=context)
731731

732+
# structured_output=True gives FastMCP a generic dict output_model so
733+
# FuncMetadata.convert_result can populate structuredContent at call time.
734+
# We'll replace output_schema below with the spec-accurate response schema.
732735
tool = Tool.from_function(
733736
comply_test_controller,
734737
name="comply_test_controller",
735738
description="Compliance test controller. Sandbox only, not for production use.",
739+
structured_output=True,
736740
)
737741

738742
# Override schema with the proper comply_test_controller inputSchema.
@@ -752,7 +756,16 @@ async def comply_test_controller(**kwargs: Any) -> dict[str, Any]:
752756
"required": ["scenario"],
753757
}
754758

755-
# Override fn_metadata with a permissive model
759+
# Look up the spec-accurate outputSchema from ADCP_TOOL_DEFINITIONS.
760+
from adcp.server.mcp_tools import ADCP_TOOL_DEFINITIONS as _TOOL_DEFS
761+
762+
_comply_def = next(
763+
(t for t in _TOOL_DEFS if t["name"] == "comply_test_controller"), {}
764+
)
765+
_comply_output_schema = _comply_def.get("outputSchema")
766+
767+
# Override fn_metadata with a permissive model; use the per-tool output_schema
768+
# for tools/list advertisement while keeping the generic output_model for runtime.
756769
class _ControllerArgs(ArgModelBase):
757770
model_config = ConfigDict(extra="allow")
758771

@@ -764,9 +777,13 @@ def model_dump_one_level(self) -> dict[str, Any]:
764777
result.update(self.model_extra)
765778
return result
766779

780+
_fallback = tool.fn_metadata.output_schema
781+
effective_output_schema = (
782+
_comply_output_schema if _comply_output_schema is not None else _fallback
783+
)
767784
tool.fn_metadata = FuncMetadata(
768785
arg_model=_ControllerArgs,
769-
output_schema=tool.fn_metadata.output_schema,
786+
output_schema=effective_output_schema,
770787
output_model=tool.fn_metadata.output_model,
771788
wrap_output=tool.fn_metadata.wrap_output,
772789
)

0 commit comments

Comments
 (0)