Skip to content

feat: universal request normalization for AdCP backward compatibility#1175

Merged
ChrisHuie merged 46 commits intoprebid:mainfrom
KonstantinMirin:research/additionalProperties-backward-compat
Apr 9, 2026
Merged

feat: universal request normalization for AdCP backward compatibility#1175
ChrisHuie merged 46 commits intoprebid:mainfrom
KonstantinMirin:research/additionalProperties-backward-compat

Conversation

@KonstantinMirin
Copy link
Copy Markdown
Collaborator

@KonstantinMirin KonstantinMirin commented Mar 30, 2026

Summary

Adds a universal request normalization layer and real end-to-end transport testing infrastructure. What started as fixing brand_manifest rejection (#1151) evolved into a comprehensive overhaul of how the seller agent handles schema evolution, validates input, and tests across transports.

Closes #1151, #1180. Supersedes #1161.

The Problem

Three problems, one root cause — the AdCP spec evolves faster than implementations can track:

  1. MCP hard-rejected deprecated fields (brand_manifestValidationError) while A2A and REST silently dropped them. Publishers blocked.
  2. FastMCP's TypeAdapter validates before our Pydantic models — a second validation gate we don't control, creating inconsistent behavior across transports.
  3. Test infrastructure bypassed real transport pathscall_mcp() called tool functions directly with a mock Context, never exercising middleware, TypeAdapter, or auth. Bugs were invisible.

What This PR Does

1. Universal Request Normalization

Single normalize_request_params() function shared by all three transports. Translates deprecated AdCP fields:

Deprecated Current Scope Translation
brand_manifest brand get_products, create_media_buy Extract domain from URL → {domain: hostname}
campaign_ref buyer_campaign_ref create_media_buy only Rename
account_id account All tools Wrap: "x"{account_id: "x"}
optimization_goal optimization_goals Package-level Scalar → array
catalog catalogs Package-level Scalar → array
promoted_offerings catalogs All tools v2.5 rename

Integrated via:

  • MCP: RequestCompatMiddleware (FastMCP on_call_tool — official API, no monkey-patching)
  • A2A: Called in _handle_explicit_skill() dispatcher
  • REST: RestCompatMiddleware (Starlette middleware normalizes JSON body)

2. Schema-Aware Deep-Strip Middleware (Production Forward-Compat)

The middleware has a three-stage pipeline, environment-gated:

Stage Dev Mode Production Mode
1. Translate deprecated fields ✅ Always ✅ Always
2. Strip unknown top-level fields ❌ Fail loudly (schema drift detection) ✅ Strip with WARNING
3. Deep-strip + retry on TypeAdapter rejection ❌ Fail loudly ✅ Recursive strip → retry

Stage 3: Deep-strip is the key innovation. When TypeAdapter rejects arguments (e.g., buyer sends a future field inside a nested object with additionalProperties: false), the middleware:

  1. Gets the tool's full JSON Schema via tool.parameters
  2. Recursively walks the value alongside the schema tree
  3. At each object level with additionalProperties: false, strips unknown properties
  4. Handles anyOf/oneOf unions by scoring variants on declared-property matches
  5. Handles allOf by merging properties from all members
  6. Retries with cleaned arguments — TypeAdapter accepts, Pydantic models validate

This is schema-independent from AdCP — strips against what our tool actually accepts, not what the external spec says. If stripping didn't change anything (e.g., type mismatch, not unknown fields), the original error propagates — no infinite retry loop.

Architecture principle: TypeAdapter is the gate we bypass. Everything after it (Pydantic model validation, _impl business logic) is intentional validation whose errors must reach the buyer. The middleware never swallows business logic errors.

3. Real End-to-End Transport Testing

Before: call_mcp() called tool functions directly with a mock Context. No middleware, no TypeAdapter, no auth chain. Bugs invisible.

After: Every transport exercises the real pipeline:

Transport Path Auth
MCP Client(mcp) in-memory → middleware chain → TypeAdapter → tool fn Real: get_http_headers → token → DB lookup
A2A AdCPRequestHandler.on_message_send() → skill routing → normalization → model_validate → handler Real: _resolve_a2a_identity (single mock point)
REST Starlette TestClient → middleware → FastAPI route → Pydantic body Real: dependency injection auth
impl Direct _impl() call Identity object

4. Forward-Compatibility Test Suite (100+ tests)

Comprehensive test coverage for the deep-strip mechanism, derived using systematic three-pass methodology:

test_deep_strip.py (56 tests):

  • P1-P8: Postcondition scenarios for each stripping behavior (top-level, nested, arrays, unions, refs, open schemas)
  • INV-1: Known fields never removed
  • INV-2: Stripping is idempotent
  • INV-3: Data preservation — known field VALUES are bit-identical after strip (strings, unicode, ints, floats, bools, None, empty strings)
  • Adversarial: mixed additionalProperties in anyOf, allOf handling, oneOf discriminated unions, 10-level nesting

test_forward_compat_acceptance.py (36 tests):

  • MCP pipeline: 9 payload shapes accepted in production (current spec, future fields, v2.5 legacy, multi-level extras)
  • Dev mode: 3 tests verify unknown fields rejected loudly
  • Deep-strip retry E2E: nested extras → TypeAdapter rejects → strip → retry → succeeds
  • Data preservation E2E: intercept _impl to verify tool receives buyer's data intact after strip
  • Error propagation: 5 adversarial tests — business logic errors, AdCPError, RuntimeError all propagate (never retried)
  • Middleware adversarial: schema lookup failure → graceful degradation, concurrent calls → no interference, retry error propagates (not stale original)

test_mcp_compat_middleware.py (10 tests): _should_retry distinguishes TypeAdapter from business logic errors

test_mcp_client_dispatch.py (3 tests): Client(mcp) pipeline baseline

5. Review Fixes

  • get_media_buys account_id bug: A2A handler bypassed model_validate(), using parameters.get("account_id") which returned None after normalization. Fixed: handler uses model_validate(), GetMediaBuysRequest gains account: AccountReference | None field. MCP/raw wrappers updated to account: dict | None.
  • Dead except PermissionError: Removed from A2A skill dispatch — AdCPAuthorizationError (subclass of AdCPError) already caught above.
  • tests/harness/ in tox: Added to unit env so harness meta-tests and dispatch tests run in CI.
  • Defensive json.dumps: error.details serialization wrapped in try/except to prevent crashes on non-serializable values.
  • ToolError branch dropped: _should_retry only catches pydantic.ValidationError with title.startswith("call["). TypeAdapter never raises ToolError.
  • Redundant schema alignment test deleted: Tested spec against library (circular — library is generated from spec). Broke whenever spec site updated ahead of library release.

6. Transport-Agnostic Error Assertions

Added assert_rejected(result, field=..., reason=...) helper that checks observable behavior: "was this request rejected because field X had problem Y?" Works across all transports without knowing which layer caught the error.

7. Additional Fixes

  • BDD empty-step guard: Extended to catch empty @given/@when steps (previously only @then)
  • CreativeAssetFactory: Pydantic schema factory for valid CreativeAsset objects
  • ToolError details preservation: Error details serialized as JSON 4th arg in ToolError, parsed back by unwrapper
  • MCP tool signature fixes: sync_accounts, list_authorized_properties migrated from req: to individual typed params

Known Limitations

  • 5 BDD tests xfailed (refactor: move billing policy and approval mode from identity to tenant configuration #1184): supported_billing and account_approval_mode are stored on identity by test harness but don't exist in DB. Real MCP auth resolves identity from DB → policies missing → billing/approval not enforced. Needs DB migration.
  • Nested forward-compat is MCP-only: adcp library types (BrandReference, AccountReference) use extra='forbid' hardcoded. Deep-strip handles this on MCP. A2A/REST rely on production extra='ignore' for top-level fields, but nested library types always reject unknowns. Upstream fix: adcp library could use extra='ignore' on these types.

Test Results

unit:        4222 passed, 0 failed
harness:      109 passed, 0 failed (deep-strip, forward-compat, dispatch)
admin:          10 passed, 0 failed
e2e:            93 passed, 0 failed
integration: 1848 passed, 0 failed
bdd:         1068 passed, 0 failed, 5 xfailed (#1184)

Follow-up Issues

@ChrisHuie ChrisHuie self-requested a review March 30, 2026 13:21
TDD RED phase: 22 test cases defining the contract for the request
normalization layer. All tests fail with ImportError because
src/core/request_compat does not exist yet.

Covers: 6 field translations (brand_manifest, campaign_ref, account_id,
optimization_goal, catalog, promoted_offerings), version inference,
precedence rules, and edge cases.
…lesagent-jnry)

Translates deprecated AdCP field names to current equivalents before
validation, mirroring the JS adcp-client's normalizeRequestParams().

Handles 6 deprecated fields: brand_manifest→brand, campaign_ref→
buyer_campaign_ref, account_id→account, optimization_goal→
optimization_goals, catalog→catalogs, promoted_offerings→catalogs.

Includes version inference from field names and precedence rules
(current field always wins over deprecated).
…-iexm)

FastMCP Middleware that normalizes deprecated field names in tool
arguments before TypeAdapter validates. Uses the official on_call_tool
hook — no monkey-patching.

Includes 5 unit tests covering normalizer delegation, context
replacement, passthrough, and edge cases.
Registers the backward-compat normalization middleware after auth
middleware. Auth resolves first, then deprecated fields are translated
before FastMCP's TypeAdapter validates tool parameters.
…7s2)

Normalizes deprecated fields in _handle_explicit_skill() before any
individual skill handler sees the parameters. Single integration
point covers all A2A skills.
…ent-lvzt)

Starlette middleware intercepts POST /api/v1/* requests and normalizes
deprecated field names in the JSON body before FastAPI's Pydantic model
parsing. Maps URL paths to tool names for targeted normalization.
…(salesagent-a1go)

4 BDD scenarios verifying deprecated field translation across all
transports: brand_manifest→brand, campaign_ref→buyer_campaign_ref,
account_id→account, and current-field-precedence.
…agent-3ydk)

brand_manifest is now translated to brand via the universal request
normalization layer. Updated test_get_products_brand_manifest to assert
success (translation works) instead of rejection (old behavior).
@KonstantinMirin KonstantinMirin force-pushed the research/additionalProperties-backward-compat branch from ad37987 to 18db9a3 Compare April 1, 2026 21:33
The guard previously only caught empty @then steps. Empty @given/@when
steps slip through — promising data setup or actions but doing nothing.

Extended to scan all three decorator types. Allowlisted 2 pre-existing
empty Given steps from prebid#1170 with FIXME tracking.
@KonstantinMirin
Copy link
Copy Markdown
Collaborator Author

Note on guard strengthening: While rebasing onto main, the BDD empty-step guard (which this PR extends to cover @given/@when steps, not just @then) caught two empty Given step bodies that slipped in from #1170:

  • admin_accounts.py:given_tenant_exists — empty body
  • uc002_create_media_buy.py:given_account_not_exists — empty body

These are allowlisted for now. Filed #1181 to fix them separately (different branch/scope).

TDD RED: 5 test cases for the unknown-field stripping function.
Tests fail with ImportError — implementation in next commit.
… (salesagent-3t9f)

Pure function that removes fields not in a known-params set. Returns
the cleaned dict and a sorted list of stripped field names. Used by
the middleware to pre-filter unknown fields before FastMCP's TypeAdapter.
…agent-kr54)

Replaces mock Context → direct wrapper calls with Client(mcp) in-memory
transport that exercises the full FastMCP pipeline: middleware chain →
TypeAdapter → tool function.

- Added _run_mcp_client() to BaseTestEnv using FastMCPTransport
- Migrated 6 existing call_mcp() envs to use real pipeline
- Added ProductEnv.call_mcp() (was missing)
- Identity injected via patched resolve_identity_from_context
- COMPAT BDD scenarios now use ProductEnv with real DB
- Kept _run_mcp_wrapper as deprecated legacy for unit-mode envs
The list_accounts MCP tool function did not declare pagination, status,
or sandbox parameters in its signature. When BDD tests send these via
the reworked McpDispatcher (which uses real FastMCP TypeAdapter),
validation correctly rejected them as unexpected keyword arguments.

Add explicit parameters matching ListAccountsRequest fields so FastMCP
TypeAdapter accepts them. Assemble into ListAccountsRequest inside the
wrapper before calling _list_accounts_impl.

Fixes: salesagent-g3we
…esagent-xd73)

Evolves the middleware to a two-stage pipeline:
1. Translate deprecated fields (existing normalizer)
2. Strip unknown fields using tool's JSON Schema from get_tool()

Unknown fields are logged at WARNING level and removed before
FastMCP's TypeAdapter validates. Pydantic models remain the sole
real validation gate.

Includes 3 integration tests using real Client(mcp) pipeline via
ProductEnv.call_mcp() with real DB.
Add REST_ENDPOINT, build_rest_body, and parse_rest_response to ProductEnv
so BDD [rest] COMPAT scenarios can exercise the REST middleware path for
product discovery. Follows the same pattern as CreativeFormatsEnv and
DeliveryPollEnv.
…gent-u9gk)

Replaces direct _raw() calls with dispatch through the real A2A handler
pipeline: message parsing → skill routing → normalize_request_params →
handler dispatch → _serialize_for_a2a → Task/Artifact framing.

- Added _run_a2a_handler() to BaseTestEnv with ServerError→AdCPError unwrapping
- Migrated 6 env call_a2a() methods to use real handler
- Added ProductEnv.call_a2a()
- Identity injected via handler._resolve_a2a_identity (single mock point)
- Fixed legacy _run_mcp_wrapper to handle req unpacking consistently
…sagent-33a8)

identity_for() now resolves the real access_token from the session-bound
Principal in integration mode. _run_mcp_client patches get_http_headers
with these real credentials so the full auth chain runs: header extraction
→ tenant detection → token-to-principal DB lookup → ResolvedIdentity.

Unit mode falls back to patching resolve_identity_from_context directly.
Remove debug print statements left by executor. Replace
get_db_session() in _ensure_tenant_for_audit with self._session
(env-managed session, not production session factory).
With real MCP auth chain (salesagent-33a8), identity.tenant is resolved
from DB, not from test overrides. Tests that mutate identity.tenant after
setup must also update the DB tenant so the real auth chain sees the
correct values.
… production (salesagent-rxrf)

In production mode, if FastMCP's TypeAdapter rejects tool arguments
with a structural validation error, erase complex types to raw dicts
via JSON round-trip and retry. Our Pydantic models (extra='ignore')
become the sole validation gate — matching A2A and REST behavior.

Dev mode still fails loudly for schema drift detection.
Unknown field stripping and TypeAdapter retry are now production-only.
Dev mode fails loudly on unknown fields and type mismatches — this is
how we detect fields the seller agent doesn't support.

Tests are now environment-aware: dev mode asserts rejection, production
mode asserts acceptance.
…apping

MCP Client wraps tool exceptions in ToolError. Added _unwrap_mcp_tool_error
to reconstruct AdCPError subclasses from ToolError.args so tests can assert
on domain exception types (AdCPNotFoundError, etc.).

Extracted shared _adcp_error_from_code helper used by both MCP and A2A
unwrappers — DRY for the error_code → exception class mapping.
…al MCP params

Both MCP tools used the req: RequestModel pattern which doesn't work
with FastMCP — TypeAdapter sees a single 'req' parameter while buyers
send flat fields like {accounts: [...], delete_missing: true}.

Migrated to individual typed parameters from the adcp library, matching
the pattern used by get_products, create_media_buy, list_accounts, etc.
Each param maps to an AdCP schema field. The function constructs the
request model internally.

sync_accounts: accounts: list[Account], delete_missing, dry_run, context
list_authorized_properties: publisher_domains, property_tags, context
…lesagent-as0m)

Created CreativeAssetFactory (Pydantic) that produces valid CreativeAsset
objects with all required fields. Replaced 6 hand-crafted creative dicts
in test_creative_sync_transport.py with factory calls.

Fixed stale sync_accounts(req=...) call in test_account_mcp_context_bypass.

Filed salesagent-8ij2 for full _impl signature tightening (35 files).
@@ -0,0 +1,107 @@
"""Regression test: MCP dispatch via Client(mcp) exercises the full pipeline.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

think you need to include the path tests/harness/ to the tox.ini test envs for this file to run.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 4b8b0db — added tests/harness/ to the tox unit env. That also picks up the 83 existing harness meta-tests that weren't being collected. Verified with tox -e unit: 4265 passed.

@ChrisHuie
Copy link
Copy Markdown
Contributor

Can you add some extra tests for the middleware.

  • call_next raises ValidationError in production
  • _should_retry returns False in development
  • _get_known_params returns correct tool schemas
  • non-retry exceptions propagate

Check ValidationError.title for the "call[" prefix that distinguishes
TypeAdapter structural errors from business logic model validation errors.
Previously all Pydantic ValidationErrors triggered retry, swallowing
errors from _impl model construction.
Replace manual ResolvedIdentity construction with factory method,
using single source of truth for test identity creation.
- Remove dead except PermissionError handler in adcp_a2a_server.py
  skill dispatch. Authorization errors are raised as AdCPAuthorizationError
  and already caught by the AdCPError handler above. Python's PermissionError
  is never raised from skill handlers in src/.

- Add tests/harness/ to tox unit env so tests/harness/test_mcp_client_dispatch.py
  (and the 83 existing harness meta-tests) actually run in CI. Previously the
  harness directory was not collected by any tox env.

Both comments from ChrisHuie on PR prebid#1175.
@ChrisHuie
Copy link
Copy Markdown
Contributor

ChrisHuie commented Apr 8, 2026

Unscoped account_id normalization could break get_media_buys

Files:

  • src/core/request_compat.py:111-115 — the unscoped deletion
  • src/a2a_server/adcp_a2a_server.py:1330 — normalization call site
  • src/a2a_server/adcp_a2a_server.py:1924 — broken consumer
  • src/core/mcp_compat_middleware.py:51 — MCP normalization call site

Example:

A buyer sends:
{"skill": "get_media_buys", "parameters": {"buyer_refs": ["REF-001"], "account_id": "ACC-123"}}

Here is the flow:

  1. normalize_request_params("get_media_buys", parameters) runs at adcp_a2a_server.py:1330
  2. Lines 111-115: deletes account_id and creates account: {account_id: "ACC-123"}
  3. Parameters then become: {"buyer_refs": ["REF-001"], "account": {"account_id": "ACC-123"}}
  4. Handler at line 1924: parameters.get("account_id") then returns None
  5. Then _get_media_buys_impl skips account filtering (the if req.account_id is not None check at the impl level doesn't fire)
  6. Buyer receives media buys from ALL accounts instead of just ACC-123

Only get_media_buys seems to be an issue. Other handlers either don't use account_id, or use model_validate(params) which parses the normalized account field (e.g., create_media_buy, sync_creatives)

Comment thread src/core/mcp_compat_middleware.py Outdated
Comment on lines +129 to +139
# FastMCP may wrap in its own ToolError
from fastmcp.exceptions import ToolError

if isinstance(exc, ToolError):
error_text = str(exc)
# TypeAdapter errors contain these Pydantic signatures
return "validation error" in error_text.lower() and (
"type=" in error_text or "Field required" in error_text
)

return False
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I don't think you can ever reach ToolError here for TypeAdapter errors. You should get a raw pydantic.ValidationError from FastMCP at server.py:986-988

Think this can possibly be removed since is already handled properly from ValidationError

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — TypeAdapter raises raw pydantic.ValidationError, never ToolError. The ToolError branch is dead code. Will remove as part of the retry rework (separate from this commit since the retry mechanism itself needs redesign — see discussion on the json.loads/json.dumps comment).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5f012fb. Dropped the ToolError branch entirely — _should_retry now only checks isinstance(exc, ValidationError) + exc.title.startswith("call[").

FastMCP's TypeAdapter raises raw pydantic.ValidationError (confirmed by tracing function_tool.py:244-287). ToolError comes from our tool functions, after TypeAdapter has already passed.

Comment thread src/core/mcp_compat_middleware.py Outdated
Comment on lines +89 to +104
# Erase complex types via JSON round-trip. This converts typed
# Pydantic-validated objects back to plain dicts/lists/primitives,
# so the TypeAdapter sees dict[str, Any] instead of CreativeAsset.
# Our Pydantic models inside the tool function do the real validation.
erased = json.loads(json.dumps(normalized))
logger.warning(
"TypeAdapter rejected %s — retrying with type-erased arguments (production forward-compat): %s",
tool_name,
_summarize_error(exc),
)
erased_message = CallToolRequestParams(
name=tool_name,
arguments=erased,
)
erased_context = context.copy(message=erased_message)
return await call_next(erased_context)
Copy link
Copy Markdown
Contributor

@ChrisHuie ChrisHuie Apr 8, 2026

Choose a reason for hiding this comment

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

Can you double check this? json.loads(json.dumps(normalized)) on these arguments I think just reproduces an identical dict already coming from FastMCP.

Also, if TypeAdapter rejects arguments due to a schema mismatch then just resending the same dict again would give the same rejection? The retry would only succeed if the behavior is non-deterministic but it isn't right...

It's early so may be missing something here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right — MCP arguments are already plain JSON types so the round-trip is a no-op. The retry can never change the outcome. The intent was to bypass TypeAdapter when our Pydantic models (extra='ignore') would accept the args, but the mechanism doesn't achieve that. Options under discussion: (a) bypass TypeAdapter by calling tool.fn directly on retry, (b) recursive deep-stripping of nested unknowns using the tool's JSON Schema. Both are more involved — will address in a follow-up commit.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5f012fb. Replaced the json round-trip with recursive deep stripping using the tool's JSON Schema.

The middleware now:

  1. Gets the tool's full schema via tool.parameters
  2. Walks the value alongside the schema tree
  3. At each object level with additionalProperties: false, strips unknown properties
  4. Handles anyOf/oneOf unions by picking the variant that preserves the most fields
  5. Retries with cleaned arguments — TypeAdapter accepts, Pydantic models validate

This is schema-independent from AdCP — strips against what our tool actually accepts. 40 unit tests covering postconditions, boundaries, invariants (idempotency, known-fields-never-removed), and real-world schemas.


if code is not None:
error_code = getattr(error, "error_code", None)
assert error_code == code or code in error_str, (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I assume code and error_code will be expanded in the future?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The or code in error_str fallback is intentional — transports format errors differently (MCP wraps in ToolError string tuple, A2A uses JSON-RPC, REST uses HTTP body). Stricter matching belongs in transport-specific result parsing inside TransportResult.error, not in this assertion helper. No concrete expansion planned beyond what's here — it already handles cross-transport comparison correctly.

Comment thread src/core/tool_error_logging.py Outdated
# The test harness unwrapper parses this back into a full AdCPError.
import json

details_json = json.dumps(error.details) if error.details else None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

json.dumps(error.details) in the future could crash inside exception handler if something that isn't JSON-serializable is passed like {"timestamp": datetime.now()}

Maybe this for more defensive coding:
try: details_json = json.dumps(error.details) except (TypeError, ValueError): details_json = None

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0b7a695 — wrapped in try/except (TypeError, ValueError) as suggested. Falls back to details_json = None so the original error always propagates.

Comment on lines +135 to +140
# promoted_offerings → catalogs (get_products)
if "promoted_offerings" in result:
if "catalogs" not in result:
result["catalogs"] = result["promoted_offerings"]
translations.append("promoted_offerings → catalogs")
del result["promoted_offerings"]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this meant to be scoped to only the get_products tool because right now is global since not tool scoped?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

It's intentionally global — promoted_offerings → catalogs maps the AdCP v2.5 field name to current. No tool accepts catalogs as a top-level parameter today, so it's forward-mapping for when the spec adds it. In practice, the value gets stripped by strip_unknown_params in production. Leaving as-is (documented).

Scoped campaign_ref → buyer_campaign_ref to create_media_buy only — that's the only tool with buyer_campaign_ref in its signature.

Bug fix: get_media_buys A2A handler bypassed model_validate(), using
parameters.get("account_id") which returned None after normalization
converted account_id → account. Handler now uses model_validate() and
calls _get_media_buys_impl directly. GetMediaBuysRequest gains an
account field (LibraryAccountReference) alongside legacy account_id.
Both paths reject with "not yet supported" for now.

MCP/raw wrappers updated: account_id → account in function signatures.
Normalization handles legacy callers sending flat account_id transparently.

Scoping: campaign_ref → buyer_campaign_ref now scoped to create_media_buy
only (was global). Deleted for other tools without translation.

Defensive: json.dumps(error.details) in tool_error_logging.py wrapped
in try/except to prevent crashes on non-serializable error details.

All comments from ChrisHuie on PR prebid#1175 round 2.
@KonstantinMirin
Copy link
Copy Markdown
Collaborator Author

Fixed in 0b7a695.

Root cause: _handle_get_media_buys_skill used manual parameters.get("account_id") which returned None after normalization converted it to account: {account_id: ...}. The handler bypassed model validation entirely.

Fix:

  • A2A handler now uses GetMediaBuysRequest.model_validate(params) and calls _get_media_buys_impl directly (same pattern as update_performance_index)
  • GetMediaBuysRequest gains account: LibraryAccountReference | None alongside legacy account_id
  • _impl checks both req.account and req.account_id → raises "not yet supported" for either
  • MCP/raw wrappers updated: function signature uses account: dict | None — normalization handles legacy callers transparently
  • Removed the now-unused get_media_buys_raw import from A2A server

@KonstantinMirin
Copy link
Copy Markdown
Collaborator Author

KonstantinMirin commented Apr 8, 2026

UPD: This is completed

Re: extra middleware tests request —

The retry mechanism is under redesign (see thread on mcp_compat_middleware.py:104). Will add the requested tests once the retry approach is settled:

  • `call_next` raises ValidationError in production → retry behavior
  • `_should_retry` returns False in development → already tested (test_mcp_compat_middleware.py:134)
  • `_get_known_params` returns correct tool schemas → already tested (test_mcp_compat_middleware.py integration tests)
  • non-retry exceptions propagate → already tested (test_mcp_compat_middleware.py:161)

AdCP spec at adcontextprotocol.org now includes account and
adcp_major_version fields across request schemas.

- Add account-ref example generator → {"account_id": "test-123"}
  (was falling through to empty dict {}, failing AccountReference validation)
- Add adcp_major_version to KNOWN_SCHEMA_LIBRARY_MISMATCHES
  (new spec field not yet in adcp library)
- Remove account from delivery/sync known mismatches (library now has it)
This test validated that AdCP JSON schemas from adcontextprotocol.org
are compatible with the adcp Python library's Pydantic models. Since
our models always extend the library (which is generated from the spec),
this tests the spec against itself — circular and redundant.

It also broke whenever the spec site updated fields ahead of the library
release, causing false failures unrelated to our code.

Schema compliance is already enforced by test_adcp_contract.py which
tests OUR extensions against the library types.
Replace the broken json.loads(json.dumps()) retry with recursive
deep stripping using the tool's JSON Schema. When TypeAdapter rejects
arguments in production (e.g., buyer sends a future spec field inside
a nested object with additionalProperties: false), the middleware now:

1. Gets the tool's full JSON Schema (already available via tool.parameters)
2. Recursively strips unknown properties at every nesting level
3. Retries with cleaned arguments — TypeAdapter accepts, our Pydantic
   models (extra='ignore') do the real validation

Key design decisions:
- Schema-independent from AdCP: strips against what our tool actually
  accepts, not what the external spec says
- anyOf/oneOf: picks the variant that preserves the most fields
- $ref: resolved from $defs
- additionalProperties: true (or absent) → unknowns preserved
- Stripping didn't change anything → no retry (avoids infinite loops)
- Drop ToolError branch from _should_retry — TypeAdapter raises raw
  pydantic.ValidationError, never ToolError

Comprehensive test suite (40 tests) with three-pass derived coverage:
- P1-P8: Postcondition scenarios for each stripping behavior
- Boundaries: empty dicts, only-unknowns, 3+ nesting levels
- INV-1: Known fields never removed
- INV-2: Stripping is idempotent
- Integration: real-world AdCP-like schemas (account refs, packages)
End-to-end tests verifying that various AdCP payload shapes are ACCEPTED
(not rejected at transport layer) across all three transport paths.

MCP tests (Client(mcp) pipeline — 12 tests):
- Current spec, future top-level fields, future nested fields in brand/
  context/filters, v2.5 legacy (brand_manifest, promoted_offerings),
  multi-level extras, all-unknown params
- Dev mode rejection of unknown top-level fields (3 tests)

Deep-strip retry E2E (3 tests):
- Nested brand extras → TypeAdapter rejects → deep-strip → retry succeeds
- Nested context extras → same retry path
- Type mismatch → deep-strip can't fix → no infinite loop

A2A transport (3 tests):
- normalize + strip + model_validate for deprecated field translations

Direct model acceptance (6 tests):
- Known payload shapes accepted, dev mode rejects extras

Key finding: BrandReference/AccountReference in adcp library use
extra='forbid' hardcoded — nested forward-compat is MCP-only (via
deep-strip). A2A/REST rely on production extra='ignore' for top-level
fields, but nested library types always reject unknowns.
Adds tests that try to BREAK the middleware, verifying that business
logic errors are never swallowed by the retry mechanism.

Error propagation (5 tests):
- Empty get_products → _impl rejects with clear error → propagates
- Deep-strip fixes TypeAdapter but _impl still rejects → propagates
- Business logic ValidationError (title != "call[...]") → NOT retried
- AdCPValidationError → NOT retried, propagates directly
- RuntimeError → NOT retried, propagates directly

Subtle bugs caught (2 tests):
- Retry produces different error than original → retry error propagates
  (not the stale original about already-stripped fields)
- Schema lookup fails → original error propagates, no retry attempted

Adversarial edge cases (3 tests):
- 10-level nested payload → no stack overflow, deepest level stripped
- anyOf with only null variants → value passes through unchanged
- Concurrent MCP calls → patches don't leak between requests
Three findings from adversarial opus review, all fixed:

1. anyOf scoring (MEDIUM): Score by declared-property matches, not total
   preserved keys. Prevents open variants (additionalProperties: true)
   from winning over strict variants when value matches strict's shape.

2. allOf handling (MEDIUM): Merge properties from all allOf members,
   strip against the combined set. Previously allOf passed through
   completely unstripped.

3. oneOf regression tests (LOW): Added tests for oneOf with discriminator
   patterns matching real tool schemas (SignalId). Code already handled
   oneOf but had no test coverage.

8 new tests covering all three findings.
Tests read cached schema files downloaded by the (now-deleted) schema
alignment test. These files aren't committed and don't exist in CI.

Kept the model acceptance tests (verify AccountReference construction
and status_filter union typing). Removed schema-reading tests (same
circular rationale: spec ↔ library is library testing itself).
Adds INV-3: known field VALUES must be bit-identical after deep-strip.
Stripping removes unknown fields but must never alter, truncate, coerce,
or lose data the buyer intentionally sent.

Unit tests (10 in test_deep_strip.py):
- String values with unicode, special chars, empty strings preserved
- Numeric values: int, float, zero, negative — exact type preserved
- Boolean True/False not coerced to int
- None values in known fields preserved (not treated as "empty")
- Array items: every item's known values preserved across 5 elements
- Complex nested scenario: brief + brand + packages at 3 levels, all
  known data intact, all unknowns stripped

E2E pipeline tests (3 in test_forward_compat_acceptance.py):
- Intercept _get_products_impl to capture what the tool actually receives
  after middleware strip + retry
- brand.domain preserved exactly after nested extra stripped
- context.session_id preserved (including unicode: "sess-保存-789")
- Multi-field: brief + brand + context all preserved simultaneously
  when extras exist at every level
@ChrisHuie ChrisHuie merged commit 1ad11b6 into prebid:main Apr 9, 2026
16 checks passed
Hrishi-Pubx added a commit to Pubx-ai/salesagent that referenced this pull request Apr 23, 2026
#3)

* feat: universal request normalization for AdCP backward compatibility (#1175)

* test: add unit tests for normalize_request_params (salesagent-av7n)

TDD RED phase: 22 test cases defining the contract for the request
normalization layer. All tests fail with ImportError because
src/core/request_compat does not exist yet.

Covers: 6 field translations (brand_manifest, campaign_ref, account_id,
optimization_goal, catalog, promoted_offerings), version inference,
precedence rules, and edge cases.

* feat: implement normalize_request_params for AdCP backward compat (salesagent-jnry)

Translates deprecated AdCP field names to current equivalents before
validation, mirroring the JS adcp-client's normalizeRequestParams().

Handles 6 deprecated fields: brand_manifest→brand, campaign_ref→
buyer_campaign_ref, account_id→account, optimization_goal→
optimization_goals, catalog→catalogs, promoted_offerings→catalogs.

Includes version inference from field names and precedence rules
(current field always wins over deprecated).

* feat: add RequestCompatMiddleware for MCP backward compat (salesagent-iexm)

FastMCP Middleware that normalizes deprecated field names in tool
arguments before TypeAdapter validates. Uses the official on_call_tool
hook — no monkey-patching.

Includes 5 unit tests covering normalizer delegation, context
replacement, passthrough, and edge cases.

* feat: wire RequestCompatMiddleware into MCP server (salesagent-0yxz)

Registers the backward-compat normalization middleware after auth
middleware. Auth resolves first, then deprecated fields are translated
before FastMCP's TypeAdapter validates tool parameters.

* feat: wire normalize_request_params into A2A dispatcher (salesagent-v7s2)

Normalizes deprecated fields in _handle_explicit_skill() before any
individual skill handler sees the parameters. Single integration
point covers all A2A skills.

* feat: wire normalize_request_params into REST via middleware (salesagent-lvzt)

Starlette middleware intercepts POST /api/v1/* requests and normalizes
deprecated field names in the JSON body before FastAPI's Pydantic model
parsing. Maps URL paths to tool names for targeted normalization.

* test(bdd): add COMPAT-001 feature for deprecated field normalization (salesagent-a1go)

4 BDD scenarios verifying deprecated field translation across all
transports: brand_manifest→brand, campaign_ref→buyer_campaign_ref,
account_id→account, and current-field-precedence.

* fix: update A2A brand_manifest test for normalization behavior (salesagent-3ydk)

brand_manifest is now translated to brand via the universal request
normalization layer. Updated test_get_products_brand_manifest to assert
success (translation works) instead of rejection (old behavior).

* fix: strengthen BDD empty-step guard to cover Given/When steps

The guard previously only caught empty @then steps. Empty @given/@when
steps slip through — promising data setup or actions but doing nothing.

Extended to scan all three decorator types. Allowlisted 2 pre-existing
empty Given steps from #1170 with FIXME tracking.

* test: add unit tests for strip_unknown_params (salesagent-goqw)

TDD RED: 5 test cases for the unknown-field stripping function.
Tests fail with ImportError — implementation in next commit.

* feat: implement strip_unknown_params for schema-aware field filtering (salesagent-3t9f)

Pure function that removes fields not in a known-params set. Returns
the cleaned dict and a sorted list of stripped field names. Used by
the middleware to pre-filter unknown fields before FastMCP's TypeAdapter.

* refactor: rework McpDispatcher to use FastMCP in-memory Client (salesagent-kr54)

Replaces mock Context → direct wrapper calls with Client(mcp) in-memory
transport that exercises the full FastMCP pipeline: middleware chain →
TypeAdapter → tool function.

- Added _run_mcp_client() to BaseTestEnv using FastMCPTransport
- Migrated 6 existing call_mcp() envs to use real pipeline
- Added ProductEnv.call_mcp() (was missing)
- Identity injected via patched resolve_identity_from_context
- COMPAT BDD scenarios now use ProductEnv with real DB
- Kept _run_mcp_wrapper as deprecated legacy for unit-mode envs

* fix: add pagination/status/sandbox params to list_accounts MCP signature

The list_accounts MCP tool function did not declare pagination, status,
or sandbox parameters in its signature. When BDD tests send these via
the reworked McpDispatcher (which uses real FastMCP TypeAdapter),
validation correctly rejected them as unexpected keyword arguments.

Add explicit parameters matching ListAccountsRequest fields so FastMCP
TypeAdapter accepts them. Assemble into ListAccountsRequest inside the
wrapper before calling _list_accounts_impl.

Fixes: salesagent-g3we

* feat: schema-aware RequestCompatMiddleware strips unknown fields (salesagent-xd73)

Evolves the middleware to a two-stage pipeline:
1. Translate deprecated fields (existing normalizer)
2. Strip unknown fields using tool's JSON Schema from get_tool()

Unknown fields are logged at WARNING level and removed before
FastMCP's TypeAdapter validates. Pydantic models remain the sole
real validation gate.

Includes 3 integration tests using real Client(mcp) pipeline via
ProductEnv.call_mcp() with real DB.

* refactor(harness): add REST dispatch support to ProductEnv

Add REST_ENDPOINT, build_rest_body, and parse_rest_response to ProductEnv
so BDD [rest] COMPAT scenarios can exercise the REST middleware path for
product discovery. Follows the same pattern as CreativeFormatsEnv and
DeliveryPollEnv.

* refactor: rework A2ADispatcher to use real AdCPRequestHandler (salesagent-u9gk)

Replaces direct _raw() calls with dispatch through the real A2A handler
pipeline: message parsing → skill routing → normalize_request_params →
handler dispatch → _serialize_for_a2a → Task/Artifact framing.

- Added _run_a2a_handler() to BaseTestEnv with ServerError→AdCPError unwrapping
- Migrated 6 env call_a2a() methods to use real handler
- Added ProductEnv.call_a2a()
- Identity injected via handler._resolve_a2a_identity (single mock point)
- Fixed legacy _run_mcp_wrapper to handle req unpacking consistently

* refactor: MCP dispatch uses real auth chain in integration mode (salesagent-33a8)

identity_for() now resolves the real access_token from the session-bound
Principal in integration mode. _run_mcp_client patches get_http_headers
with these real credentials so the full auth chain runs: header extraction
→ tenant detection → token-to-principal DB lookup → ResolvedIdentity.

Unit mode falls back to patching resolve_identity_from_context directly.

* chore: clean debug code and fix get_db_session in test harness

Remove debug print statements left by executor. Replace
get_db_session() in _ensure_tenant_for_audit with self._session
(env-managed session, not production session factory).

* fix: update test data for MCP real auth chain (approval_mode from DB)

With real MCP auth chain (salesagent-33a8), identity.tenant is resolved
from DB, not from test overrides. Tests that mutate identity.tenant after
setup must also update the DB tenant so the real auth chain sees the
correct values.

* feat: TypeAdapter ValidationError fallback — erase types and retry in production (salesagent-rxrf)

In production mode, if FastMCP's TypeAdapter rejects tool arguments
with a structural validation error, erase complex types to raw dicts
via JSON round-trip and retry. Our Pydantic models (extra='ignore')
become the sole validation gate — matching A2A and REST behavior.

Dev mode still fails loudly for schema drift detection.

* fix: environment-gate unknown field stripping and TypeAdapter fallback

Unknown field stripping and TypeAdapter retry are now production-only.
Dev mode fails loudly on unknown fields and type mismatches — this is
how we detect fields the seller agent doesn't support.

Tests are now environment-aware: dev mode asserts rejection, production
mode asserts acceptance.

* refactor: add ToolError unwrapper for MCP dispatch + DRY error code mapping

MCP Client wraps tool exceptions in ToolError. Added _unwrap_mcp_tool_error
to reconstruct AdCPError subclasses from ToolError.args so tests can assert
on domain exception types (AdCPNotFoundError, etc.).

Extracted shared _adcp_error_from_code helper used by both MCP and A2A
unwrappers — DRY for the error_code → exception class mapping.

* fix: migrate sync_accounts and list_authorized_properties to individual MCP params

Both MCP tools used the req: RequestModel pattern which doesn't work
with FastMCP — TypeAdapter sees a single 'req' parameter while buyers
send flat fields like {accounts: [...], delete_missing: true}.

Migrated to individual typed parameters from the adcp library, matching
the pattern used by get_products, create_media_buy, list_accounts, etc.
Each param maps to an AdCP schema field. The function constructs the
request model internally.

sync_accounts: accounts: list[Account], delete_missing, dry_run, context
list_authorized_properties: publisher_domains, property_tags, context

* fix: add CreativeAssetFactory + fix test data for MCP TypeAdapter (salesagent-as0m)

Created CreativeAssetFactory (Pydantic) that produces valid CreativeAsset
objects with all required fields. Replaced 6 hand-crafted creative dicts
in test_creative_sync_transport.py with factory calls.

Fixed stale sync_accounts(req=...) call in test_account_mcp_context_bypass.

Filed salesagent-8ij2 for full _impl signature tightening (35 files).

* feat: preserve AdCPError details across MCP ToolError round-trip (salesagent-29le)

_translate_to_tool_error now serializes error.details as JSON 4th arg
in ToolError(code, message, recovery, json_details). The MCP lowlevel
server includes it in str(exception). The test harness unwrapper parses
the 4th tuple element back to a dict and passes it to AdCPError.

This preserves error fidelity (suggestions, field paths) across the
MCP transport boundary.

* fix: fall back to identity patching when identity has custom fields

When the harness identity carries test-specific fields (supported_billing,
account_approval_mode) that don't exist in the DB, the real MCP auth
chain would lose them. Detects custom fields and falls back to identity
patching to preserve test coverage.

Filed #1184 for the architectural fix (move these to tenant config).

* Revert "fix: fall back to identity patching when identity has custom fields"

This reverts commit 7809847dd5cc52b371f2ba90f42f52038d6de432.

* feat: add assert_rejected and assert_rejected_with_suggestion helpers

Transport-agnostic rejection assertions that work across all transports
and environments. Checks error code, field, and suggestions in both
structured AdCPError details and ToolError message strings.

BDD Then steps use these helpers instead of raw isinstance checks,
hiding transport-specific error wrapping from step definitions.

* fix: use transport-agnostic assert_rejected for MCP error-path tests

Added assert_rejected() and assert_rejected_with_suggestion() helpers
that normalize rejection assertions across transports. A request can be
rejected by TypeAdapter (MCP dev mode), business logic (_impl), or
both — the assertion checks the error was communicated regardless of
which layer caught it.

Fixed 4 integration tests (creative_formats validation + creative sync
no-format) to use the new helpers instead of transport-specific
isinstance checks.

* fix: xfail 5 BDD UC-011 MCP tests pending billing policy DB migration (#1184)

These tests exercise supported_billing and account_approval_mode
enforcement which is currently injected on the identity by the test
harness. The real MCP auth chain resolves identity from DB where
these fields don't exist. #1184 tracks the migration to store
billing/approval policy on the Tenant model.

* chore: upgrade aiohttp 3.13.3→3.13.5 (10 CVEs)

* chore: add security audit to run_all_tests.sh

* fix: address code review findings (MED-01 through MED-05, 3 lows)

MED-01: Added REST integration tests (brand_manifest translation + known fields)
MED-02: parse_rest_error now uses _adcp_error_from_code for error code precision
MED-03: A2A ValueError/PermissionError translation matches MCP behavior
MED-05: _run_mcp_client asserts header patches were called (auth chain guard)

LOW-01: Fixed invalid recovery hint "contact_support" → "terminal"
LOW-02: Updated McpDispatcher docstring to reflect Client(mcp) dispatch
LOW-03: Added -> NoReturn to _translate_to_tool_error

* fix: skip global uv tool check in security audit (not project dependency)

* fix: scope _should_retry to TypeAdapter errors only

Check ValidationError.title for the "call[" prefix that distinguishes
TypeAdapter structural errors from business logic model validation errors.
Previously all Pydantic ValidationErrors triggered retry, swallowing
errors from _impl model construction.

* fix: use PrincipalFactory.make_identity in test_mcp_client_dispatch

Replace manual ResolvedIdentity construction with factory method,
using single source of truth for test identity creation.

* fix: address PR1175 review comments

- Remove dead except PermissionError handler in adcp_a2a_server.py
  skill dispatch. Authorization errors are raised as AdCPAuthorizationError
  and already caught by the AdCPError handler above. Python's PermissionError
  is never raised from skill handlers in src/.

- Add tests/harness/ to tox unit env so tests/harness/test_mcp_client_dispatch.py
  (and the 83 existing harness meta-tests) actually run in CI. Previously the
  harness directory was not collected by any tox env.

Both comments from ChrisHuie on PR #1175.

* fix: address PR1175 review round 2 (account_id bug, scoping, defensive)

Bug fix: get_media_buys A2A handler bypassed model_validate(), using
parameters.get("account_id") which returned None after normalization
converted account_id → account. Handler now uses model_validate() and
calls _get_media_buys_impl directly. GetMediaBuysRequest gains an
account field (LibraryAccountReference) alongside legacy account_id.
Both paths reject with "not yet supported" for now.

MCP/raw wrappers updated: account_id → account in function signatures.
Normalization handles legacy callers sending flat account_id transparently.

Scoping: campaign_ref → buyer_campaign_ref now scoped to create_media_buy
only (was global). Deleted for other tools without translation.

Defensive: json.dumps(error.details) in tool_error_logging.py wrapped
in try/except to prevent crashes on non-serializable error details.

All comments from ChrisHuie on PR #1175 round 2.

* fix: update schema alignment test for AdCP spec evolution

AdCP spec at adcontextprotocol.org now includes account and
adcp_major_version fields across request schemas.

- Add account-ref example generator → {"account_id": "test-123"}
  (was falling through to empty dict {}, failing AccountReference validation)
- Add adcp_major_version to KNOWN_SCHEMA_LIBRARY_MISMATCHES
  (new spec field not yet in adcp library)
- Remove account from delivery/sync known mismatches (library now has it)

* refactor: remove redundant schema alignment test

This test validated that AdCP JSON schemas from adcontextprotocol.org
are compatible with the adcp Python library's Pydantic models. Since
our models always extend the library (which is generated from the spec),
this tests the spec against itself — circular and redundant.

It also broke whenever the spec site updated fields ahead of the library
release, causing false failures unrelated to our code.

Schema compliance is already enforced by test_adcp_contract.py which
tests OUR extensions against the library types.

* feat: deep-strip unknown fields for TypeAdapter forward compatibility

Replace the broken json.loads(json.dumps()) retry with recursive
deep stripping using the tool's JSON Schema. When TypeAdapter rejects
arguments in production (e.g., buyer sends a future spec field inside
a nested object with additionalProperties: false), the middleware now:

1. Gets the tool's full JSON Schema (already available via tool.parameters)
2. Recursively strips unknown properties at every nesting level
3. Retries with cleaned arguments — TypeAdapter accepts, our Pydantic
   models (extra='ignore') do the real validation

Key design decisions:
- Schema-independent from AdCP: strips against what our tool actually
  accepts, not what the external spec says
- anyOf/oneOf: picks the variant that preserves the most fields
- $ref: resolved from $defs
- additionalProperties: true (or absent) → unknowns preserved
- Stripping didn't change anything → no retry (avoids infinite loops)
- Drop ToolError branch from _should_retry — TypeAdapter raises raw
  pydantic.ValidationError, never ToolError

Comprehensive test suite (40 tests) with three-pass derived coverage:
- P1-P8: Postcondition scenarios for each stripping behavior
- Boundaries: empty dicts, only-unknowns, 3+ nesting levels
- INV-1: Known fields never removed
- INV-2: Stripping is idempotent
- Integration: real-world AdCP-like schemas (account refs, packages)

* test: forward-compatibility acceptance tests across MCP, A2A, REST

End-to-end tests verifying that various AdCP payload shapes are ACCEPTED
(not rejected at transport layer) across all three transport paths.

MCP tests (Client(mcp) pipeline — 12 tests):
- Current spec, future top-level fields, future nested fields in brand/
  context/filters, v2.5 legacy (brand_manifest, promoted_offerings),
  multi-level extras, all-unknown params
- Dev mode rejection of unknown top-level fields (3 tests)

Deep-strip retry E2E (3 tests):
- Nested brand extras → TypeAdapter rejects → deep-strip → retry succeeds
- Nested context extras → same retry path
- Type mismatch → deep-strip can't fix → no infinite loop

A2A transport (3 tests):
- normalize + strip + model_validate for deprecated field translations

Direct model acceptance (6 tests):
- Known payload shapes accepted, dev mode rejects extras

Key finding: BrandReference/AccountReference in adcp library use
extra='forbid' hardcoded — nested forward-compat is MCP-only (via
deep-strip). A2A/REST rely on production extra='ignore' for top-level
fields, but nested library types always reject unknowns.

* test: adversarial error propagation tests for deep-strip middleware

Adds tests that try to BREAK the middleware, verifying that business
logic errors are never swallowed by the retry mechanism.

Error propagation (5 tests):
- Empty get_products → _impl rejects with clear error → propagates
- Deep-strip fixes TypeAdapter but _impl still rejects → propagates
- Business logic ValidationError (title != "call[...]") → NOT retried
- AdCPValidationError → NOT retried, propagates directly
- RuntimeError → NOT retried, propagates directly

Subtle bugs caught (2 tests):
- Retry produces different error than original → retry error propagates
  (not the stale original about already-stripped fields)
- Schema lookup fails → original error propagates, no retry attempted

Adversarial edge cases (3 tests):
- 10-level nested payload → no stack overflow, deepest level stripped
- anyOf with only null variants → value passes through unchanged
- Concurrent MCP calls → patches don't leak between requests

* fix: address adversarial review findings in deep-strip

Three findings from adversarial opus review, all fixed:

1. anyOf scoring (MEDIUM): Score by declared-property matches, not total
   preserved keys. Prevents open variants (additionalProperties: true)
   from winning over strict variants when value matches strict's shape.

2. allOf handling (MEDIUM): Merge properties from all allOf members,
   strip against the combined set. Previously allOf passed through
   completely unstripped.

3. oneOf regression tests (LOW): Added tests for oneOf with discriminator
   patterns matching real tool schemas (SignalId). Code already handled
   oneOf but had no test coverage.

8 new tests covering all three findings.

* fix: remove schema file dependencies from account mismatch test

Tests read cached schema files downloaded by the (now-deleted) schema
alignment test. These files aren't committed and don't exist in CI.

Kept the model acceptance tests (verify AccountReference construction
and status_filter union typing). Removed schema-reading tests (same
circular rationale: spec ↔ library is library testing itself).

* test: data preservation invariant — known values survive strip unchanged

Adds INV-3: known field VALUES must be bit-identical after deep-strip.
Stripping removes unknown fields but must never alter, truncate, coerce,
or lose data the buyer intentionally sent.

Unit tests (10 in test_deep_strip.py):
- String values with unicode, special chars, empty strings preserved
- Numeric values: int, float, zero, negative — exact type preserved
- Boolean True/False not coerced to int
- None values in known fields preserved (not treated as "empty")
- Array items: every item's known values preserved across 5 elements
- Complex nested scenario: brief + brand + packages at 3 levels, all
  known data intact, all unknowns stripped

E2E pipeline tests (3 in test_forward_compat_acceptance.py):
- Intercept _get_products_impl to capture what the tool actually receives
  after middleware strip + retry
- brand.domain preserved exactly after nested extra stripped
- context.session_id preserved (including unicode: "sess-保存-789")
- Multi-field: brief + brand + context all preserved simultaneously
  when extras exist at every level

* fix: ci e2e port allocation and setting | crypto package update (#1188)

* fix: bump cryptography 46.0.6 → 46.0.7 (GHSA-p423-j2cm-9vmq)


* fix: resolve E2E port allocation bug and remove stale pre-commit hook

E2E tests failed on CI because find_free_port() checked 127.0.0.1 but
Docker bound 0.0.0.0 — ports occupied on non-loopback interfaces were
invisible to the check. The 40000-50000 range also fell inside Linux's
ephemeral port range (32768-60999), increasing collision likelihood.

Fixes:
- Bind docker-compose.e2e.yml ports to 127.0.0.1 (matches check address)
- Move port scan ranges below ephemeral (20000-30000)
- Set POSTGRES_PORT in CI workflow (bypasses dynamic allocation)
- Fix worktree-add.sh bind address to match docker-compose.yml
- Remove pydantic-adcp-alignment hook (test file was deleted in f22aeab)

* chore(main): release 1.7.0 (#1147)

* chore(main): release 1.7.0

* chore: trigger CI checks for release 1.7.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: agentmoose <phoenixtechnerd@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: apply selection_type inference to inventory profile publisher_properties (#1174)

* test: add regression tests for #1162 selection_type inference on inventory profile path

TDD red phase: 6 unit tests proving Product.effective_properties returns
raw inventory profile publisher_properties without selection_type inference.
5 tests fail (KeyError: selection_type), 1 passes (passthrough when
selection_type already present). Also adds InventoryProfileFactory.

* test(bdd): add Gherkin feature for #1162 inventory profile selection_type inference

6 scenarios covering product discovery when inventory profile
publisher_properties lack the selection_type discriminator:
- by_id inference from property_ids
- by_tag inference from property_tags
- all inference when no IDs/tags
- passthrough when selection_type present
- fallback to all when property_ids invalid
- legacy field stripping

* refactor(harness): expand ProductEnv with all 4 transport dispatchers

Add call_a2a, call_mcp, build_rest_body, parse_rest_response, and
REST_ENDPOINT to ProductEnv. Override call_impl with sync wrapper
(asyncio.run) so ImplDispatcher works with the async ProductMixin.
Enables multi-transport BDD dispatch for product discovery scenarios.

* test(bdd): wire step definitions for #1162 inventory profile scenarios

6 scenarios x 4 transports = 24 test cases. 20 fail proving the bug
(Unable to extract tag using discriminator 'selection_type'), 4 pass
(passthrough when selection_type already present).

Steps use ProductEnv harness — real DB, real _get_products_impl,
real convert_product_model_to_schema, all 4 transports (IMPL/A2A/MCP/REST).

* fix: apply selection_type inference to inventory profile publisher_properties (#1162)

Extract ensure_selection_type() helper and apply it to both code paths
in Product.effective_properties. Inventory profile publisher_properties
that lack the selection_type discriminator now get it inferred:
- valid property_ids -> "by_id"
- valid property_tags -> "by_tag"
- neither -> "all"

Legacy extra fields (property_name, property_type, identifiers) are
stripped. Invalid property_ids (e.g. domain-style "weather.com") fall
back to "all".

BDD: 24/24 pass (6 scenarios x 4 transports).
Unit: 6 regression tests pass.

* fix: align ProductEnv async bridge pattern + use default tenant_id

- ProductEnv: replace get_running_loop() auto-detection with explicit
  call_impl/call_impl_async + call_a2a/call_a2a_async pairs, matching
  AccountSyncEnv pattern for project consistency
- Remove hardcoded "inv-profile-test" tenant_id from conftest.py and
  step definitions — use default "test_tenant" like all other harnesses

* fix: resolve merge conflicts and test failures after main merge

Merge conflicts (2 files):
- tests/bdd/conftest.py: kept both plugin registrations (inventory_profile
  from PR1174, compat_normalization from PR1175)
- tests/harness/product.py: took main's full-pipeline dispatchers
  (call_a2a → _run_a2a_handler, call_mcp → _run_mcp_client) + PR1174's
  async bridge for call_impl

Test fixes (3 root causes):
1. asyncio.run() in running event loop: ProductEnv.call_impl sync wrapper
   used asyncio.run() which fails inside pytest-asyncio's loop. Fixed:
   detect running loop → return coroutine for await; no loop → asyncio.run.

2. identity kwarg leak: ProductMixin.call_impl passed identity through
   **extra into GetProductsRequest constructor (extra='forbid' rejects).
   Fixed: pop identity from extra before request construction.

3. Code duplication: TestCreateGetProductsRequestWithPropertyList and
   TestCapabilitiesPropertyListFiltering duplicated between unit and
   integration test files. Removed from integration (canonical in unit).

Lint fixes: ruff format (3 files), zip strict=True, unused imports.

* chore: bump cryptography 46.0.6 → 46.0.7

* fix: bump UV_VERSION 0.9.6 → 0.11.6 in CI (GHSA-pjjw-68hj-v9mw)

GHSA-pjjw-68hj-v9mw was disclosed against uv 0.9.6 between Apr 10 and
Apr 12, 2026. uv-secure audits both uv.lock deps and the uv CLI itself
as a global tool; our lockfile is clean but the pinned CI uv version
is now flagged. Fixed in uv 0.11.6.

* fix: implement empty BDD Given step bodies (#1181) (#1185)

* test: add unit tests for normalize_request_params (salesagent-av7n)

TDD RED phase: 22 test cases defining the contract for the request
normalization layer. All tests fail with ImportError because
src/core/request_compat does not exist yet.

Covers: 6 field translations (brand_manifest, campaign_ref, account_id,
optimization_goal, catalog, promoted_offerings), version inference,
precedence rules, and edge cases.

* feat: implement normalize_request_params for AdCP backward compat (salesagent-jnry)

Translates deprecated AdCP field names to current equivalents before
validation, mirroring the JS adcp-client's normalizeRequestParams().

Handles 6 deprecated fields: brand_manifest→brand, campaign_ref→
buyer_campaign_ref, account_id→account, optimization_goal→
optimization_goals, catalog→catalogs, promoted_offerings→catalogs.

Includes version inference from field names and precedence rules
(current field always wins over deprecated).

* feat: add RequestCompatMiddleware for MCP backward compat (salesagent-iexm)

FastMCP Middleware that normalizes deprecated field names in tool
arguments before TypeAdapter validates. Uses the official on_call_tool
hook — no monkey-patching.

Includes 5 unit tests covering normalizer delegation, context
replacement, passthrough, and edge cases.

* feat: wire RequestCompatMiddleware into MCP server (salesagent-0yxz)

Registers the backward-compat normalization middleware after auth
middleware. Auth resolves first, then deprecated fields are translated
before FastMCP's TypeAdapter validates tool parameters.

* feat: wire normalize_request_params into A2A dispatcher (salesagent-v7s2)

Normalizes deprecated fields in _handle_explicit_skill() before any
individual skill handler sees the parameters. Single integration
point covers all A2A skills.

* feat: wire normalize_request_params into REST via middleware (salesagent-lvzt)

Starlette middleware intercepts POST /api/v1/* requests and normalizes
deprecated field names in the JSON body before FastAPI's Pydantic model
parsing. Maps URL paths to tool names for targeted normalization.

* test(bdd): add COMPAT-001 feature for deprecated field normalization (salesagent-a1go)

4 BDD scenarios verifying deprecated field translation across all
transports: brand_manifest→brand, campaign_ref→buyer_campaign_ref,
account_id→account, and current-field-precedence.

* fix: update A2A brand_manifest test for normalization behavior (salesagent-3ydk)

brand_manifest is now translated to brand via the universal request
normalization layer. Updated test_get_products_brand_manifest to assert
success (translation works) instead of rejection (old behavior).

* fix: strengthen BDD empty-step guard to cover Given/When steps

The guard previously only caught empty @then steps. Empty @given/@when
steps slip through — promising data setup or actions but doing nothing.

Extended to scan all three decorator types. Allowlisted 2 pre-existing
empty Given steps from #1170 with FIXME tracking.

* test: add unit tests for strip_unknown_params (salesagent-goqw)

TDD RED: 5 test cases for the unknown-field stripping function.
Tests fail with ImportError — implementation in next commit.

* feat: implement strip_unknown_params for schema-aware field filtering (salesagent-3t9f)

Pure function that removes fields not in a known-params set. Returns
the cleaned dict and a sorted list of stripped field names. Used by
the middleware to pre-filter unknown fields before FastMCP's TypeAdapter.

* refactor: rework McpDispatcher to use FastMCP in-memory Client (salesagent-kr54)

Replaces mock Context → direct wrapper calls with Client(mcp) in-memory
transport that exercises the full FastMCP pipeline: middleware chain →
TypeAdapter → tool function.

- Added _run_mcp_client() to BaseTestEnv using FastMCPTransport
- Migrated 6 existing call_mcp() envs to use real pipeline
- Added ProductEnv.call_mcp() (was missing)
- Identity injected via patched resolve_identity_from_context
- COMPAT BDD scenarios now use ProductEnv with real DB
- Kept _run_mcp_wrapper as deprecated legacy for unit-mode envs

* fix: add pagination/status/sandbox params to list_accounts MCP signature

The list_accounts MCP tool function did not declare pagination, status,
or sandbox parameters in its signature. When BDD tests send these via
the reworked McpDispatcher (which uses real FastMCP TypeAdapter),
validation correctly rejected them as unexpected keyword arguments.

Add explicit parameters matching ListAccountsRequest fields so FastMCP
TypeAdapter accepts them. Assemble into ListAccountsRequest inside the
wrapper before calling _list_accounts_impl.

Fixes: salesagent-g3we

* feat: schema-aware RequestCompatMiddleware strips unknown fields (salesagent-xd73)

Evolves the middleware to a two-stage pipeline:
1. Translate deprecated fields (existing normalizer)
2. Strip unknown fields using tool's JSON Schema from get_tool()

Unknown fields are logged at WARNING level and removed before
FastMCP's TypeAdapter validates. Pydantic models remain the sole
real validation gate.

Includes 3 integration tests using real Client(mcp) pipeline via
ProductEnv.call_mcp() with real DB.

* refactor(harness): add REST dispatch support to ProductEnv

Add REST_ENDPOINT, build_rest_body, and parse_rest_response to ProductEnv
so BDD [rest] COMPAT scenarios can exercise the REST middleware path for
product discovery. Follows the same pattern as CreativeFormatsEnv and
DeliveryPollEnv.

* refactor: rework A2ADispatcher to use real AdCPRequestHandler (salesagent-u9gk)

Replaces direct _raw() calls with dispatch through the real A2A handler
pipeline: message parsing → skill routing → normalize_request_params →
handler dispatch → _serialize_for_a2a → Task/Artifact framing.

- Added _run_a2a_handler() to BaseTestEnv with ServerError→AdCPError unwrapping
- Migrated 6 env call_a2a() methods to use real handler
- Added ProductEnv.call_a2a()
- Identity injected via handler._resolve_a2a_identity (single mock point)
- Fixed legacy _run_mcp_wrapper to handle req unpacking consistently

* refactor: MCP dispatch uses real auth chain in integration mode (salesagent-33a8)

identity_for() now resolves the real access_token from the session-bound
Principal in integration mode. _run_mcp_client patches get_http_headers
with these real credentials so the full auth chain runs: header extraction
→ tenant detection → token-to-principal DB lookup → ResolvedIdentity.

Unit mode falls back to patching resolve_identity_from_context directly.

* chore: clean debug code and fix get_db_session in test harness

Remove debug print statements left by executor. Replace
get_db_session() in _ensure_tenant_for_audit with self._session
(env-managed session, not production session factory).

* fix: update test data for MCP real auth chain (approval_mode from DB)

With real MCP auth chain (salesagent-33a8), identity.tenant is resolved
from DB, not from test overrides. Tests that mutate identity.tenant after
setup must also update the DB tenant so the real auth chain sees the
correct values.

* feat: TypeAdapter ValidationError fallback — erase types and retry in production (salesagent-rxrf)

In production mode, if FastMCP's TypeAdapter rejects tool arguments
with a structural validation error, erase complex types to raw dicts
via JSON round-trip and retry. Our Pydantic models (extra='ignore')
become the sole validation gate — matching A2A and REST behavior.

Dev mode still fails loudly for schema drift detection.

* fix: environment-gate unknown field stripping and TypeAdapter fallback

Unknown field stripping and TypeAdapter retry are now production-only.
Dev mode fails loudly on unknown fields and type mismatches — this is
how we detect fields the seller agent doesn't support.

Tests are now environment-aware: dev mode asserts rejection, production
mode asserts acceptance.

* refactor: add ToolError unwrapper for MCP dispatch + DRY error code mapping

MCP Client wraps tool exceptions in ToolError. Added _unwrap_mcp_tool_error
to reconstruct AdCPError subclasses from ToolError.args so tests can assert
on domain exception types (AdCPNotFoundError, etc.).

Extracted shared _adcp_error_from_code helper used by both MCP and A2A
unwrappers — DRY for the error_code → exception class mapping.

* fix: migrate sync_accounts and list_authorized_properties to individual MCP params

Both MCP tools used the req: RequestModel pattern which doesn't work
with FastMCP — TypeAdapter sees a single 'req' parameter while buyers
send flat fields like {accounts: [...], delete_missing: true}.

Migrated to individual typed parameters from the adcp library, matching
the pattern used by get_products, create_media_buy, list_accounts, etc.
Each param maps to an AdCP schema field. The function constructs the
request model internally.

sync_accounts: accounts: list[Account], delete_missing, dry_run, context
list_authorized_properties: publisher_domains, property_tags, context

* fix: add CreativeAssetFactory + fix test data for MCP TypeAdapter (salesagent-as0m)

Created CreativeAssetFactory (Pydantic) that produces valid CreativeAsset
objects with all required fields. Replaced 6 hand-crafted creative dicts
in test_creative_sync_transport.py with factory calls.

Fixed stale sync_accounts(req=...) call in test_account_mcp_context_bypass.

Filed salesagent-8ij2 for full _impl signature tightening (35 files).

* feat: preserve AdCPError details across MCP ToolError round-trip (salesagent-29le)

_translate_to_tool_error now serializes error.details as JSON 4th arg
in ToolError(code, message, recovery, json_details). The MCP lowlevel
server includes it in str(exception). The test harness unwrapper parses
the 4th tuple element back to a dict and passes it to AdCPError.

This preserves error fidelity (suggestions, field paths) across the
MCP transport boundary.

* fix: fall back to identity patching when identity has custom fields

When the harness identity carries test-specific fields (supported_billing,
account_approval_mode) that don't exist in the DB, the real MCP auth
chain would lose them. Detects custom fields and falls back to identity
patching to preserve test coverage.

Filed #1184 for the architectural fix (move these to tenant config).

* Revert "fix: fall back to identity patching when identity has custom fields"

This reverts commit 7809847dd5cc52b371f2ba90f42f52038d6de432.

* feat: add assert_rejected and assert_rejected_with_suggestion helpers

Transport-agnostic rejection assertions that work across all transports
and environments. Checks error code, field, and suggestions in both
structured AdCPError details and ToolError message strings.

BDD Then steps use these helpers instead of raw isinstance checks,
hiding transport-specific error wrapping from step definitions.

* fix: use transport-agnostic assert_rejected for MCP error-path tests

Added assert_rejected() and assert_rejected_with_suggestion() helpers
that normalize rejection assertions across transports. A request can be
rejected by TypeAdapter (MCP dev mode), business logic (_impl), or
both — the assertion checks the error was communicated regardless of
which layer caught it.

Fixed 4 integration tests (creative_formats validation + creative sync
no-format) to use the new helpers instead of transport-specific
isinstance checks.

* fix: xfail 5 BDD UC-011 MCP tests pending billing policy DB migration (#1184)

These tests exercise supported_billing and account_approval_mode
enforcement which is currently injected on the identity by the test
harness. The real MCP auth chain resolves identity from DB where
these fields don't exist. #1184 tracks the migration to store
billing/approval policy on the Tenant model.

* chore: upgrade aiohttp 3.13.3→3.13.5 (10 CVEs)

* chore: add security audit to run_all_tests.sh

* fix: address code review findings (MED-01 through MED-05, 3 lows)

MED-01: Added REST integration tests (brand_manifest translation + known fields)
MED-02: parse_rest_error now uses _adcp_error_from_code for error code precision
MED-03: A2A ValueError/PermissionError translation matches MCP behavior
MED-05: _run_mcp_client asserts header patches were called (auth chain guard)

LOW-01: Fixed invalid recovery hint "contact_support" → "terminal"
LOW-02: Updated McpDispatcher docstring to reflect Client(mcp) dispatch
LOW-03: Added -> NoReturn to _translate_to_tool_error

* fix: implement empty BDD Given step bodies (#1181)

given_tenant_exists: calls env._ensure_tenant_for_id() to create the
specified tenant in the DB (not just the default harness tenant).

given_account_id_not_found: calls env.call_impl(resolve_account) and
verifies AdCPAccountNotFoundError is raised — real production code path.

given_natural_key_not_found: same pattern with natural key lookup.

Emptied the _EMPTY_GIVEN_WHEN_ALLOWLIST — both steps now have real bodies.

* chore: bump cryptography 46.0.6 → 46.0.7 (GHSA-p423-j2cm-9vmq)

* chore: bump UV_VERSION 0.9.6 → 0.11.6 in CI workflow

* refactor: move billing policy and approval mode to tenant configuration (#1184) (#1186)

* test: add unit tests for normalize_request_params (salesagent-av7n)

TDD RED phase: 22 test cases defining the contract for the request
normalization layer. All tests fail with ImportError because
src/core/request_compat does not exist yet.

Covers: 6 field translations (brand_manifest, campaign_ref, account_id,
optimization_goal, catalog, promoted_offerings), version inference,
precedence rules, and edge cases.

* feat: implement normalize_request_params for AdCP backward compat (salesagent-jnry)

Translates deprecated AdCP field names to current equivalents before
validation, mirroring the JS adcp-client's normalizeRequestParams().

Handles 6 deprecated fields: brand_manifest→brand, campaign_ref→
buyer_campaign_ref, account_id→account, optimization_goal→
optimization_goals, catalog→catalogs, promoted_offerings→catalogs.

Includes version inference from field names and precedence rules
(current field always wins over deprecated).

* feat: add RequestCompatMiddleware for MCP backward compat (salesagent-iexm)

FastMCP Middleware that normalizes deprecated field names in tool
arguments before TypeAdapter validates. Uses the official on_call_tool
hook — no monkey-patching.

Includes 5 unit tests covering normalizer delegation, context
replacement, passthrough, and edge cases.

* feat: wire RequestCompatMiddleware into MCP server (salesagent-0yxz)

Registers the backward-compat normalization middleware after auth
middleware. Auth resolves first, then deprecated fields are translated
before FastMCP's TypeAdapter validates tool parameters.

* feat: wire normalize_request_params into A2A dispatcher (salesagent-v7s2)

Normalizes deprecated fields in _handle_explicit_skill() before any
individual skill handler sees the parameters. Single integration
point covers all A2A skills.

* feat: wire normalize_request_params into REST via middleware (salesagent-lvzt)

Starlette middleware intercepts POST /api/v1/* requests and normalizes
deprecated field names in the JSON body before FastAPI's Pydantic model
parsing. Maps URL paths to tool names for targeted normalization.

* test(bdd): add COMPAT-001 feature for deprecated field normalization (salesagent-a1go)

4 BDD scenarios verifying deprecated field translation across all
transports: brand_manifest→brand, campaign_ref→buyer_campaign_ref,
account_id→account, and current-field-precedence.

* fix: update A2A brand_manifest test for normalization behavior (salesagent-3ydk)

brand_manifest is now translated to brand via the universal request
normalization layer. Updated test_get_products_brand_manifest to assert
success (translation works) instead of rejection (old behavior).

* fix: strengthen BDD empty-step guard to cover Given/When steps

The guard previously only caught empty @then steps. Empty @given/@when
steps slip through — promising data setup or actions but doing nothing.

Extended to scan all three decorator types. Allowlisted 2 pre-existing
empty Given steps from #1170 with FIXME tracking.

* test: add unit tests for strip_unknown_params (salesagent-goqw)

TDD RED: 5 test cases for the unknown-field stripping function.
Tests fail with ImportError — implementation in next commit.

* feat: implement strip_unknown_params for schema-aware field filtering (salesagent-3t9f)

Pure function that removes fields not in a known-params set. Returns
the cleaned dict and a sorted list of stripped field names. Used by
the middleware to pre-filter unknown fields before FastMCP's TypeAdapter.

* refactor: rework McpDispatcher to use FastMCP in-memory Client (salesagent-kr54)

Replaces mock Context → direct wrapper calls with Client(mcp) in-memory
transport that exercises the full FastMCP pipeline: middleware chain →
TypeAdapter → tool function.

- Added _run_mcp_client() to BaseTestEnv using FastMCPTransport
- Migrated 6 existing call_mcp() envs to use real pipeline
- Added ProductEnv.call_mcp() (was missing)
- Identity injected via patched resolve_identity_from_context
- COMPAT BDD scenarios now use ProductEnv with real DB
- Kept _run_mcp_wrapper as deprecated legacy for unit-mode envs

* fix: add pagination/status/sandbox params to list_accounts MCP signature

The list_accounts MCP tool function did not declare pagination, status,
or sandbox parameters in its signature. When BDD tests send these via
the reworked McpDispatcher (which uses real FastMCP TypeAdapter),
validation correctly rejected them as unexpected keyword arguments.

Add explicit parameters matching ListAccountsRequest fields so FastMCP
TypeAdapter accepts them. Assemble into ListAccountsRequest inside the
wrapper before calling _list_accounts_impl.

Fixes: salesagent-g3we

* feat: schema-aware RequestCompatMiddleware strips unknown fields (salesagent-xd73)

Evolves the middleware to a two-stage pipeline:
1. Translate deprecated fields (existing normalizer)
2. Strip unknown fields using tool's JSON Schema from get_tool()

Unknown fields are logged at WARNING level and removed before
FastMCP's TypeAdapter validates. Pydantic models remain the sole
real validation gate.

Includes 3 integration tests using real Client(mcp) pipeline via
ProductEnv.call_mcp() with real DB.

* refactor(harness): add REST dispatch support to ProductEnv

Add REST_ENDPOINT, build_rest_body, and parse_rest_response to ProductEnv
so BDD [rest] COMPAT scenarios can exercise the REST middleware path for
product discovery. Follows the same pattern as CreativeFormatsEnv and
DeliveryPollEnv.

* refactor: rework A2ADispatcher to use real AdCPRequestHandler (salesagent-u9gk)

Replaces direct _raw() calls with dispatch through the real A2A handler
pipeline: message parsing → skill routing → normalize_request_params →
handler dispatch → _serialize_for_a2a → Task/Artifact framing.

- Added _run_a2a_handler() to BaseTestEnv with ServerError→AdCPError unwrapping
- Migrated 6 env call_a2a() methods to use real handler
- Added ProductEnv.call_a2a()
- Identity injected via handler._resolve_a2a_identity (single mock point)
- Fixed legacy _run_mcp_wrapper to handle req unpacking consistently

* refactor: MCP dispatch uses real auth chain in integration mode (salesagent-33a8)

identity_for() now resolves the real access_token from the session-bound
Principal in integration mode. _run_mcp_client patches get_http_headers
with these real credentials so the full auth chain runs: header extraction
→ tenant detection → token-to-principal DB lookup → ResolvedIdentity.

Unit mode falls back to patching resolve_identity_from_context directly.

* chore: clean debug code and fix get_db_session in test harness

Remove debug print statements left by executor. Replace
get_db_session() in _ensure_tenant_for_audit with self._session
(env-managed session, not production session factory).

* fix: update test data for MCP real auth chain (approval_mode from DB)

With real MCP auth chain (salesagent-33a8), identity.tenant is resolved
from DB, not from test overrides. Tests that mutate identity.tenant after
setup must also update the DB tenant so the real auth chain sees the
correct values.

* feat: TypeAdapter ValidationError fallback — erase types and retry in production (salesagent-rxrf)

In production mode, if FastMCP's TypeAdapter rejects tool arguments
with a structural validation error, erase complex types to raw dicts
via JSON round-trip and retry. Our Pydantic models (extra='ignore')
become the sole validation gate — matching A2A and REST behavior.

Dev mode still fails loudly for schema drift detection.

* fix: environment-gate unknown field stripping and TypeAdapter fallback

Unknown field stripping and TypeAdapter retry are now production-only.
Dev mode fails loudly on unknown fields and type mismatches — this is
how we detect fields the seller agent doesn't support.

Tests are now environment-aware: dev mode asserts rejection, production
mode asserts acceptance.

* refactor: add ToolError unwrapper for MCP dispatch + DRY error code mapping

MCP Client wraps tool exceptions in ToolError. Added _unwrap_mcp_tool_error
to reconstruct AdCPError subclasses from ToolError.args so tests can assert
on domain exception types (AdCPNotFoundError, etc.).

Extracted shared _adcp_error_from_code helper used by both MCP and A2A
unwrappers — DRY for the error_code → exception class mapping.

* fix: migrate sync_accounts and list_authorized_properties to individual MCP params

Both MCP tools used the req: RequestModel pattern which doesn't work
with FastMCP — TypeAdapter sees a single 'req' parameter while buyers
send flat fields like {accounts: [...], delete_missing: true}.

Migrated to individual typed parameters from the adcp library, matching
the pattern used by get_products, create_media_buy, list_accounts, etc.
Each param maps to an AdCP schema field. The function constructs the
request model internally.

sync_accounts: accounts: list[Account], delete_missing, dry_run, context
list_authorized_properties: publisher_domains, property_tags, context

* fix: add CreativeAssetFactory + fix test data for MCP TypeAdapter (salesagent-as0m)

Created CreativeAssetFactory (Pydantic) that produces valid CreativeAsset
objects with all required fields. Replaced 6 hand-crafted creative dicts
in test_creative_sync_transport.py with factory calls.

Fixed stale sync_accounts(req=...) call in test_account_mcp_context_bypass.

Filed salesagent-8ij2 for full _impl signature tightening (35 files).

* feat: preserve AdCPError details across MCP ToolError round-trip (salesagent-29le)

_translate_to_tool_error now serializes error.details as JSON 4th arg
in ToolError(code, message, recovery, json_details). The MCP lowlevel
server includes it in str(exception). The test harness unwrapper parses
the 4th tuple element back to a dict and passes it to AdCPError.

This preserves error fidelity (suggestions, field paths) across the
MCP transport boundary.

* fix: fall back to identity patching when identity has custom fields

When the harness identity carries test-specific fields (supported_billing,
account_approval_mode) that don't exist in the DB, the real MCP auth
chain would lose them. Detects custom fields and falls back to identity
patching to preserve test coverage.

Filed #1184 for the architectural fix (move these to tenant config).

* Revert "fix: fall back to identity patching when identity has custom fields"

This reverts commit 7809847dd5cc52b371f2ba90f42f52038d6de432.

* feat: add assert_rejected and assert_rejected_with_suggestion helpers

Transport-agnostic rejection assertions that work across all transports
and environments. Checks error code, field, and suggestions in both
structured AdCPError details and ToolError message strings.

BDD Then steps use these helpers instead of raw isinstance checks,
hiding transport-specific error wrapping from step definitions.

* fix: use transport-agnostic assert_rejected for MCP error-path tests

Added assert_rejected() and assert_rejected_with_suggestion() helpers
that normalize rejection assertions across transports. A request can be
rejected by TypeAdapter (MCP dev mode), business logic (_impl), or
both — the assertion checks the error was communicated regardless of
which layer caught it.

Fixed 4 integration tests (creative_formats validation + creative sync
no-format) to use the new helpers instead of transport-specific
isinstance checks.

* fix: xfail 5 BDD UC-011 MCP tests pending billing policy DB migration (#1184)

These tests exercise supported_billing and account_approval_mode
enforcement which is currently injected on the identity by the test
harness. The real MCP auth chain resolves identity from DB where
these fields don't exist. #1184 tracks the migration to store
billing/approval policy on the Tenant model.

* chore: upgrade aiohttp 3.13.3→3.13.5 (10 CVEs)

* chore: add security audit to run_all_tests.sh

* fix: address code review findings (MED-01 through MED-05, 3 lows)

MED-01: Added REST integration tests (brand_manifest translation + known fields)
MED-02: parse_rest_error now uses _adcp_error_from_code for error code precision
MED-03: A2A ValueError/PermissionError translation matches MCP behavior
MED-05: _run_mcp_client asserts header patches were called (auth chain guard)

LOW-01: Fixed invalid recovery hint "contact_support" → "terminal"
LOW-02: Updated McpDispatcher docstring to reflect Client(mcp) dispatch
LOW-03: Added -> NoReturn to _translate_to_tool_error

* refactor: move billing policy and approval mode to tenant configuration (#1184)

Production code: _sync_accounts_impl reads supported_billing and
account_approval_mode from identity.tenant (tenant dict) instead of
identity object fields.

DB: Added supported_billing JSON column to tenants table (migration).
approval_mode already existed as a column.

Harness: AccountSyncEnv.set_billing_policy() and set_approval_mode()
update both in-memory tenant overrides and the DB tenant record so
both mock and real auth chain paths carry the configuration.

BDD: Removed 5 MCP xfails that were blocked on this.
Stepped helpers use harness methods instead of raw DB queries.

* feat: add account_approval_mode tenant column (BR-RULE-060)

Add account_approval_mode as a proper tenant-level config field, distinct
from the creative approval_mode field (BR-RULE-037). The two concerns have
disjoint enums and cannot share storage:

- Creative approval_mode: auto-approve | require-human | ai-powered
- Account approval_mode:  auto | credit_review | legal_review

PR #1184 moved the read site to identity.tenant but never added the column;
_sync_accounts_impl was falling back to the creative approval_mode field,
silently applying the wrong setting and auto-approving every account in
production.

Changes:
- alembic: add account_approval_mode String(50) nullable column to tenants
- Tenant model: add mapped_column for the new field
- TenantContext: add account_approval_mode field (separate from approval_mode)
- from_orm_model: populate the new field from DB
- serialize_tenant_to_dict: expose account_approval_mode key
- integration test: round-trip coverage via TenantFactory + IntegrationEnv

Follow-up tickets (epic salesagent-wwut):
- salesagent-1018: remove dual-key fallback in accounts.py
- salesagent-5w6r: audit harness session consistency
- salesagent-qa92: remove 5 UC-011 MCP xfails

* refactor: simplify tenant config lookups in accounts.py + unit tests

Two cleanups for the #1184 follow-up:

1. _check_billing_policy (BR-RULE-059): collapse isinstance(dict) branching
   to a single tenant.get('supported_billing'). TenantContext implements
   .get() identically to dict (tenant_context.py:71-75), so the branch
   added no value.

2. _sync_accounts_impl (BR-RULE-060): drop the dual-key lookup that fell
   back from account_approval_mode to approval_mode. The fallback was
   silently reading the creative approval_mode field (BR-RULE-037) with a
   disjoint enum. Now that account_approval_mode exists as a proper column
   (commit 98405634), the fallback is both unnecessary and harmful.

Also add focused unit tests for the two pure helpers:
- tests/unit/test_accounts_policy_helpers.py: 15 tests covering
  _check_billing_policy (9 cases incl. tenant=None, TenantContext access,
  dict access, suggestion field per BR-RULE-059) and
  _build_setup_for_approval (6 cases incl. credit_review/legal_review/auto
  and defensive handling of unknown/empty modes).

Ticket salesagent-542q (refactor) + salesagent-bh22 (unit tests),
epic salesagent-ng3n (Complete #1184).

* fix: harness writes account_approval_mode to correct DB column

tests/harness/account_sync.py set_approval_mode() was writing to
tenant.approval_mode — the creative approval field (BR-RULE-037) with a
disjoint enum. The MCP real-auth chain (resolve_identity → get_tenant_by_id
→ serialize_tenant_to_dict) reads tenant.account_approval_mode (the
BR-RULE-060 field added by commit 98405634), so MCP-transport tests were
silently falling through to the default ('auto') regardless of what the
test configured.

Fix: write to tenant.account_approval_mode.

Added regression test that verifies the DB column state from a fresh
session after set_approval_mode(), and that get_tenant_by_id returns
the value. This is the final harness-side piece needed before removing
the 5 UC-011 MCP xfails (tracked as salesagent-piij).

Ticket salesagent-69xd, epic salesagent-ng3n.

* refactor: finish #1184 — drop dead fields, add transport matrix, remove MCP xfails

Completes epic salesagent-ng3n. Three changes:

1. Remove dead fields from ResolvedIdentity (salesagent-oprr):
   supported_billing and account_approval_mode were declared on
   ResolvedIdentity but never populated by resolve_identity(). All
   production reads go through identity.tenant. Fields orphaned by #1184.

2. Transport-matrix integration tests (salesagent-wp9u):
   New TestSyncAccountsBillingPolicyTransport and
   TestSyncAccountsApprovalTransport classes exercise all 4 transports
   (IMPL/A2A/REST/MCP) via @pytest.mark.parametrize + env.call_via.
   6 new tests × 4 transports = 24 new test cases covering:
   - unsupported_billing_returns_failed
   - billing_rejection_error_includes_suggestion (BR-RULE-059)
   - unconfigured_billing_policy_accepts_all
   - credit_review_returns_pending_with_setup (BR-RULE-060)
   - legal_review_returns_pending_message_only
   - auto_approve_returns_active_no_setup

   Tests use env.set_billing_policy() and env.set_approval_mode() which
   write to DB columns, exercising the real MCP auth chain end-to-end.

3. Remove 5 UC-011 MCP xfails (salesagent-piij):
   With b3un schema + 542q lookup cleanup + 69xd harness fix + this
   commit's transport coverage, the MCP real-auth chain now correctly
   propagates account_approval_mode and supported_billing from DB.
   Verified: all 20 (5 scenarios × 4 transports) pass cleanly.

Ticket salesagent-oprr + salesagent-wp9u + salesagent-piij,
epic salesagent-ng3n (Complete #1184).

* fix: dry_run previews reflect approval mode, not hardcoded 'active'

_sync_accounts_impl hardcoded status='active' in the dry_run branch for
new-account previews, bypassing the BR-RULE-060 approval-mode check. A
real create with account_approval_mode='credit_review' returns
status=pending_approval with a setup object; the dry_run preview was
returning 'active' with no setup, violating BR-RULE-062 (dry_run must
preview what would happen).

Fix: resolve approval_mode + setup BEFORE the dry_run branch, and pass
both into the dry_run result so the preview matches what a real create
would return.

Regression test in tests/integration/test_sync_accounts.py:
test_dry_run_credit_review_previews_pending_approval verifies a dry_run
with credit_review returns status=pending_approval with full setup
(message + url + expires_at).

Ticket salesagent-jcvn, epic salesagent-ng3n (Complete #1184).

* test: skip fixture-vs-upstream-spec drift tests

Two e2e tests validate a hardcoded Python dict against a JSON schema
downloaded at runtime from adcontextprotocol.org/schemas/latest/...:

- tests/e2e/test_schema_validation_standalone.py::test_valid_get_products_response
- tests/e2e/test_a2a_protocol_compliance.py::TestA2AProtocolCompliance::test_update_media_buy_schema_validates_correctly

These do NOT exercise any sales agent behavior. They test whether a
test-author-maintained fixture still matches whatever the upstream spec
publishes. Every time the AdCP spec tightens a field faster than the
fixture is updated, these tests fail — regardless of what the sales
agent actually does.

Verified pre-existing on main (d0b472cb) and on the PR's own first
commit (bd377fb3) before any of this epic's work was applied.

Skipping rather than deleting to preserve the file structure in case
future fixture refresh makes them useful again. Real schema conformance
is already covered by tests/unit/test_adcp_contract.py which validates
against the pinned adcp library version, not a moving remote target.

* chore: bump pillow 12.1.1→12.2.0 + pytest 8.4.2→9.0.3 for security

- pillow 12.2.0: fixes GHSA-whj4-6x5x-4v2j (in addition to GHSA-cfh3-3jmp-rvhc already tracked)
- pytest 9.0.3: fixes GHSA-6w46-j5rx-g56g
- pytest-playwright 0.6.1→0.7.2: required to unblock pytest>=9.0.3 (0.6.x caps pytest<9)

make quality: 4243 passed, 19 xfailed, 0 failed (45s) — no behavior change.

* chore: bump python-multipart + delete drift tests (CI fixes)

Two CI failures on the prior push:

1. Security Audit: python-multipart 0.0.22 → 0.0.26 fixes
   GHSA-mj87-hwqh-73pj.

2. Smoke Tests (TestNoSkippedTests::test_no_skip_decorators):
   The project has an AST guard banning @pytest.mark.skip in the tree.
   Commit d1a51cf9 added two skip markers, which tripped the guard.

   Delete the two drift tests entirely instead of skipping:
   - tests/e2e/test_schema_validation_standalone.py::test_valid_get_products_response
   - tests/e2e/test_a2a_protocol_compliance.py::TestA2AProtocolCompliance::test_update_media_buy_schema_validates_correctly

   Rationale unchanged — these validate hardcoded fixtures against
   adcontextprotocol.org/schemas/latest/... and don't exercise any
   sales agent behavior. Real schema conformance is covered by
   tests/unit/test_adcp_contract.py against the pinned adcp library.

* fix: error-handling cleanup — data loss bugs, silent catches, structural guard (#1078) (#1212)

* fix: mock adapter .formats→.format_ids + delete dead format validator (#1078 H2)

- Fix mock_ad_server.py: 4 references to `.formats` → `.format_ids`
  (wrong attribute name silently lost format selections on save)
- Delete dead CreativeFormatModel class from json_validators.py
  (unused since format_ids column rename)
- Delete dead @validates("formats") validator from JSONValidatorMixin
  (never fires — column was renamed to format_ids)
- Remove CreativeFormatModel test from test_session_json_validation.py
- Add regression test via ProductRepository confirming correct/wrong
  attribute behavior

* fix: decryption failure raises AdCPConfigurationError, not returns None (#1078 H1)

- Add AdCPConfigurationError (500, "correctable") to exception hierarchy
- Tenant.gemini_api_key: raise instead of return None on decrypt failure
- AdapterConfig.gam_service_account_json: same
- TenantAuthConfig.oidc_client_secret: wrap bare ValueError in AdCPConfigurationError
- Update test_encryption.py to expect raise (was asserting None)
- Add CONFIGURATION_ERROR to canonical error code vocabulary
- Add 3 integration regression tests for each property getter

* test: add missing degradation test for dynamic pricing fallback (#1078 H4)

TestDynamicPricingExceptionPropagation now has both sides:
- test_type_error_propagates (existed) — bugs crash through
- test_runtime_error_is_graceful (new) — service failures degrade gracefully

Symmetric with TestDynamicVariantsExceptionPropagation which already had both.
No production code change — existing behavior was already correct per GH #1093.

* fix: GAM reporting retains zero-impression rows with clicks or revenue (#1078 H5)

The zero-impression filter in _process_report_data() dropped ALL rows where
impressions=0, including FLAT_RATE/SPONSORSHIP line items that accrue spend
without impressions, and click-only tracking campaigns.

Fix: only skip rows where ALL three metrics (impressions, clicks, revenue)
are zero. Rows with any non-zero metric are always included.

- Fixes silent $0 spend for sponsorship campaigns
- Fixes lost click data for click-only campaigns
- 3 regression tests: revenue-only, clicks-only, all-zeros

* fix: invalid variant TTL days flashes error instead of silently discarding (#1078 M12)

Admin form handler caught ValueError on int(variant_ttl_days) and silently
passed, saving the product without TTL and giving no feedback to the admin.

Fix: flash error message and re-render the form with preserved data.

* fix: log Slack notification failures instead of silently swallowing (#1078 M9)

Two outer catches in audit_logger.py (lines 242, 315) swallowed all Slack
exceptions with bare `pass`. Admin had no visibility into notification failures.

Fix: add audit_logger.warning with exc_info=True. Keeps the catch (Slack must
never crash core operations) but makes failures visible in logs.

* fix: add debug logging to activity_feed silent catches (#1078 M15/L19)

- WebSocket send failure (line 37): bare pass → logger.debug with exc_info
- _format_time_ago (line 203): bare return "Unknown" → logger.debug with exc_info

Both are low-impact UI helpers where raising would be wrong, but silent
swallowing hides diagnostic information.

* fix: add logging to sync_api and policy.py silent catches (#1078 L20/L21)

- sync_api.py:186: secondary error during sync_job update now logged at warning
- policy.py:126: missing WorkflowStep table guard now logged at debug

Both are error-during-error-handling and schema-compat guards where raising
would be wrong, but bare pass hid diagnostic information.

* fix: GAM discovery logs parse failures with stack trace, removes nested bare except (#1078 H6)

discover_orders() and discover_line_items() had nested bare except blocks
that swallowed even the ID extraction, and logged errors without exc_info.

Fix:
- Remove nested try/except for ID extr…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

create_media_buy rejects brand_manifest due to strict FastMCP tool schema

3 participants