Repository navigation
feat(server): backport compact output schemas to 8.x so discovery is servable - #1512
Conversation
There was a problem hiding this comment.
Ladon verdict: Comment (human reviewer recommended)
Comment — 1 medium finding; wire-shape change under non-breaking prefix needs a human eye.
This is the #1507 backport to release/8.x: compact output schemas, $ref-aware media-buy widening, prune-time re-admit-as-true, and per-(kind,mode,tool) memoization. Core logic reviewed and found correct — widening is idempotent and pairs with _drop_unreachable_defs to collect the stranded narrow twin; _prune_to_required_fields keeps closed objects closed while preserving the field inventory; the memo cache is keyed correctly and returns deep copies; the alternate path is scoped to non-pinned handlers only. Tests are non-vacuous (anti-smuggling on control_media_buy, escape-hatch, dangling-ref sweep).
Medium findings
- src/adcp/server/mcp_tools.py:2239 — Advertised outputSchema wire shape changes and the ref-free-output guarantee is removed, shipping under a
fix:/patch prefix. Mitigated by the inline escape hatch and CHANGELOG prose, but a human should confirm the patch-level semver signal is intended for a wire-shape change on the public surface.
Why comment and not approve: the repo's semver-signal rule treats a response-model shape change under a non-breaking prefix as potentially a high finding (release-please cuts a minor from fix:, so a break could ship without a major). The reviewer graded this Medium because the escape hatch and CHANGELOG mitigate, but confirmation that patch is the intended semver signal warrants a human look before merge.
Why not escalate: high_risk is true only via a (modified) match on src/adcp/server/mcp_tools.py, and the single medium is not in data-loss/schema/infra category (row 4), so row 5 is the only candidate — a (modified) high-risk file WITH a medium finding does fire row 5.
Medium findings
- src/adcp/server/mcp_tools.py:2239 — Advertised outputSchema wire shape changes under fix:/patch prefix
…a claim (#1513) Follow-up to #1507, which merged before these landed. The 8.x backport's review (#1512) found three things that apply to main too. Repointing a widened union arm strands the definition it left behind, and `_drop_unreachable_defs` runs before the widener so nothing collects it. That is not just dead weight on the payload #1507 exists to shrink: the stranded twin still carries the narrow `status: {"const": "completed"}`, so a client resolving definitions by model name rather than by following `$ref` — which the dotted `$defs` names invite — picks exactly the shape the widening replaces. The widener now reports whether it fired, and unreachable definitions are collected again when it did. It also decides which arm to widen before copying, rather than cloning every arm and discarding all but one: that was shipping three `*LegacyStatus` definitions per tool with one referenced. `schema_mode="inline"` and `"defs"` regenerate the whole Pydantic graph on every call. That is the documented way to restore the pre-#1507 output shape, and it lands on per-request callers, so the remediation was also the expensive path: ~7.5s per call for a 13-tool handler. Generated schemas are now memoized per (kind, mode, tool) and deep-copied out, since a tool definition handed to a caller is an ordinary mutable value. Repeat calls: defs 7.5s -> 0.40s, inline 7.4s -> 1.05s. The anti-smuggling claim in #1507 was too strong. Where a Pydantic model sets `extra="allow"` — `Product` and most response roots — the advertised object is open in every mode, so keeping closed objects closed is schema fidelity, not a filter a buyer can lean on to catch a seller smuggling fields past it. Reworded where it was stated. Tests: the smuggling test now uses `control_media_buy`, whose success arm is both closed and drops an optional, and asserts the clean payload validates first so the unknown key is the only variable — the version in #1507 passed because its sample omitted eight required fields, and passed just as happily with the smuggled key removed. Plus coverage for the non-compact escape hatch actually reaching output schemas (deleting that wiring left 175 tests green), and a sweep for dangling `$ref`s. Refs #1507 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vable Backport of #1507 to `release/8.x`. `examples/seller_agent.py` on this line answers MCP discovery with a 22,777,757-byte (21.7 MiB) `tools/list` for 12 tools. A buyer applying the AdCP default 4 MiB response budget (adcontextprotocol/adcp#8190) rejects that outright, so the agent looks unreachable rather than verbose — which is how adcp-client's python reference seller gate executes zero tracks against it. Not over-advertisement: the override filter already narrows the handler to the 12 tools it implements. `_generate_pydantic_output_schemas` called `_model_to_json_schema` without a `schema_mode`, so every output schema took that function's own `"inline"` default and expanded each shared `$ref` once per reference, while input schemas had been `"compact"` since they were introduced. On this branch `list_creatives` went out as a 14,247-byte input next to a 4,802,383-byte output. 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. Three things the switch required rather than merely permitted: - `_widen_media_buy_output_schema_for_legacy_statuses` walks `anyOf` arms expecting inline `properties`, so against a `$ref` arm it silently no-ops — advertising a schema that rejects a legitimate negotiated-3.0 `status: "pending_creatives"`. It now resolves refs, and widens a copy under a new definition name rather than mutating a `$defs` entry nothing guarantees is unshared. Repointing the arm strands the definition it left, so unreachable definitions are collected again afterwards: the stranded twin still carries the narrow `status: {"const": "completed"}`, and a client resolving by model name rather than by `$ref` would pick exactly the shape this widening replaces. - `_prune_to_required_fields` flipped `additionalProperties: false` to `true` whenever it dropped an optional. With inline outputs that never applied to them; with compact outputs it would have advertised objects as accepting keys the runtime model rejects. A dropped optional now keeps its name bound to `true` instead, so the object stays closed and the field inventory survives. - The non-compact modes regenerate the whole Pydantic graph on every call, which on this line lands on four per-request callers including the pre-auth agent-card route. Adding outputs to that path would have taken it from ~1.9s to ~7.1s per request, and `schema_mode="inline"` is the documented way to restore the old shape — so the generated schemas are now memoized per (kind, mode, tool). Repeat calls: defs 7.5s -> 0.35s, inline 7.4s -> 0.44s. examples/seller_agent.py tools/list: 22,777,757 -> 1,251,781 bytes. The CHANGELOG records the advertised shape change for upgraders: output schemas gain local `$ref`s and boolean property schemas, and 38 nested input objects now advertise `additionalProperties: false` to match what the models already enforced. Released as a minor, not a patch. The advertised discovery contract changes for every non-pinned seller on this line: - `outputSchema` now uses local `$defs`/`$ref` and boolean property schemas (a dropped optional keeps its name bound to `true`) where it previously inlined everything. A client that parses `outputSchema` must resolve local references; one that indexes into a property as an object (`schema["properties"][name]["type"]`) raises `TypeError` on a kept name. - `inputSchema` gains `additionalProperties: false` on 38 nested objects across 11 tools, matching what the runtime models already enforced. - `tools/list` for the reference seller drops from ~21.7 MiB to ~1.19 MiB. `schema_mode="inline"` on `create_mcp_server()` / `serve()` / `MCPToolSet` restores the previous shape. This line does not ship the buyer-side response cap, so an 8.x buyer may have been parsing the old payload successfully — which is why this is a minor rather than a patch. The buyer-side response cap from #1504 is deliberately not backported; this is the seller-side half only. Refs #1507 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c0493b2 to
c0d78a1
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean backport of #1507 to release/8.x with prior feedback resolved.
The prior medium finding (outputSchema wire-shape change under a fix:/patch prefix at mcp_tools.py:2239) has been addressed: the commit now ships as feat: (cutting 8.2.0 minor) with a full CHANGELOG "Advertised schema shape" entry and a documented escape hatch (schema_mode="inline"). Response models themselves are unchanged, so the mandatory response-deserialization semver trigger does not strictly fire; the breaking-vs-minor classification is an explicit, documented maintainer call.
The three correctness-sensitive edits were verified: _widen_media_buy_output_schema_for_legacy_statuses now resolves $ref arms, clones/repoints under {name}LegacyStatus, and strands/collects the original def via _drop_unreachable_defs (mcp_tools.py:108-122); _prune_to_required_fields re-admits dropped names via setdefault keeping closed objects closed (mcp_tools.py:1873-1894); and _alternate_schemas memoization is bounded, keyed by kind/mode/name, deep-copied on return, and does not touch the version-pinned bundled-wire-schema branch. Tests are thorough (dangling-ref sweep, smuggling closure, escape-hatch wiring, real-response validation).
Decision table: no critical/high/medium findings (rows 1, 4-6, 8 do not fire); gated_paths is false (row 2 n/a); high_risk is true but the only reason is a (modified) file with no medium-or-higher finding, so rows 3 and 5 do not fire; no no-auto-approve team match (row 7 n/a). Prior decision was comment, not escalate, so row 6 is irrelevant. Falls through to row 9.
Summary
Backport of #1507 to
release/8.x.examples/seller_agent.pyon this line answers MCP discovery with a 22,777,757-byte (21.7 MiB)tools/listfor 12 tools. A buyer applying the AdCP default 4 MiB response budget (adcontextprotocol/adcp#8190) rejects that outright, so the agent looks unreachable rather than verbose — which is how adcp-client's python reference seller gate executes zero tracks against it. The "advertising 12 of 78 tools" line in the incident report is this branch.After: 1,251,781 bytes (1.19 MiB), same 12 tools.
Cause
Not over-advertisement — the override filter already narrows the handler to the 12 tools it implements.
_generate_pydantic_output_schemascalled_model_to_json_schema(response_type, allow_root_union=True)with noschema_mode, so every output schema took that function's own"inline"default and expanded each shared$refonce per reference, while input schemas had been"compact"since they were introduced.On this branch,
list_creatives: inputSchema 14,247 bytes, outputSchema 4,802,383 bytes.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$refto read aninputSchema;schema_mode="inline"still restores the reference-free shape for those that cannot.This is the one thing an 8.1.3 → 8.1.4 upgrader needs to read, so it is in the CHANGELOG as well as here.
$refsites and ~500 boolean property schemas (a dropped optional keeps its name bound totrue) where there were previously none of either. Client code written against the fully inlined shape may need updating:schema["properties"][name]["type"]raisesTypeError: 'bool' object is not subscriptableon a kept name, and a client that does not resolve local references cannot read the schema at all.additionalProperties: falsewhere 8.1.3 advertisedtrue. That tracks what the runtime models already enforced (extra="forbid") — server behaviour is unchanged — but a client that pre-validates its own request against the advertised schema and sends a vendor extension on, say,BusinessEntitynow fails locally where 8.1.3 let it through and the server rejected it.schema_mode="inline"oncreate_mcp_server()/serve()/MCPToolSetrestores the previous shape.Release class: minor (8.2.0)
Filed as
feat:rather thanfix:on a maintainer decision. The advertised discovery contract changes for every non-pinned seller on this line, and 8.x does not ship the buyer-side response cap — so an 8.x buyer may have been parsing the 21.7 MiB payload successfully, and is exactly who the shape change affects.Changelog-facing summary:
outputSchemanow uses local$defs/$refand boolean property schemas (a dropped optional keeps its name bound totrue) where it previously inlined everything. A client that parsesoutputSchemamust resolve local references. One that indexes into a property as an object —schema["properties"][name]["type"]— raisesTypeErroron a kept name.inputSchemagainsadditionalProperties: falseon 38 nested objects across 11 tools, matching what the runtime models already enforced.tools/listfor the reference seller drops from ~21.7 MiB to ~1.19 MiB.schema_mode="inline"oncreate_mcp_server()/serve()/MCPToolSetrestores the previous shape.Scope
Per the maintainer: the schema fix plus its size test. The buyer-side response cap from #1504 is not backported.
Three things came along because the switch requires them, not as scope creep:
_widen_media_buy_output_schema_for_legacy_statuseswalksanyOfarms expecting inlineproperties, so against a$refarm it silently no-ops — advertising a schema that rejects a legitimate negotiated-3.0status: "pending_creatives". It now resolves refs and widens a copy, then collects the definition the repointed arm stranded (the stranded twin still carries the narrowconst: "completed")._prune_to_required_fieldsflippedadditionalProperties: false → truewhenever it dropped an optional. Harmless while outputs were inline; with compact outputs it would have advertised objects as accepting keys the runtime model rejects. A dropped optional now keeps its name bound totrueinstead.get_tools_for_handlercallers including the pre-auth agent-card route. Adding outputs there would have taken it from ~1.9s to ~7.1s per request — andschema_mode="inline"is the documented remediation for the shape change, so the remediation was the DoS path. Schemas are now memoized per (kind, mode, tool): repeat calls drop to 0.35s (defs) and 0.44s (inline).Deliberately not carried from #1507: the startup payload-size warning, the
advertised_toolsdeclaration on DemoSeller, the A2A agent-card_include_schemas=Falsefix (the default path got ~18x cheaper on its own), the reworded unregistered-subclass warning, and the docs prose.Review
A code-reviewer and a security-reviewer pass ran against the first commit. Both confirmed the port is mechanically faithful —
_prune_to_required_fieldsand_model_to_json_schemaare byte-identical to main after it — and that nothing narrows: a differential over all 78 compact output schemas against theirdefs-mode counterparts found zero nodes where a closed object lost a key or a payload the runtime model accepts would be rejected.What they caught, and what changed:
control_media_buy, whose arm is genuinely closed and genuinely drops an optional; asserts the clean payload validates firstProductand most response roots areextra="allow", open in every mode_prune_to_required_fieldsdocstring described the behaviour the port replaced$defssurvive the widening, and the orphan is stricter than the live armschema_modestill documented as "input discovery shape"; non-default cost undocumentedtest_non_default_schema_modes_reach_output_schemas_too$refstest_advertised_schemas_have_no_dangling_referencesAccepted with reason: compact
$defskeys publish generated-model names pre-auth — the already-shipped compactinputSchemapublishes 18+ of exactly that form, so nothing here is worse than 8.1.3 already serves.Tests
tests/test_discovery_payload_size.py(new, 13 tests) plus ref-aware updates to three existing files. Reverting the outputschema_modedefault to"inline"fails 4; reverting the closure fix fails the smuggling test; deleting the escape-hatch wiring fails its own test — all verified.🤖 Generated with Claude Code