Skip to content

_resolve_params_pydantic_model resolves base-class annotation, breaking subclass-extended request validation #596

@bokelley

Description

@bokelley

Summary

When a seller's DecisioningPlatform subclass overrides a tool method with a more-specific request annotation, the SDK's _resolve_params_pydantic_model resolves the base class's annotation, not the subclass's. The seller's extended schema (e.g. with extra="forbid" for strict-extra rejection) is silently bypassed at the wire boundary.

Repro

Salesagent has its own GetProductsRequest extending the library's, with extra="forbid" in dev mode to catch unknown fields:

# src/core/schemas/product.py
from adcp.types import GetProductsWholesaleRequest as LibraryGetProductsRequest

class GetProductsRequest(LibraryGetProductsRequest):
    model_config = ConfigDict(extra=get_pydantic_extra_mode())  # "forbid" in dev
    buying_mode: str | None = Field(None, ...)  # widened
    ...

The subclass platform method:

# core/platforms/mock.py
class MockSellerPlatform(DecisioningPlatform):
    async def get_products(
        self,
        req: GetProductsRequest,  # ← salesagent's strict-extra subclass
        ctx: RequestContext[Any],
    ) -> dict[str, Any]:
        return await _delegate_get_products(req, ctx)

Annotations resolve correctly via typing.get_type_hints:

hints: {'req': <class 'src.core.schemas.product.GetProductsRequest'>, ...}

But when the SDK builds the tool caller via create_tool_caller(handler, "get_products", ...), _resolve_params_pydantic_model(method) returns the library's type:

[DEBUG SDK] get_products: params_model=<class 'adcp.types.generated_poc.media_buy.get_products_request.GetProductsRequest'>

Library's GetProductsRequest has extra="allow", so calls with unknown fields slip through validation untouched. The seller's intended strict-extra contract is silently bypassed.

Impact

Any seller who extends a library request type to add stricter validation (forbid extra fields, narrow union types, custom validators) finds those rules silently bypassed at the MCP wire boundary. Tests that rely on the wire layer rejecting unknown fields (e.g. salesagent's test_unknown_field_rejected) fail with no obvious cause — the validation simply doesn't run.

Suspected cause

_resolve_params_pydantic_model likely walks the platform's base class methods (because that's how it gets the catalog of advertised tools and their canonical signatures) instead of the subclass's actual override. The base-class signature uses the library type; the subclass override uses the seller's extended type.

Suggested fix

When iterating advertised tools to build callers, look up getattr(handler, method_name) (which is the subclass-bound method) and use that method's annotations for the params model resolution — not the base class's signature. The base class's role is to advertise; the subclass's role is to type-narrow.

Reproduction repo: https://github.com/bokelley/salesagent (hits this on 4.5.0; salesagent test tests/integration/test_mcp_unknown_field_handling.py::TestMcpDevMode::test_unknown_field_rejected).

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions