Skip to content

fix(server): compact a pinned seller's tools/list so an SDK 10 buyer can call it - #1702

Merged
bokelley merged 1 commit into
mainfrom
claude/issue-1692-tools-list-member-bound-main
Oct 29, 2026
Merged

bokelley merged 1 commit into
mainfrom
claude/issue-1692-tools-list-member-bound-main

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Backport of #1701 to main (9.x). Fixes #1692 for 9.x sellers.

Why 9.x is affected

A version-pinned MCP seller — an adcp_version pin, which every decisioning PlatformHandler carries (resolve_adcp_version(adcp_version), default 3.2) — advertises the exact bundled wire schemas in tools/list, ignoring schema_mode. get_products's response alone resolves 278 definitions, and MCP has no way to share definitions between tools, so a plain eleven-tool seller lists 106,250 JSON members (2.5 MB) on 9.3.0, measured. An SDK 10 buyer bounds a response's JSON members and its MCP client lists tools before every call, so every SDK 10 call to a 9.x decisioning seller fails with ADCPResponseTooComplexError before the handler runs. This line's own client has no member bound, so the fix here is to the listing a 9.x seller serves.

Fix (same as #1701, server side only)

Under the default schema_mode="compact", a pinned server's bundled schemas are compacted by the rules the unpinned path already applied to Pydantic output — every root field with its description and rules, every required nested field, every scalar with its constraints, each dropped optional nested field re-admitted as true — after dropping nested cross-field rules (if/then/else, not), nested x- metadata, nested $schema and definition descriptions, and advertising oneOf as anyOf so a loosened arm can never make a valid response fail the MCP client's structuredContent validation. Local $ref pointers are JSON-Pointer-unescaped (external:core~1product.json); the compact shape is memoized with the loader state. schema_mode="defs" and "inline" keep the exact bundled schema. The startup size line reports JSON members as well as bytes and warns past half of either SDK 10 buyer bound (BUYER_RESPONSE_MEMBER_BUDGET = 524_288, the figure #1701 sets; 10.0.0b0/b1 enforced 100,000).

Not ported: the client bound change (no member bound exists on 9.x) and the supported_versions→discovery-version selection (that is #1689 machinery; on 9.x supported_versions alone does not pin, and PlatformHandler pins itself).

Numbers (full tools/list JSON-RPC frame, 9.3.0)

Seller Before After
Issue repro: 11 tools, _adcp_version="3.2", supported_versions=["3.1","3.2"] 106,250 members, 2,519,482 bytes 17,181 members, 466,730 bytes
Every tool (77), advertise_all=True, pinned to the packaged 3.2 301,320 members (exact) 66,028 members, 1,833,856 bytes

Tests

Same as #1701: the all-tools guard (≤ 50% of both SDK 10 buyer bounds; pinned explicitly here, see the docstring), the end-to-end ADCPClient → create_mcp_server call over MCP, compaction soundness against real DemoSeller responses, root-contract preservation, no dangling $refs, simplifier and JSON-Pointer unit tests, the member-count startup warning, and recomputed pinned-schema digests.

🤖 Generated with Claude Code

…can call it

A version-pinned MCP seller (an adcp_version pin, which every decisioning
PlatformHandler carries) advertised the exact bundled wire schemas in
tools/list, ignoring schema_mode. get_products's response alone resolves
278 definitions, and MCP cannot share definitions between tools, so a plain
eleven-tool seller listed 106,250 JSON members (2.5 MB). An SDK 10 buyer
bounds a response's JSON members and its MCP client lists tools before
every call, so every call to such a seller failed with
ADCPResponseTooComplexError before the handler ran (#1692). This line's
client has no member bound; the fix is to the listing a 9.x seller serves.

The default schema_mode="compact" now compacts the bundled schema by the
rules the unpinned path already applied to Pydantic output (every root
field with its description and rules, every required nested field, every
scalar with its constraints, each dropped optional nested field re-admitted
as true), after dropping nested cross-field rules (if/then/else, not),
nested x- metadata, nested $schema and definition descriptions, and
advertising oneOf as anyOf so a loosened arm can never make a valid
response fail the client's output-schema check. Local $ref pointers are
JSON-Pointer-unescaped (the bundled definitions are named after paths,
"external:core~1product.json"). The compact shape is memoized with the
loader state. schema_mode="defs" and "inline" keep the exact bundled
schema. The startup size line reports JSON members as well as bytes and
warns past half of either SDK 10 buyer bound.

The issue's seller lists 17,181 members (467 KB). Every tool the SDK can
serve, pinned to the packaged release, lists 66,028 members and 1.8 MB,
under half of both bounds; a guard test pins that, and an end-to-end test
has an SDK client at defaults call get_products on an SDK seller at
defaults over MCP.

Fixes #1692

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — backport of #1701 to 9.x with no blocking findings.

Checked: this PR compacts a pinned MCP seller's tools/list under the default schema_mode="compact" (106,250→17,181 members on the #1692 repro) so an SDK 10 buyer's member bound no longer rejects every call. The load-bearing soundness invariant holds — compaction only loosens: if/not copied verbatim (mcp_tools.py:85), oneOf→anyOf, pruned optionals re-admitted as true. Covered by test_pinned_compact_output_schemas_accept_what_the_exact_schema_accepts against real DemoSeller output across 7 tools, plus root-contract preservation, no-dangling-$ref, and JSON-Pointer unescape tests. Memoization returns independent trees (schema_loader.py:523), so outputSchema.setdefault mutation is safe. E2e server has a 30s start deadline and bounded thread join.

Semver: fix(server): is correct — no public export signature changes, and the behavior change is a superset-accepting loosening (no buyer/seller deserialization break).

Decision-table walk: no critical/high/medium findings → rows 1, 4, 5, 6, 8 don't fire. gated_paths is false → row 2 doesn't fire. high_risk is true but all high-risk reasons are (modified) with zero medium findings → rows 3 and 5 don't fire. No no-auto-approve team match → row 7 doesn't fire. Falls through to row 9 = approve.

One non-blocking Low noted by the reviewer (_simplify_wire_schema can drop a oneOf when a node carries anyOf+oneOf+allOf, mcp_tools.py:278-284 — only ever loosens, soundness holds); Low findings are omitted from blocking consideration.

async def get_products(self, params, context=None):
return {"products": []}

async def create_media_buy(self, params, context=None): ...

async def create_media_buy(self, params, context=None): ...

async def update_media_buy(self, params, context=None): ...

async def update_media_buy(self, params, context=None): ...

async def get_media_buys(self, params, context=None): ...

async def get_media_buys(self, params, context=None): ...

async def get_media_buy_delivery(self, params, context=None): ...

async def get_media_buy_delivery(self, params, context=None): ...

async def sync_creatives(self, params, context=None): ...

async def sync_creatives(self, params, context=None): ...

async def list_creatives(self, params, context=None): ...

async def list_creatives(self, params, context=None): ...

async def provide_performance_feedback(self, params, context=None): ...

async def provide_performance_feedback(self, params, context=None): ...

async def list_creative_formats(self, params, context=None): ...

async def list_creative_formats(self, params, context=None): ...

async def list_accounts(self, params, context=None): ...

async def list_accounts(self, params, context=None): ...

async def sync_accounts(self, params, context=None): ...
@bokelley
bokelley merged commit cc11643 into main Oct 29, 2026
81 of 83 checks passed
@bokelley
bokelley deleted the claude/issue-1692-tools-list-member-bound-main branch October 29, 2026 11:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10.0.0b1: an SDK seller's default tools/list exceeds the SDK client's 100,000-member bound, so call_tool fails

1 participant