Skip to content

Commit d908aaa

Browse files
bokelleyclaude
andauthored
fix(server): advertise compact output schemas so discovery is servable (#1507)
* fix(server): advertise compact output schemas so discovery is servable `examples/seller_agent.py` answered MCP discovery with a 22 MiB tools/list for 13 tools, which the AdCP default 4 MiB buyer response budget rejects outright — adcp-client's python reference seller gate executed zero tracks against it. The cause was not over-advertisement. The override filter already narrows a direct `ADCPHandler` subclass to the tools it implements, and the seller was advertising 13. It was schema shape: `_generate_pydantic_output_schemas` called `_model_to_json_schema` without a `schema_mode`, so every output schema took that function's `"inline"` default and expanded each shared subtree once per reference — while input schemas had been `"compact"` since they were introduced. `ListCreativesResponse` went out at 4.8 MB where its compact form is 25 KB, and one tool's `outputSchema` was 300x its `inputSchema`. Output schemas now honour `schema_mode`, compact by default, so both sides of a tool definition have the same shape. Clients already had to resolve local `$ref` to read an `inputSchema`; `schema_mode="inline"` still restores the reference-free shape for those that cannot. Compact stays a permissive description — it reopens `additionalProperties` wherever it drops an optional field — and the tests validate real handler output against the advertised schema to hold that. `_widen_media_buy_output_schema_for_legacy_statuses` assumed inlined union arms and would have silently stopped widening, advertising a schema that rejects a legitimate negotiated-3.0 `status: "pending_creatives"`. It now resolves local references. Alongside the fix: the server logs its tools/list size at startup and warns past half the buyer budget, naming the two levers; `examples/seller_agent.py` declares `advertised_tools` so its surface is a statement of intent rather than a byproduct of which methods exist; and the unregistered-subclass warning no longer claims the full surface is served, which is what sent this investigation after the wrong cause. examples/seller_agent.py tools/list: 23,272,699 -> 1,350,478 bytes. Refs #1505 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(server): keep compact schemas closed and keep their field inventory Both review passes landed on the same hole in `_prune_to_required_fields`: dropping an optional property flipped `additionalProperties: false` to `true`, which across the output surface opened 77 objects that had been closed — every response root, `CanonicalProduct`, and the `oneOf` arms whose mutual exclusivity partly rested on that flag. For a buyer validating `structuredContent` against the advertised schema before handing it to a model, that flag was the check catching a seller smuggling extra keys. A dropped optional now keeps its name bound to the permissive boolean schema `true` instead of being deleted. The object stays closed, and the field inventory survives — a reader still learns `errors` and `ext` exist on a task envelope, losing only their subtrees, which is the size that made compaction necessary in the first place. Reference seller tools/list: 1,350,478 -> 1,407,433 bytes, still 16.5x below the 22.2 MiB this PR started from. Also from review: - The A2A agent card built the full Pydantic schema graph per request on the pre-auth `/.well-known/agent-card.json` route and discarded it — 118 ms of event-loop time for names and descriptions. Same for two reporting capability probes. All three now pass `_include_schemas=False`. - The oversize verdict went out only as a `UserWarning`, which production routinely mutes; it is now on the log channel too, at error level when the payload is over the buyer budget. - `_widen_media_buy_output_schema_for_legacy_statuses` mutated the `$defs` entry an arm pointed at. One reference today, but nothing enforced that; it now widens a copy and repoints the arm. - `MCPToolSet` adopters never reach `create_mcp_server`, so they had no size signal at all. They do now. - `test_advertised_output_schemas_use_local_refs_like_input_schemas` passed against the very shape it guards: `"$defs" in json.dumps(...)` matched the `#/$defs/...` strings inside an inlined `discriminator.mapping`. It now asserts the arms are refs. - `test_server_dx.py` and the output-schema validation set walked union arms assuming inlining; both now deref, and the validation set covers `create_media_buy` and `control_media_buy`, the two tools with post-processed schemas. - Declaring `advertised_tools` makes a class a discovery base, which silently narrows its subclasses. Documented on the example and in handler-authoring. Refs #1505 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eb66de8 commit d908aaa

12 files changed

Lines changed: 606 additions & 52 deletions

‎CHANGELOG.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@
3030

3131
### Bug fixes
3232

33+
* **server:** MCP ``outputSchema`` now honours ``schema_mode`` instead of
34+
always inlining every ``$ref``. Inlining expands each shared subtree once
35+
per reference, so ``ListCreativesResponse`` was advertised at 4.8 MB where
36+
its compact form is 25 KB — and ``examples/seller_agent.py`` served a
37+
22 MiB ``tools/list`` for 13 tools, which a buyer applying the new 4 MiB
38+
response budget rejects outright. Output schemas are now compact by
39+
default, matching input schemas, which have shipped local ``$ref``/``$defs``
40+
since they were introduced; the reference seller's discovery payload drops
41+
to 1.29 MiB. Adopters serving clients that do not resolve references keep
42+
the old shape with ``schema_mode="inline"``. The server also logs its
43+
``tools/list`` size at startup and warns past half the buyer budget, and
44+
``examples/seller_agent.py`` declares ``advertised_tools``. Refs #1505.
45+
3346
* **signing:** The webhook receiver's `WWW-Authenticate` challenge now carries
3447
`webhook_target_uri_malformed` instead of rewriting it to
3548
`webhook_signature_invalid`. A JWKS host that fails DNS resolution in

‎docs/handler-authoring.md‎

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,49 @@ Pass ``advertise_all=True`` to ``serve()`` / ``create_mcp_server()`` /
5555
storyboards, agents that deliberately signal
5656
``not_supported`` on specific tools).
5757

58-
### MCP input-schema migration
58+
### MCP schema modes
5959

6060
The default MCP ``schema_mode="compact"`` keeps ``tools/list`` responses
61-
small by using local JSON Schema ``$ref`` entries and ``$defs``. MCP clients
62-
must resolve those references before inspecting nested input fields. If a
63-
client does not resolve local JSON Schema references, use the compatibility
64-
escape hatch when constructing the MCP server:
61+
small by using local JSON Schema ``$ref`` entries and ``$defs``. It applies
62+
to **both** ``inputSchema`` and ``outputSchema``. MCP clients must resolve
63+
those references before inspecting nested fields. If a client does not
64+
resolve local JSON Schema references, use the compatibility escape hatch when
65+
constructing the MCP server:
6566

6667
```python
6768
from adcp.server import create_mcp_server
6869

6970
mcp = create_mcp_server(MyAgent(), schema_mode="inline")
7071
```
7172

72-
``schema_mode="inline"`` restores the historical, reference-free input
73-
schema at the cost of a substantially larger ``tools/list`` response.
73+
``schema_mode="inline"`` restores the historical, reference-free schema at
74+
the cost of a substantially larger ``tools/list`` response — inlining
75+
expands every shared subtree once per reference, so a response model whose
76+
compact form is 25 KB can go out at 4.8 MB. Measure before shipping it:
77+
a buyer applying the AdCP default 4 MiB response budget rejects an oversized
78+
``tools/list`` outright, and the agent then looks unreachable rather than
79+
verbose. The SDK logs the payload size at startup and warns past half the
80+
budget.
81+
7482
``schema_mode="defs"`` is also available when a client resolves references
7583
and needs the complete, unpruned Pydantic schema graph.
7684

85+
**Keeping discovery small.** The two levers are the number of tools you
86+
advertise (declare ``advertised_tools``; avoid ``advertise_all=True`` unless
87+
you mean it) and the schema mode. Both are visible in the startup line:
88+
89+
```
90+
mcp server advertising 13 of 13 tools
91+
mcp server tools/list payload: 1351670 bytes across 13 tools
92+
```
93+
94+
**Declaring ``advertised_tools`` makes that class a discovery base.** Once
95+
declared, the framework measures *subclasses* of that class against it, so a
96+
subclass advertises only what it overrides itself — a subclass of a concrete
97+
seller that adds nothing advertises nothing. Declare the set on the class your
98+
adopters subclass, not on a leaf, and subclass ``ADCPHandler`` with your own
99+
declaration rather than extending someone else's concrete handler.
100+
77101
**Custom handler bases — declare ``advertised_tools``.** The override
78102
filter works perfectly for direct ``ADCPHandler`` subclasses and the
79103
specialized bases (``GovernanceHandler``, ``ContentStandardsHandler``,

‎examples/seller_agent.py‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from copy import deepcopy
2323
from datetime import datetime, timedelta, timezone
2424
from decimal import ROUND_DOWN, Decimal
25-
from typing import Any
25+
from typing import Any, ClassVar
2626

2727
from adcp import (
2828
ActionAvailabilityStatus,
@@ -926,6 +926,35 @@ def _narrow_metrics(metrics: dict[str, Any], requested: list[str] | None) -> dic
926926

927927

928928
class DemoSeller(ADCPHandler):
929+
#: The tools this seller actually serves. Without it, a handler that
930+
#: subclasses ``ADCPHandler`` directly inherits the full 78-tool universe
931+
#: and relies on the framework's override detection to narrow it — which
932+
#: works, but leaves the declared surface implicit and warns at startup.
933+
#: Declaring it registers the class with the tool-discovery registry, so
934+
#: ``tools/list`` is a statement of intent rather than a byproduct of
935+
#: which methods happen to exist. The framework adds the always-on
936+
#: protocol/discovery tools (``get_adcp_capabilities``) on top.
937+
#:
938+
#: One consequence worth knowing before copying this: declaring it also
939+
#: registers ``DemoSeller`` as a tool-discovery base, so a subclass of
940+
#: *this* class is measured against it and advertises only what it
941+
#: overrides itself. Subclass ``ADCPHandler`` and declare your own set
942+
#: rather than subclassing a concrete seller.
943+
advertised_tools: ClassVar[set[str]] = {
944+
"control_media_buy",
945+
"create_media_buy",
946+
"get_media_buy_delivery",
947+
"get_media_buys",
948+
"get_products",
949+
"list_creative_formats",
950+
"list_creatives",
951+
"list_products",
952+
"sync_accounts",
953+
"sync_creatives",
954+
"sync_governance",
955+
"update_media_buy",
956+
}
957+
929958
legacy_format_converter = staticmethod(_legacy_format_converter)
930959
canonical_format_legacy_resolver = staticmethod(_canonical_format_legacy_resolver)
931960

‎src/adcp/reporting/outbox/status_support.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ async def durable(self) -> bool:
144144
or projection._escalation != getattr(self.store, "escalation", None)
145145
or not projection._consumer_status_enabled
146146
or "get_reporting_status"
147-
not in {t["name"] for t in get_tools_for_handler(self.handler)}
147+
not in {t["name"] for t in get_tools_for_handler(self.handler, _include_schemas=False)}
148148
):
149149
return False
150150
return all([await self.account_ready(account_id=a) for a in sorted(set(self.account_ids))])
@@ -183,7 +183,7 @@ async def validate_status_claims(
183183
from adcp.server.mcp_tools import get_tools_for_handler
184184

185185
assert support.handler is not None
186-
mounted = {t["name"] for t in get_tools_for_handler(support.handler)}
186+
mounted = {t["name"] for t in get_tools_for_handler(support.handler, _include_schemas=False)}
187187
for field, task in block.items():
188188
if field.endswith("_task") and isinstance(task, str) and task not in mounted:
189189
raise ReportingNotificationError("reporting_capability_requires_mounted_task")

‎src/adcp/server/a2a_server.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1101,7 +1101,11 @@ def _build_agent_card(
11011101
1.0 clients see the transport they expect on
11021102
``/.well-known/agent-card.json``.
11031103
"""
1104-
tool_defs = get_tools_for_handler(handler, advertise_all=advertise_all)
1104+
# Names and descriptions only. An A2A skill carries neither schema, and
1105+
# this runs per request on the pre-auth ``/.well-known/agent-card.json``
1106+
# route — building the full Pydantic schema graph there is ~400x the work
1107+
# for a result immediately discarded.
1108+
tool_defs = get_tools_for_handler(handler, advertise_all=advertise_all, _include_schemas=False)
11051109
extra_ids = {s.id for s in extra_skills} if extra_skills else set()
11061110

11071111
skills = [

‎src/adcp/server/mcp_tools.py‎

Lines changed: 82 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,29 @@ def _widen_media_buy_output_schema_for_legacy_statuses(
183183
"""
184184
if tool_name not in {"create_media_buy", "update_media_buy"}:
185185
return
186+
defs = schema.get("$defs")
187+
defs = defs if isinstance(defs, dict) else {}
186188
variants = schema.get("anyOf") or schema.get("oneOf") or []
187189
for variant in variants:
188190
if not isinstance(variant, dict):
189191
continue
192+
# Outside ``schema_mode="inline"`` a union arm is a local reference.
193+
# Widen a *copy* under a new definition name and repoint this arm at
194+
# it: a `$defs` entry may be referenced from more than one site, and
195+
# loosening a status constraint somewhere else is not what this is
196+
# for. Today each arm's definition has exactly one reference, so the
197+
# copy is free — it is the invariant that is worth not depending on.
198+
ref = variant.get("$ref")
199+
if isinstance(ref, str) and ref.startswith("#/$defs/"):
200+
source_name = ref[len("#/$defs/") :]
201+
target = defs.get(source_name)
202+
if not isinstance(target, dict):
203+
continue
204+
widened_name = f"{source_name}LegacyStatus"
205+
widened = copy.deepcopy(target)
206+
defs[widened_name] = widened
207+
variant["$ref"] = f"#/$defs/{widened_name}"
208+
variant = widened
190209
required = variant.get("required")
191210
properties = variant.get("properties")
192211
if not isinstance(required, list) or not isinstance(properties, dict):
@@ -1762,9 +1781,11 @@ def _prune_to_required_fields(
17621781
nested objects required properties, scalar optionals, and protected
17631782
mutation-discovery fields remain. Protected fields keep their complete
17641783
referenced Pydantic model graph. When optional properties are omitted from
1765-
an object that otherwise rejects unknown fields, the compact discovery
1766-
shape is opened with ``additionalProperties: true`` so it remains a
1767-
permissive description of payloads accepted by the runtime model.
1784+
an object that otherwise rejects unknown fields, each omitted name is
1785+
re-admitted with the permissive boolean schema ``true`` rather than the
1786+
object being opened with ``additionalProperties: true`` — the compact
1787+
shape stays a permissive description of what the runtime model accepts
1788+
*and* keeps rejecting what it rejects.
17681789
17691790
The transform operates only on Pydantic's JSON Schema output; it never
17701791
substitutes hand-written tool shapes.
@@ -1833,8 +1854,28 @@ def _prune_to_required_fields(
18331854
protected_defs=_protected_defs,
18341855
)
18351856

1836-
if dropped_optional_properties and result.get("additionalProperties") is False:
1837-
result["additionalProperties"] = True
1857+
if dropped_optional_properties:
1858+
# Re-admit every dropped name with the permissive boolean schema
1859+
# ``true`` rather than deleting it. Two things follow, both of which
1860+
# the previous "open the object with additionalProperties: true"
1861+
# approach lost:
1862+
#
1863+
# * The object stays closed if it was closed. Opening it would make
1864+
# the compact shape accept keys the model rejects — on an
1865+
# outputSchema that is the only check a buyer has against a seller
1866+
# smuggling extra fields into a payload it feeds to a model.
1867+
# * The field inventory survives. A reader still learns that
1868+
# ``errors`` and ``ext`` exist on a task envelope; only their
1869+
# subtrees are gone, which is the size that made compaction
1870+
# necessary.
1871+
#
1872+
# The cost is the length of each dropped name, not its subtree.
1873+
pruned_properties = result.get("properties")
1874+
if isinstance(pruned_properties, dict) and isinstance(properties, dict):
1875+
for name in properties:
1876+
pruned_properties.setdefault(name, True)
1877+
elif result.get("additionalProperties") is False:
1878+
result["additionalProperties"] = True
18381879
return result
18391880

18401881

@@ -2174,6 +2215,8 @@ def _generate_pydantic_schemas(
21742215

21752216
def _generate_pydantic_output_schemas(
21762217
tool_names: Iterable[str] | None = None,
2218+
*,
2219+
schema_mode: SchemaMode = "compact",
21772220
) -> dict[str, dict[str, Any]]:
21782221
"""Generate JSON schemas from Pydantic response models.
21792222
@@ -2191,6 +2234,15 @@ def _generate_pydantic_output_schemas(
21912234
Unlike input schemas, root-level ``anyOf`` is allowed — discriminated
21922235
response unions are valid JSON Schema and clients that consume
21932236
``outputSchema`` already handle them.
2237+
2238+
``schema_mode`` matches the input side, and for the same reason. Inlining
2239+
every ``$ref`` expands a response model's shared subtrees once per
2240+
reference: ``ListCreativesResponse`` is 25 KB compact, 418 KB with local
2241+
``$defs``, and 4.8 MB inlined. A handful of tools in that last shape is a
2242+
multi-megabyte ``tools/list`` that a buyer with a response-size budget
2243+
rejects outright. Compact stays a permissive description — dropping an
2244+
optional nested property reopens ``additionalProperties`` — so a real
2245+
response still validates against it.
21942246
"""
21952247
try:
21962248
from adcp.types import (
@@ -2384,7 +2436,9 @@ def _generate_pydantic_output_schemas(
23842436
for tool_name, response_type in _tool_to_response.items():
23852437
if selected is not None and tool_name not in selected:
23862438
continue
2387-
schema = _model_to_json_schema(response_type, allow_root_union=True)
2439+
schema = _model_to_json_schema(
2440+
response_type, allow_root_union=True, schema_mode=schema_mode
2441+
)
23882442
if schema is None:
23892443
logger.debug(
23902444
"Pydantic output-schema generation failed for %s",
@@ -2630,7 +2684,10 @@ def get_tools_for_handler(
26302684
(default) keeps every root request field, required nested fields,
26312685
scalar optionals, and protected mutation surfaces; ``"defs"``
26322686
keeps the complete schema with local references, and ``"inline"``
2633-
preserves the historical fully expanded schema.
2687+
preserves the historical fully expanded schema. Both non-default
2688+
modes regenerate input *and* output schemas on every call with no
2689+
memoization — seconds per construction for a 13-tool handler —
2690+
where ``"compact"`` reads the memoized registry.
26342691
Version-pinned handlers continue to advertise their exact bundled
26352692
wire schemas.
26362693
@@ -2706,14 +2763,19 @@ def get_tools_for_handler(
27062763
# mutable JSON values, matching the pre-memoization behavior.
27072764
definitions = [_copy_json_without_aliases(tool) for tool in selected]
27082765
if schema_mode != "compact":
2709-
alternate_inputs = _generate_pydantic_schemas(
2710-
(definition["name"] for definition in definitions),
2711-
schema_mode=schema_mode,
2712-
)
2766+
names = [definition["name"] for definition in definitions]
2767+
alternate_inputs = _generate_pydantic_schemas(names, schema_mode=schema_mode)
2768+
# Outputs follow the same mode. They used to be inlined
2769+
# unconditionally, which is what made a 13-tool seller advertise a
2770+
# 24 MB tools/list while its input schemas were 15 KB each.
2771+
alternate_outputs = _generate_pydantic_output_schemas(names, schema_mode=schema_mode)
27132772
for definition in definitions:
27142773
alternate = alternate_inputs.get(definition["name"])
27152774
if alternate is not None:
27162775
definition["inputSchema"] = alternate
2776+
alternate_output = alternate_outputs.get(definition["name"])
2777+
if alternate_output is not None:
2778+
definition["outputSchema"] = alternate_output
27172779
for definition in definitions:
27182780
if definition["name"] == "sync_reporting_receipts":
27192781
from adcp.reporting.receipts.wire import receipt_schema
@@ -3745,6 +3807,15 @@ def __init__(
37453807
adcp_version=resolved_adcp_version,
37463808
schema_mode=schema_mode,
37473809
)
3810+
# Adopters who wire ``tool_definitions`` into their own
3811+
# ``@server.list_tools()`` never reach ``create_mcp_server``, so this
3812+
# is their only size signal.
3813+
from adcp.server.serve import warn_if_discovery_payload_is_oversized
3814+
3815+
warn_if_discovery_payload_is_oversized(
3816+
tool_defs=self._filtered_definitions,
3817+
advertise_all=advertise_all,
3818+
)
37483819
self._tools: dict[str, Callable[..., Any]] = {}
37493820

37503821
# Create tool callers only for filtered tools

0 commit comments

Comments
 (0)