diff --git a/.changeset/4399-idempotency-key-read-tool-guidance.md b/.changeset/4399-idempotency-key-read-tool-guidance.md new file mode 100644 index 0000000000..279da72b7f --- /dev/null +++ b/.changeset/4399-idempotency-key-read-tool-guidance.md @@ -0,0 +1,4 @@ +--- +--- + +docs: add Python Pydantic guidance for compliance runner idempotency_key injection on read tools (#4399) diff --git a/docs/building/by-layer/L4/build-an-agent.mdx b/docs/building/by-layer/L4/build-an-agent.mdx index 7deb3e7546..f47ba219ee 100644 --- a/docs/building/by-layer/L4/build-an-agent.mdx +++ b/docs/building/by-layer/L4/build-an-agent.mdx @@ -48,6 +48,10 @@ serve(MySeller(), name="my-seller") Response builders (`adcp.server.responses`) handle schema compliance so you don't construct raw JSON. Use them for every tool return. + +**Pydantic input models: allow unknown fields.** The compliance runner injects `idempotency_key` on all tool calls including reads. If your read tool input models use strict Pydantic validation (FastMCP default, or any model without `extra='ignore'`), add `model_config = ConfigDict(extra='ignore')` to a base model your inputs inherit from — otherwise `get_products` and similar read tools will fail validation before the handler runs. See [Known spec ambiguities](/docs/building/cross-cutting/known-ambiguities) for details. + + - [PyPI Package](https://pypi.org/project/adcp/) - [GitHub Repository](https://github.com/adcontextprotocol/adcp-client-python) diff --git a/docs/building/cross-cutting/known-ambiguities.mdx b/docs/building/cross-cutting/known-ambiguities.mdx index adbe53deca..2e742314b4 100644 --- a/docs/building/cross-cutting/known-ambiguities.mdx +++ b/docs/building/cross-cutting/known-ambiguities.mdx @@ -36,6 +36,29 @@ For the opposite direction — failures that are **not** in this list and do hav - **Resolution**: [#2607](https://github.com/adcontextprotocol/adcp/issues/2607) — the step declares `omit_idempotency_key: true`, which signals the runner to skip both its own `applyIdempotencyInvariant` and the SDK's auto-inject. The request arrives at your agent without a key, letting the vector probe the rejection path honestly. - **Workaround**: nothing required — honor the existing spec requirement (reject missing `idempotency_key` on mutating tasks with `INVALID_REQUEST` or `VALIDATION_ERROR`). +### Compliance runner injects `idempotency_key` on read tool calls + +- **Storyboards**: Any storyboard that calls a read tool — `get_products`, `list_accounts`, `get_adcp_capabilities`, and similar read-verb tools. +- **Gap**: the `@adcp/sdk` compliance runner injects `idempotency_key` on every tool call it dispatches, including read tools. The spec requires `idempotency_key` only on mutating tools — read tools are safe to call without one. Python sellers using FastMCP or a strict Pydantic input model receive a validation error (`TypeError: unexpected keyword argument` for function-based dispatch; `ValidationError` with `extra_forbidden` for Pydantic-model–based dispatch) before the handler runs. (The read tool JSON Schemas already set `"additionalProperties": true`; the strict behavior is at the MCP wrapper layer, not the schema.) +- **Resolution**: [#4399](https://github.com/adcontextprotocol/adcp/issues/4399) — normative guidance that sellers MUST use permissive MCP parameter handling is a 3.1.x item. +- **Workaround (Python/FastMCP)**: Add `ConfigDict(extra='ignore')` to a base request model that all read tool input models inherit from: + + ```python + from typing import Optional + from pydantic import BaseModel, ConfigDict + + class AdcpReadRequest(BaseModel): + model_config = ConfigDict(extra='ignore') + + class GetProductsRequest(AdcpReadRequest): + brief: Optional[str] = None + # ... other fields + ``` + + If you use per-function handler signatures without a base model, add `idempotency_key: Optional[str] = None` as an accept-and-ignore parameter on each read tool instead. + +- **Workaround (JS/TS)**: No action needed — the `@adcp/sdk` runner uses open parameter dispatch. Go sellers using default `encoding/json` struct unmarshaling are also unaffected; if your struct decoder uses `DisallowUnknownFields`, apply the same accept-and-ignore pattern. + ### Response schema fields asserted by storyboards - **Storyboards**: `sales_catalog_driven` (catalog counts), `creative_ad_server` (pricing_options), `media_buy_seller/inventory_list_targeting` (property_list echo), `creative_ad_server` (vendor_cost required). diff --git a/docs/building/verification/get-test-ready.mdx b/docs/building/verification/get-test-ready.mdx index fdd4f2a50d..1658add03b 100644 --- a/docs/building/verification/get-test-ready.mdx +++ b/docs/building/verification/get-test-ready.mdx @@ -156,6 +156,10 @@ For custom MCP wrappers — AsyncLocalStorage for per-request auth, transport-le ## Step 4 — Run the storyboard runner + +**Python sellers: configure Pydantic to accept unknown fields before running.** The compliance runner injects `idempotency_key` on every tool call, including read tools like `get_adcp_capabilities` and `get_products`. Strict Pydantic validation (FastMCP default, or any model without `extra='ignore'`) rejects unknown fields with a validation error before your handler runs — the first storyboard tool call fails before any business logic executes. Add `model_config = ConfigDict(extra='ignore')` to a base request model that all your read tool input models inherit from. See [Known spec ambiguities](/docs/building/cross-cutting/known-ambiguities) for the full workaround and context. + + Once the three surfaces are in place, the runner takes over: ```bash