From b2e03c7e7caa71ff7a641e70cdd838cb14cced89 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Fri, 17 Jul 2026 22:59:34 +0530 Subject: [PATCH 01/11] deal-api-mcp integration --- .../clients/ssp_deals_api_mcp_client.py | 215 ++++++++++++++++++ src/ad_seller/clients/ssp_factory.py | 13 ++ src/ad_seller/config/settings.py | 6 +- 3 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 src/ad_seller/clients/ssp_deals_api_mcp_client.py diff --git a/src/ad_seller/clients/ssp_deals_api_mcp_client.py b/src/ad_seller/clients/ssp_deals_api_mcp_client.py new file mode 100644 index 0000000..20ecf21 --- /dev/null +++ b/src/ad_seller/clients/ssp_deals_api_mcp_client.py @@ -0,0 +1,215 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""SSP client for IAB deals-api-mcp (HTTP Streamable MCP transport). + +Connects to a running deals-api-mcp server via MCP Streamable HTTP and maps +the IAB Deal Sync API v1.0 tool schema to the generic SSPClient interface. + +deals-api-mcp tools used: + - deals_create: create a new deal (required: name, origin, seller, dealFloor, startDate) + - deals_status: get deal + all buyer seat statuses + history + - deals_list: list deals with optional status filter + - deals_update: update mutable deal fields (blocked after deals_send) + - deals_pause: pause an active deal (propagates to provider) + - deals_resume: resume a paused deal (propagates to provider) +""" + +import logging +from datetime import datetime, timezone +from typing import Any, Optional + +from .freewheel_mcp_client import FreeWheelMCPClient +from .ssp_base import ( + SSPClient, + SSPDeal, + SSPDealCreateRequest, + SSPDealStatus, + SSPTroubleshootResult, + SSPType, +) + +logger = logging.getLogger(__name__) + +# deals-api-mcp sellerStatus integer → SSPDealStatus +# SellerStatus enum: 0=Active, 1=Paused, 2=Pending, 4=Complete, 5=Archived +_SELLER_STATUS_MAP: dict[int, SSPDealStatus] = { + 0: SSPDealStatus.ACTIVE, + 1: SSPDealStatus.PAUSED, + 2: SSPDealStatus.CREATED, + 4: SSPDealStatus.EXPIRED, + 5: SSPDealStatus.ARCHIVED, +} + +_SELLER_STATUS_LABEL: dict[int, str] = { + 0: "Active", 1: "Paused", 2: "Pending", 4: "Complete", 5: "Archived", +} + + +class DealsAPIMCPClient(SSPClient): + """SSP connector for deals-api-mcp via MCP Streamable HTTP. + + Wraps FreeWheelMCPClient for transport and maps structured IAB tool + arguments to/from the generic SSPClient interface. + """ + + ssp_type: SSPType = SSPType.CUSTOM + ssp_name: str = "IAB Deals MCP" + + def __init__( + self, + *, + mcp_url: str, + api_key: Optional[str] = None, + seller_origin: str = "publisher.example.com", + ) -> None: + self.ssp_type = SSPType.CUSTOM + self.ssp_name = "IAB Deals MCP" + self._mcp_url = mcp_url + self._api_key = api_key + self._seller_origin = seller_origin + self._mcp_client = FreeWheelMCPClient() + + # ── Lifecycle ────────────────────────────────────────────────────────── + + async def connect(self) -> None: + auth_params = {"api_key": self._api_key} if self._api_key else None + await self._mcp_client.connect( + url=self._mcp_url, + auth_params=auth_params, + ) + logger.info("Connected to deals-api-mcp at %s", self._mcp_url) + + async def disconnect(self) -> None: + await self._mcp_client.disconnect() + + # ── Deal Operations ──────────────────────────────────────────────────── + + async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: + """Map SSPDealCreateRequest → deals_create structured args.""" + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + args: dict[str, Any] = { + "name": getattr(request, "name", None) or "Untitled Deal", + "origin": self._seller_origin, + "seller": getattr(request, "advertiser", None) or self.ssp_name, + "dealFloor": getattr(request, "cpm", None) or 1.0, + "startDate": getattr(request, "start_date", None) or now_iso, + } + + if getattr(request, "end_date", None): + args["endDate"] = request.end_date + if getattr(request, "impressions_goal", None): + args["units"] = request.impressions_goal + if getattr(request, "buyer_seat_ids", None): + args["wseat"] = request.buyer_seat_ids + if getattr(request, "description", None): + args["description"] = request.description + if getattr(request, "currency", None): + args["currency"] = request.currency + + raw = await self._mcp_client.call_tool("deals_create", args) + return self._parse_deal(raw) + + async def get_deal(self, deal_id: str) -> SSPDeal: + raw = await self._mcp_client.call_tool("deals_status", {"dealId": deal_id}) + return self._parse_deal(raw) + + async def list_deals( + self, + *, + status: Optional[SSPDealStatus] = None, + limit: int = 100, + ) -> list[SSPDeal]: + args: dict[str, Any] = {"pageSize": min(limit, 100)} + raw = await self._mcp_client.call_tool("deals_list", args) + if isinstance(raw, dict): + items = raw.get("deals", raw.get("items", [])) + return [self._parse_deal({"deal": d}) for d in items] + return [] + + async def clone_deal( + self, + source_deal_id: str, + overrides: Optional[dict[str, Any]] = None, + ) -> SSPDeal: + """Clone by fetching the source deal and creating a new one with overrides.""" + source_raw = await self._mcp_client.call_tool("deals_status", {"dealId": source_deal_id}) + source = self._parse_deal(source_raw) + + # Build create args from source deal's terms, apply overrides + source_deal = source_raw.get("deal", {}) if isinstance(source_raw, dict) else {} + terms = source_deal.get("terms", {}) if isinstance(source_deal.get("terms"), dict) else {} + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + args: dict[str, Any] = { + "name": f"Copy of {source_deal.get('name', source_deal_id)}", + "origin": self._seller_origin, + "seller": source_deal.get("seller", self.ssp_name), + "dealFloor": terms.get("dealFloor", 1.0), + "startDate": terms.get("startDate", now_iso), + } + if terms.get("endDate"): + args["endDate"] = terms["endDate"] + if overrides: + args.update(overrides) + + raw = await self._mcp_client.call_tool("deals_create", args) + return self._parse_deal(raw) + + async def update_deal(self, deal_id: str, updates: dict[str, Any]) -> SSPDeal: + raw = await self._mcp_client.call_tool("deals_update", {"id": deal_id, **updates}) + return self._parse_deal(raw) + + async def troubleshoot_deal(self, deal_id: str) -> SSPTroubleshootResult: + raw = await self._mcp_client.call_tool("deals_status", {"dealId": deal_id}) + + issues: list[str] = [] + if isinstance(raw, dict): + seats = raw.get("buyerSeats", []) + for seat in seats: + if isinstance(seat, dict) and seat.get("buyerStatusLabel") == "Rejected": + issues.append(f"Buyer seat {seat.get('seatId', '?')} was rejected by provider") + + return SSPTroubleshootResult( + deal_id=deal_id, + status=self._seller_status_label(raw), + primary_issues=issues, + ssp_type=self.ssp_type, + raw=raw, + ) + + # ── Response Parsing ─────────────────────────────────────────────────── + + def _parse_deal(self, raw: Any) -> SSPDeal: + """Parse a deals-api-mcp tool response into SSPDeal.""" + if not isinstance(raw, dict): + return SSPDeal(deal_id="unknown", ssp_type=self.ssp_type, ssp_name=self.ssp_name) + + # deals_create wraps in {"success": true, "deal": {...}} + # deals_status wraps in {"deal": {...}, "buyerSeats": [...]} + deal = raw.get("deal", raw) + if not isinstance(deal, dict): + deal = raw + + terms = deal.get("terms", {}) if isinstance(deal.get("terms"), dict) else {} + seller_status_int = deal.get("sellerStatus") + + return SSPDeal( + deal_id=str(deal.get("externalDealId", deal.get("id", "unknown"))), + name=deal.get("name"), + status=_SELLER_STATUS_MAP.get(seller_status_int, SSPDealStatus.CREATED), + cpm=terms.get("dealFloor"), + currency=terms.get("currency", "USD"), + ssp_type=self.ssp_type, + ssp_name=self.ssp_name, + raw=raw, + ) + + def _seller_status_label(self, raw: Any) -> str: + if isinstance(raw, dict): + deal = raw.get("deal", raw) + if isinstance(deal, dict): + code = deal.get("sellerStatus") + return _SELLER_STATUS_LABEL.get(code, "unknown") + return "unknown" diff --git a/src/ad_seller/clients/ssp_factory.py b/src/ad_seller/clients/ssp_factory.py index fe4591a..c89587e 100644 --- a/src/ad_seller/clients/ssp_factory.py +++ b/src/ad_seller/clients/ssp_factory.py @@ -113,6 +113,19 @@ def _create_ssp_client(name: str, settings: Any) -> Any: api_key=settings.index_exchange_api_key, ) + elif name_lower == "deals_api_mcp": + from .ssp_deals_api_mcp_client import DealsAPIMCPClient + + if not settings.deals_api_mcp_url: + logger.warning("deals_api_mcp configured but DEALS_API_MCP_URL not set") + return None + + return DealsAPIMCPClient( + mcp_url=settings.deals_api_mcp_url, + api_key=settings.deals_api_mcp_key, + seller_origin=settings.deals_api_mcp_seller_origin, + ) + else: logger.warning( "Unknown SSP '%s'. To add support, create a client in ssp_factory.py " diff --git a/src/ad_seller/config/settings.py b/src/ad_seller/config/settings.py index d481bb8..616b78e 100644 --- a/src/ad_seller/config/settings.py +++ b/src/ad_seller/config/settings.py @@ -146,7 +146,7 @@ class Settings(BaseSettings): # SSP Connectors (publishers can configure multiple SSPs) # Comma-separated list of SSP names to enable - ssp_connectors: str = "" # e.g. "pubmatic,magnite" + ssp_connectors: str = "" # e.g. "pubmatic,magnite,deals_api_mcp" # Routing rules: inventory_type:ssp_name pairs, comma-separated ssp_routing_rules: str = "" # e.g. "ctv:pubmatic,display:magnite" # PubMatic SSP @@ -158,6 +158,10 @@ class Settings(BaseSettings): # Index Exchange SSP (REST API) index_exchange_api_url: Optional[str] = None index_exchange_api_key: Optional[str] = None + # IAB Deals MCP (deals-api-mcp server — HTTP Streamable transport) + deals_api_mcp_url: Optional[str] = None # e.g. http://localhost:3100/mcp + deals_api_mcp_key: Optional[str] = None # IAB_DEALS_API_KEY on the MCP server + deals_api_mcp_seller_origin: str = "publisher.example.com" # origin field for deals_create # Pricing Configuration default_currency: str = "USD" From 7d4dc6b0272ca7fec7850e9315b406c551bea6d4 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Sat, 18 Jul 2026 20:17:19 +0530 Subject: [PATCH 02/11] test and lint fix --- src/ad_seller/clients/gam_soap_client.py | 14 ++++------- .../clients/ssp_deals_api_mcp_client.py | 23 +++++++++++-------- src/ad_seller/storage/quote_history.py | 6 ++--- 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/ad_seller/clients/gam_soap_client.py b/src/ad_seller/clients/gam_soap_client.py index 51b940f..edc82e3 100644 --- a/src/ad_seller/clients/gam_soap_client.py +++ b/src/ad_seller/clients/gam_soap_client.py @@ -232,9 +232,7 @@ def create_order( try: order["externalOrderId"] = int(external_order_id) except (ValueError, TypeError): - order["notes"] = ( - f"{order.get('notes', '')} [deal_id:{external_order_id}]".strip() - ) + order["notes"] = f"{order.get('notes', '')} [deal_id:{external_order_id}]".strip() result = order_service.createOrders([order]) order_data = result[0] @@ -747,7 +745,7 @@ def _fmt_date(dt: Any) -> str: d = getattr(dt, "date", None) if d is None: return str(dt) - return f"{getattr(d, 'year', '')-0:04d}-{getattr(d, 'month', ''):02d}-{getattr(d, 'day', ''):02d}" + return f"{getattr(d, 'year', '') - 0:04d}-{getattr(d, 'month', ''):02d}-{getattr(d, 'day', ''):02d}" return { "id": str(getattr(o, "id", "")), @@ -778,9 +776,7 @@ def list_line_items_for_order(self, order_id: str) -> list[dict[str, Any]]: "id": str(getattr(li, "id", "")), "name": getattr(li, "name", ""), "status": str(getattr(li, "status", "")), - "impressions_goal": getattr( - getattr(li, "primaryGoal", None), "units", -1 - ), + "impressions_goal": getattr(getattr(li, "primaryGoal", None), "units", -1), "cost_type": str(getattr(li, "costType", "CPM")), } for li in (getattr(result, "results", None) or []) @@ -848,9 +844,7 @@ def run_delivery_report( downloader = self._client.GetDataDownloader(version=self.api_version) buf = io.BytesIO() - downloader.DownloadReportToFile( - job_id, "CSV_EXCEL", buf, use_gzip_compression=False - ) + downloader.DownloadReportToFile(job_id, "CSV_EXCEL", buf, use_gzip_compression=False) buf.seek(0) lines = buf.read().decode("utf-8").splitlines() diff --git a/src/ad_seller/clients/ssp_deals_api_mcp_client.py b/src/ad_seller/clients/ssp_deals_api_mcp_client.py index 20ecf21..adf5d50 100644 --- a/src/ad_seller/clients/ssp_deals_api_mcp_client.py +++ b/src/ad_seller/clients/ssp_deals_api_mcp_client.py @@ -42,7 +42,11 @@ } _SELLER_STATUS_LABEL: dict[int, str] = { - 0: "Active", 1: "Paused", 2: "Pending", 4: "Complete", 5: "Archived", + 0: "Active", + 1: "Paused", + 2: "Pending", + 4: "Complete", + 5: "Archived", } @@ -90,11 +94,11 @@ async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") args: dict[str, Any] = { - "name": getattr(request, "name", None) or "Untitled Deal", - "origin": self._seller_origin, - "seller": getattr(request, "advertiser", None) or self.ssp_name, - "dealFloor": getattr(request, "cpm", None) or 1.0, - "startDate": getattr(request, "start_date", None) or now_iso, + "name": getattr(request, "name", None) or "Untitled Deal", + "origin": self._seller_origin, + "seller": getattr(request, "advertiser", None) or self.ssp_name, + "dealFloor": getattr(request, "cpm", None) or 1.0, + "startDate": getattr(request, "start_date", None) or now_iso, } if getattr(request, "end_date", None): @@ -135,7 +139,6 @@ async def clone_deal( ) -> SSPDeal: """Clone by fetching the source deal and creating a new one with overrides.""" source_raw = await self._mcp_client.call_tool("deals_status", {"dealId": source_deal_id}) - source = self._parse_deal(source_raw) # Build create args from source deal's terms, apply overrides source_deal = source_raw.get("deal", {}) if isinstance(source_raw, dict) else {} @@ -143,9 +146,9 @@ async def clone_deal( now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") args: dict[str, Any] = { - "name": f"Copy of {source_deal.get('name', source_deal_id)}", - "origin": self._seller_origin, - "seller": source_deal.get("seller", self.ssp_name), + "name": f"Copy of {source_deal.get('name', source_deal_id)}", + "origin": self._seller_origin, + "seller": source_deal.get("seller", self.ssp_name), "dealFloor": terms.get("dealFloor", 1.0), "startDate": terms.get("startDate", now_iso), } diff --git a/src/ad_seller/storage/quote_history.py b/src/ad_seller/storage/quote_history.py index 23e8055..5bee687 100644 --- a/src/ad_seller/storage/quote_history.py +++ b/src/ad_seller/storage/quote_history.py @@ -78,9 +78,7 @@ async def record_quote( } # Store by quote_id - await self._storage.set( - f"quote_history:{quote_id}", record - ) + await self._storage.set(f"quote_history:{quote_id}", record) # Maintain a buyer+product index for fast lookup index_key = f"quote_history_index:{buyer_id}:{product_id}" @@ -183,7 +181,7 @@ async def verify_pricing( reason=( f"Proposed CPM ${proposed_cpm:.2f} matches quote " f"{quote['quote_id']} (${quoted_cpm:.2f}, " - f"{relative_diff*100:.1f}% difference)." + f"{relative_diff * 100:.1f}% difference)." ), ) From 5ad2b1bd4f4290a48153dcb461f08740d06a4049 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Tue, 21 Jul 2026 17:34:17 +0530 Subject: [PATCH 03/11] fix: persistent MCP session for deals-api-mcp, correct UUID handling, add tests and docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deals-api-mcp's TypeScript MCP SDK sets _initialized=true on first session and never resets it after termination, so a second initialize permanently fails with 400. Fix: class-level persistent background task holds the session open for the entire process lifetime; __aexit__ is a no-op so subsequent requests reuse the session without re-initializing. Also fixes SSPDeal.deal_id to store the internal UUID (deal.id) instead of externalDealId — all MCP tools validate dealId as z.string().uuid() and reject the IAB string format. Fixes troubleshoot_deal to read buyer statuses from the correct response path (status.buyerStatuses, not top-level buyerSeats). Adds 26 unit tests covering parse, create, get, list, clone, troubleshoot, and status mapping. Adds docs/integration/deals-api-mcp.md with connection lifecycle, configuration, status mapping, and persistent session explanation. --- docs/integration/deals-api-mcp.md | 148 +++++++ mkdocs.yml | 1 + .../clients/ssp_deals_api_mcp_client.py | 130 ++++++- tests/unit/test_deals_api_mcp_client.py | 362 ++++++++++++++++++ 4 files changed, 629 insertions(+), 12 deletions(-) create mode 100644 docs/integration/deals-api-mcp.md create mode 100644 tests/unit/test_deals_api_mcp_client.py diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md new file mode 100644 index 0000000..04b0288 --- /dev/null +++ b/docs/integration/deals-api-mcp.md @@ -0,0 +1,148 @@ +# deals-api-mcp Integration + +The seller agent can distribute deals through [deals-api-mcp](https://github.com/IABTechLab/deals-api-mcp) — an MCP server that implements the IAB Deal Sync API v1.0. The seller agent talks to it over **MCP Streamable HTTP**. + +## How It Fits Together + +``` +seller-agent (Python) + └── DealsAPIMCPClient ← SSPClient implementation + └── MCP Transport ← MCP Streamable HTTP transport + └── deals-api-mcp ← MCP server (TypeScript, SQLite) +``` + +`DealsAPIMCPClient` implements the generic `SSPClient` interface and is registered in the SSP registry alongside other connectors. All operations are exposed through the seller agent's standard REST API — no direct access to the connector is needed. + +## Prerequisites + +Start `deals-api-mcp` in HTTP mode before the seller agent connects: + +```bash +MCP_TRANSPORT=http MCP_PORT=3100 NODE_ENV=demo node dist/index.js +``` + +The server listens at `http://localhost:3100/mcp`. See [deals-api-mcp HTTP Streamable Transport](https://github.com/IABTechLab/deals-api-mcp#http-streamable-transport) for full env var details. + +## Configuration + +Enable the connector via environment variables: + +```bash +SSP_CONNECTORS=deals_api_mcp +DEALS_API_MCP_URL=http://localhost:3100/mcp +DEALS_API_MCP_SELLER_ORIGIN=publisher.example.com +DEALS_API_MCP_KEY= # omit for NODE_ENV=demo +``` + +| Variable | Description | +|----------|-------------| +| `SSP_CONNECTORS` | Comma-separated list of active connectors. Include `deals_api_mcp` to enable. | +| `DEALS_API_MCP_URL` | Full URL of the deals-api-mcp `/mcp` endpoint | +| `DEALS_API_MCP_SELLER_ORIGIN` | Publisher domain stamped on every created deal | +| `DEALS_API_MCP_KEY` | Auth key. Required when `NODE_ENV=production`; omit in demo mode | + +## Operations + +### Distribute (Create) a Deal + +```bash +curl -X POST http://localhost:8000/api/v1/deals/distribute \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "deal_id": "your-internal-deal-id", + "ssp_name": "deals_api_mcp", + "name": "Q3 Sports Video", + "advertiser": "Acme Corp", + "cpm": 25.00, + "deal_type": "PMP", + "start_date": "2026-07-01T00:00:00Z", + "end_date": "2026-09-30T00:00:00Z", + "buyer_seat_ids": ["seat-001"] + }' +``` + +Response: + +```json +{ + "deal_id": "8b3f9cfe-7716-48e0-94bf-42fe44595928", + "ssp": "IAB Deals MCP", + "ssp_type": "custom", + "status": "created", + "deal": { "name": "Q3 Sports Video", "cpm": 25.00, ... } +} +``` + +`deal_id` is always the internal UUID (e.g. `8b3f9cfe-...`). The IAB external ID (e.g. `"IAB-..."`) is stored in the SSP's database and is accessible from deals-api-mcp directly when needed for DSP activation. + +Omit `ssp_name` to let the seller agent route based on `SSP_ROUTING_RULES`. + +### Troubleshoot a Deal + +```bash +curl "http://localhost:8000/api/v1/deals/8b3f9cfe-7716-48e0-94bf-42fe44595928/ssp-troubleshoot?ssp_name=deals_api_mcp" \ + -H "Authorization: Bearer " +``` + +Returns the deal's buyer seat statuses and any rejected seats flagged as primary issues. + +### List Configured SSPs + +```bash +curl http://localhost:8000/api/v1/ssps \ + -H "Authorization: Bearer " +``` + +Confirms `deals_api_mcp` is registered and active. + +## Status Mapping + +`deals-api-mcp` returns seller status as an integer (`sellerStatus`). The connector normalizes it: + +| `sellerStatus` | IAB label | Normalized status | +|---------------|-----------|-------------------| +| `0` | Active | `active` | +| `1` | Paused | `paused` | +| `2` | Pending | `created` | +| `4` | Complete | `expired` | +| `5` | Archived | `archived` | + +!!! warning "Buyer vs seller status codes share the integer space" + A buyer-side `0` (Pending) is not the same as a deal-side `0` (Active). They live on different objects — `deal.sellerStatus` vs `buyerSeat.buyerStatus`. Always check which field you are reading. + +## Connection Lifecycle + +```mermaid +sequenceDiagram + participant SA as seller-agent + participant TR as MCP Transport + participant MCP as deals-api-mcp + + SA->>TR: __aenter__ (first request) + TR->>MCP: POST /mcp initialize + MCP-->>TR: 200 OK + Mcp-Session-Id: + TR-->>SA: persistent session ready ✓ + + SA->>TR: call_tool("deals_create", args) + TR->>MCP: POST /mcp tools/call (Mcp-Session-Id: ) + MCP-->>TR: Content-Type: text/event-stream\ndata: {"result":{...}} + TR-->>SA: parsed result dict + + SA->>TR: __aenter__ (second request — session reused) + Note over SA,TR: background task still running, no re-initialize + SA->>TR: call_tool("deals_create", args) + TR->>MCP: POST /mcp tools/call (Mcp-Session-Id: ) + MCP-->>TR: Content-Type: text/event-stream\ndata: {"result":{...}} + TR-->>SA: parsed result dict +``` + +!!! note "Persistent session" + `deals-api-mcp`'s TypeScript MCP SDK sets `_initialized = true` on the first `initialize` and never resets it — `close()` does not clear the flag. A second `initialize` after any session termination permanently fails with `400 Bad Request: Server already initialized` for the lifetime of the process. + + `DealsAPIMCPClient` handles this via a **class-level persistent background task**: the MCP session is created once on first use and held open indefinitely. Every subsequent `async with ssp:` block reuses the existing session without re-initializing. + +## Related + +- [deals-api-mcp README](https://github.com/IABTechLab/deals-api-mcp#readme) — server setup, all 10 MCP tools, data models +- `src/ad_seller/clients/ssp_deals_api_mcp_client.py` — source for `DealsAPIMCPClient` diff --git a/mkdocs.yml b/mkdocs.yml index c149714..8bfbba0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,6 +76,7 @@ nav: - Integration: - Buyer Agent Guide: integration/buyer-agent.md - Negotiation Protocol: integration/negotiation.md + - deals-api-mcp: integration/deals-api-mcp.md - AI Assistant Setup: - Claude (Desktop & Web): guides/claude-desktop-setup.md - ChatGPT, Codex & AI IDEs: guides/chatgpt-setup.md diff --git a/src/ad_seller/clients/ssp_deals_api_mcp_client.py b/src/ad_seller/clients/ssp_deals_api_mcp_client.py index adf5d50..6ddd797 100644 --- a/src/ad_seller/clients/ssp_deals_api_mcp_client.py +++ b/src/ad_seller/clients/ssp_deals_api_mcp_client.py @@ -13,11 +13,18 @@ - deals_update: update mutable deal fields (blocked after deals_send) - deals_pause: pause an active deal (propagates to provider) - deals_resume: resume a paused deal (propagates to provider) + +Note on deal IDs: + SSPDeal.deal_id holds the internal UUID returned by deals-api-mcp (deal.id). + All MCP tools validate dealId as z.string().uuid() — they require this UUID. + The external IAB deal ID (externalDealId, e.g. "IAB-...") is available via + SSPDeal.raw["deal"]["externalDealId"] and is used for DSP activation only. """ +import asyncio import logging from datetime import datetime, timezone -from typing import Any, Optional +from typing import Any, ClassVar, Optional from .freewheel_mcp_client import FreeWheelMCPClient from .ssp_base import ( @@ -55,11 +62,34 @@ class DealsAPIMCPClient(SSPClient): Wraps FreeWheelMCPClient for transport and maps structured IAB tool arguments to/from the generic SSPClient interface. + + deals-api-mcp's TypeScript MCP SDK sets _initialized = true on the first + session and never resets it (close() does not clear the flag), so the server + supports exactly ONE session per process lifetime. To satisfy this constraint + we keep a class-level persistent background task that holds the MCP session + open for the entire process. __aenter__ starts it on first call and reuses + it on every subsequent call; __aexit__ is a no-op so the session is never + torn down between requests. """ ssp_type: SSPType = SSPType.CUSTOM ssp_name: str = "IAB Deals MCP" + # ── Class-level persistent session ───────────────────────────────────── + _shared_mcp: ClassVar[Optional[FreeWheelMCPClient]] = None + _session_task: ClassVar[Optional[asyncio.Task]] = None + _session_ready: ClassVar[Optional[asyncio.Event]] = None + _session_done: ClassVar[Optional[asyncio.Event]] = None + _session_error: ClassVar[Optional[Exception]] = None + _session_url: ClassVar[Optional[str]] = None + _session_lock: ClassVar[Optional[asyncio.Lock]] = None + + @classmethod + def _get_lock(cls) -> asyncio.Lock: + if cls._session_lock is None: + cls._session_lock = asyncio.Lock() + return cls._session_lock + def __init__( self, *, @@ -72,21 +102,88 @@ def __init__( self._mcp_url = mcp_url self._api_key = api_key self._seller_origin = seller_origin - self._mcp_client = FreeWheelMCPClient() + self._mcp_client = FreeWheelMCPClient() # replaced with shared client in __aenter__ # ── Lifecycle ────────────────────────────────────────────────────────── async def connect(self) -> None: auth_params = {"api_key": self._api_key} if self._api_key else None - await self._mcp_client.connect( - url=self._mcp_url, - auth_params=auth_params, - ) + await self._mcp_client.connect(url=self._mcp_url, auth_params=auth_params) logger.info("Connected to deals-api-mcp at %s", self._mcp_url) async def disconnect(self) -> None: await self._mcp_client.disconnect() + async def __aenter__(self) -> "DealsAPIMCPClient": + """Ensure the persistent class-level MCP session is running, then wire + self._mcp_client to it. Satisfies anyio's cancel-scope invariant by + running the full streamablehttp_client lifecycle inside a single + background asyncio Task. + """ + cls = DealsAPIMCPClient + async with self._get_lock(): + session_alive = ( + cls._session_task is not None + and not cls._session_task.done() + and cls._session_url == self._mcp_url + and cls._shared_mcp is not None + and cls._shared_mcp._connected + ) + if not session_alive: + await self._start_shared_session() + + if cls._session_error: + raise cls._session_error + + self._mcp_client = cls._shared_mcp # type: ignore[assignment] + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + # Session is persistent — do not disconnect between requests. + # deals-api-mcp's TypeScript MCP SDK will reject a second initialize + # for the lifetime of the server process after any session termination. + pass + + async def _start_shared_session(self) -> None: + """Start a new background task that holds the shared MCP session open.""" + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + + cls = DealsAPIMCPClient + cls._session_ready = asyncio.Event() + cls._session_done = asyncio.Event() + cls._session_error = None + cls._shared_mcp = FreeWheelMCPClient() + cls._session_url = self._mcp_url + + mcp_url = self._mcp_url + api_key = self._api_key + shared_mcp = cls._shared_mcp + ready = cls._session_ready + done = cls._session_done + + async def _run_session() -> None: + try: + headers = {"x-api-key": api_key} if api_key else None + async with streamablehttp_client(mcp_url, headers=headers) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + shared_mcp._session = session + shared_mcp._connected = True + ready.set() + logger.info("Persistent MCP session established at %s", mcp_url) + await done.wait() # holds connection open indefinitely + except Exception as exc: + cls._session_error = exc + if not ready.is_set(): + ready.set() + finally: + shared_mcp._connected = False + shared_mcp._session = None + + cls._session_task = asyncio.create_task(_run_session()) + await cls._session_ready.wait() + # ── Deal Operations ──────────────────────────────────────────────────── async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: @@ -169,9 +266,11 @@ async def troubleshoot_deal(self, deal_id: str) -> SSPTroubleshootResult: issues: list[str] = [] if isinstance(raw, dict): - seats = raw.get("buyerSeats", []) - for seat in seats: - if isinstance(seat, dict) and seat.get("buyerStatusLabel") == "Rejected": + # deals_status returns labeled summaries under raw["status"]["buyerStatuses"] + # (not raw["buyerSeats"]) — each entry has "status" (label) and "seatId" + status_block = raw.get("status", {}) + for seat in status_block.get("buyerStatuses", []): + if isinstance(seat, dict) and seat.get("status") == "Rejected": issues.append(f"Buyer seat {seat.get('seatId', '?')} was rejected by provider") return SSPTroubleshootResult( @@ -189,8 +288,8 @@ def _parse_deal(self, raw: Any) -> SSPDeal: if not isinstance(raw, dict): return SSPDeal(deal_id="unknown", ssp_type=self.ssp_type, ssp_name=self.ssp_name) - # deals_create wraps in {"success": true, "deal": {...}} - # deals_status wraps in {"deal": {...}, "buyerSeats": [...]} + # deals_create: {"success": true, "deal": {...}} + # deals_status: {"success": true, "deal": {...}, "status": {...}} deal = raw.get("deal", raw) if not isinstance(deal, dict): deal = raw @@ -199,7 +298,10 @@ def _parse_deal(self, raw: Any) -> SSPDeal: seller_status_int = deal.get("sellerStatus") return SSPDeal( - deal_id=str(deal.get("externalDealId", deal.get("id", "unknown"))), + # Internal UUID is what all MCP tools accept (z.string().uuid()). + # externalDealId ("IAB-...") is for DSP activation only — available + # via SSPDeal.raw["deal"]["externalDealId"] when needed. + deal_id=str(deal.get("id", "unknown")), name=deal.get("name"), status=_SELLER_STATUS_MAP.get(seller_status_int, SSPDealStatus.CREATED), cpm=terms.get("dealFloor"), @@ -211,6 +313,10 @@ def _parse_deal(self, raw: Any) -> SSPDeal: def _seller_status_label(self, raw: Any) -> str: if isinstance(raw, dict): + # deals_status provides a pre-labeled status block — prefer it + status_block = raw.get("status", {}) + if isinstance(status_block, dict) and status_block.get("sellerStatus"): + return status_block["sellerStatus"] deal = raw.get("deal", raw) if isinstance(deal, dict): code = deal.get("sellerStatus") diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py new file mode 100644 index 0000000..df96266 --- /dev/null +++ b/tests/unit/test_deals_api_mcp_client.py @@ -0,0 +1,362 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Unit tests for DealsAPIMCPClient. + +All tests mock FreeWheelMCPClient.call_tool so no live server is required. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from ad_seller.clients.ssp_deals_api_mcp_client import ( + DealsAPIMCPClient, + _SELLER_STATUS_MAP, +) +from ad_seller.clients.ssp_base import SSPDeal, SSPDealCreateRequest, SSPDealStatus, SSPType + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_client() -> DealsAPIMCPClient: + client = DealsAPIMCPClient( + mcp_url="http://localhost:3100/mcp", + seller_origin="test.example.com", + ) + client._mcp_client = MagicMock() + client._mcp_client.call_tool = AsyncMock() + return client + + +def _deal_response( + *, + deal_id: str = "ext-123", + internal_id: str = "abc", + name: str = "Test Deal", + seller_status: int = 2, + floor: float = 10.0, + currency: str = "USD", +) -> dict: + """Build a minimal deals_create / deals_status response payload.""" + return { + "success": True, + "deal": { + "id": internal_id, + "externalDealId": deal_id, + "name": name, + "sellerStatus": seller_status, + "seller": "Test Publisher", + "terms": { + "dealFloor": floor, + "currency": currency, + "startDate": "2026-01-01T00:00:00Z", + }, + }, + } + + +# --------------------------------------------------------------------------- +# _parse_deal +# --------------------------------------------------------------------------- + + +class TestParseDeal: + def setup_method(self): + self.client = _make_client() + + def test_extracts_internal_uuid_as_deal_id(self): + # deal_id stores internal UUID (used by all MCP tools); externalDealId is for DSP only + raw = _deal_response(internal_id="uuid-abc", deal_id="IAB-999", seller_status=0) + deal = self.client._parse_deal(raw) + assert deal.deal_id == "uuid-abc" + + def test_uses_internal_id_field(self): + raw = {"success": True, "deal": {"id": "fallback-id", "sellerStatus": 2, "terms": {}}} + deal = self.client._parse_deal(raw) + assert deal.deal_id == "fallback-id" + + def test_maps_seller_status_0_to_active(self): + deal = self.client._parse_deal(_deal_response(seller_status=0)) + assert deal.status == SSPDealStatus.ACTIVE + + def test_maps_seller_status_1_to_paused(self): + deal = self.client._parse_deal(_deal_response(seller_status=1)) + assert deal.status == SSPDealStatus.PAUSED + + def test_maps_seller_status_2_to_created(self): + deal = self.client._parse_deal(_deal_response(seller_status=2)) + assert deal.status == SSPDealStatus.CREATED + + def test_maps_seller_status_4_to_expired(self): + deal = self.client._parse_deal(_deal_response(seller_status=4)) + assert deal.status == SSPDealStatus.EXPIRED + + def test_maps_seller_status_5_to_archived(self): + deal = self.client._parse_deal(_deal_response(seller_status=5)) + assert deal.status == SSPDealStatus.ARCHIVED + + def test_unknown_seller_status_defaults_to_created(self): + deal = self.client._parse_deal(_deal_response(seller_status=99)) + assert deal.status == SSPDealStatus.CREATED + + def test_extracts_floor_and_currency(self): + deal = self.client._parse_deal(_deal_response(floor=25.5, currency="EUR")) + assert deal.cpm == 25.5 + assert deal.currency == "EUR" + + def test_returns_unknown_for_non_dict_input(self): + deal = self.client._parse_deal("not a dict") + assert deal.deal_id == "unknown" + assert deal.ssp_type == SSPType.CUSTOM + + def test_handles_missing_terms_gracefully(self): + raw = {"deal": {"id": "x", "externalDealId": "ext-x", "sellerStatus": 0}} + deal = self.client._parse_deal(raw) + assert deal.deal_id == "x" # internal UUID + assert deal.cpm is None + + def test_ssp_name_and_type_always_set(self): + deal = self.client._parse_deal(_deal_response()) + assert deal.ssp_name == "IAB Deals MCP" + assert deal.ssp_type == SSPType.CUSTOM + + +# --------------------------------------------------------------------------- +# create_deal +# --------------------------------------------------------------------------- + + +class TestCreateDeal: + def setup_method(self): + self.client = _make_client() + + @pytest.mark.asyncio + async def test_maps_request_fields_to_mcp_args(self): + self.client._mcp_client.call_tool.return_value = _deal_response(internal_id="new-uuid-1", deal_id="IAB-new-1") + + request = SSPDealCreateRequest( + name="Q3 Video", + advertiser="Acme Corp", + cpm=30.0, + start_date="2026-07-01T00:00:00Z", + end_date="2026-09-30T00:00:00Z", + impressions_goal=500_000, + buyer_seat_ids=["seat-a", "seat-b"], + currency="GBP", + ) + deal = await self.client.create_deal(request) + + call_args = self.client._mcp_client.call_tool.call_args + tool_name, args = call_args[0] + assert tool_name == "deals_create" + assert args["name"] == "Q3 Video" + assert args["seller"] == "Acme Corp" + assert args["dealFloor"] == 30.0 + assert args["startDate"] == "2026-07-01T00:00:00Z" + assert args["endDate"] == "2026-09-30T00:00:00Z" + assert args["units"] == 500_000 + assert args["wseat"] == ["seat-a", "seat-b"] + assert args["currency"] == "GBP" + assert args["origin"] == "test.example.com" + assert deal.deal_id == "new-uuid-1" # internal UUID + + @pytest.mark.asyncio + async def test_uses_defaults_for_missing_optional_fields(self): + self.client._mcp_client.call_tool.return_value = _deal_response() + + deal = await self.client.create_deal(SSPDealCreateRequest()) + + call_args = self.client._mcp_client.call_tool.call_args + _, args = call_args[0] + assert args["name"] == "Untitled Deal" + assert args["dealFloor"] == 1.0 + assert "endDate" not in args + assert "units" not in args + + @pytest.mark.asyncio + async def test_returns_ssp_deal_instance(self): + self.client._mcp_client.call_tool.return_value = _deal_response(internal_id="ret-uuid-1") + result = await self.client.create_deal(SSPDealCreateRequest()) + assert isinstance(result, SSPDeal) + + +# --------------------------------------------------------------------------- +# get_deal +# --------------------------------------------------------------------------- + + +class TestGetDeal: + def setup_method(self): + self.client = _make_client() + + @pytest.mark.asyncio + async def test_calls_deals_status_with_deal_id(self): + self.client._mcp_client.call_tool.return_value = _deal_response(internal_id="uuid-42", deal_id="IAB-42") + deal = await self.client.get_deal("uuid-42") + + self.client._mcp_client.call_tool.assert_called_once_with( + "deals_status", {"dealId": "uuid-42"} + ) + assert deal.deal_id == "uuid-42" # internal UUID returned by deals_status + + +# --------------------------------------------------------------------------- +# list_deals +# --------------------------------------------------------------------------- + + +class TestListDeals: + def setup_method(self): + self.client = _make_client() + + @pytest.mark.asyncio + async def test_returns_list_of_ssp_deals(self): + self.client._mcp_client.call_tool.return_value = { + "deals": [ + { + "id": "i1", + "externalDealId": "e1", + "sellerStatus": 0, + "name": "Deal A", + "terms": {"dealFloor": 5.0, "currency": "USD"}, + }, + { + "id": "i2", + "externalDealId": "e2", + "sellerStatus": 1, + "name": "Deal B", + "terms": {"dealFloor": 8.0, "currency": "USD"}, + }, + ] + } + + deals = await self.client.list_deals() + assert len(deals) == 2 + assert deals[0].deal_id == "i1" # internal UUID + assert deals[0].status == SSPDealStatus.ACTIVE + assert deals[1].deal_id == "i2" # internal UUID + assert deals[1].status == SSPDealStatus.PAUSED + + @pytest.mark.asyncio + async def test_caps_page_size_at_100(self): + self.client._mcp_client.call_tool.return_value = {"deals": []} + await self.client.list_deals(limit=999) + + _, args = self.client._mcp_client.call_tool.call_args[0] + assert args["pageSize"] == 100 + + @pytest.mark.asyncio + async def test_returns_empty_list_on_non_dict_response(self): + self.client._mcp_client.call_tool.return_value = "unexpected" + result = await self.client.list_deals() + assert result == [] + + +# --------------------------------------------------------------------------- +# clone_deal +# --------------------------------------------------------------------------- + + +class TestCloneDeal: + def setup_method(self): + self.client = _make_client() + + @pytest.mark.asyncio + async def test_prefixes_name_with_copy_of(self): + source = _deal_response(internal_id="src-uuid-1", deal_id="IAB-src-1", name="Original Deal", floor=20.0) + new_deal = _deal_response(internal_id="clone-uuid-1", deal_id="IAB-clone-1", name="Copy of Original Deal") + + # clone_deal: (1) deals_status for source, (2) deals_create + self.client._mcp_client.call_tool.side_effect = [source, new_deal] + deal = await self.client.clone_deal("src-uuid-1") + + create_call = self.client._mcp_client.call_tool.call_args_list[1] + _, args = create_call[0] + assert args["name"] == "Copy of Original Deal" + assert args["dealFloor"] == 20.0 + + @pytest.mark.asyncio + async def test_applies_overrides(self): + source = _deal_response(internal_id="src-uuid-2", deal_id="IAB-src-2", floor=10.0) + new_deal = _deal_response(internal_id="clone-uuid-2", deal_id="IAB-clone-2") + + # clone_deal: (1) deals_status for source, (2) deals_create + self.client._mcp_client.call_tool.side_effect = [source, new_deal] + await self.client.clone_deal("src-uuid-2", overrides={"dealFloor": 99.0, "name": "Custom"}) + + create_call = self.client._mcp_client.call_tool.call_args_list[1] + _, args = create_call[0] + assert args["dealFloor"] == 99.0 + assert args["name"] == "Custom" + + +# --------------------------------------------------------------------------- +# troubleshoot_deal +# --------------------------------------------------------------------------- + + +class TestTroubleshootDeal: + def setup_method(self): + self.client = _make_client() + + @pytest.mark.asyncio + async def test_reports_rejected_buyer_seats(self): + # deals_status returns labeled summaries under status.buyerStatuses, not top-level buyerSeats + self.client._mcp_client.call_tool.return_value = { + "deal": {"id": "d1", "externalDealId": "e1", "sellerStatus": 0, "terms": {}}, + "status": { + "sellerStatus": "Active", + "buyerStatuses": [ + {"seatId": "seat-1", "providerId": "mock", "status": "Rejected", "platformDealId": None}, + {"seatId": "seat-2", "providerId": "mock", "status": "Approved", "platformDealId": None}, + ], + }, + } + + result = await self.client.troubleshoot_deal("d1") + assert len(result.primary_issues) == 1 + assert "seat-1" in result.primary_issues[0] + + @pytest.mark.asyncio + async def test_no_issues_when_all_seats_approved(self): + self.client._mcp_client.call_tool.return_value = { + "deal": {"id": "d1", "externalDealId": "e1", "sellerStatus": 0, "terms": {}}, + "status": { + "sellerStatus": "Active", + "buyerStatuses": [ + {"seatId": "seat-1", "providerId": "mock", "status": "Approved", "platformDealId": None}, + ], + }, + } + + result = await self.client.troubleshoot_deal("d1") + assert result.primary_issues == [] + + @pytest.mark.asyncio + async def test_deal_id_preserved_in_result(self): + self.client._mcp_client.call_tool.return_value = { + "deal": {"id": "target-deal", "sellerStatus": 2, "terms": {}}, + "status": {"sellerStatus": "Pending", "buyerStatuses": []}, + } + + result = await self.client.troubleshoot_deal("target-deal") + assert result.deal_id == "target-deal" + + +# --------------------------------------------------------------------------- +# Status map completeness +# --------------------------------------------------------------------------- + + +class TestSellerStatusMap: + def test_all_known_codes_present(self): + # IAB spec codes: 0=Active, 1=Paused, 2=Pending, 4=Complete, 5=Archived + assert set(_SELLER_STATUS_MAP.keys()) == {0, 1, 2, 4, 5} + + def test_code_3_is_not_mapped(self): + # Code 3 is unassigned in the IAB spec — must not silently map to a status + assert 3 not in _SELLER_STATUS_MAP From 605060585b71020e37e2e9aa28baa25ea4bece2f Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Tue, 21 Jul 2026 19:36:34 +0530 Subject: [PATCH 04/11] fix: remove unused import and variables in test file (ruff) --- tests/unit/test_deals_api_mcp_client.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py index df96266..d7b1063 100644 --- a/tests/unit/test_deals_api_mcp_client.py +++ b/tests/unit/test_deals_api_mcp_client.py @@ -6,16 +6,15 @@ All tests mock FreeWheelMCPClient.call_tool so no live server is required. """ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest +from ad_seller.clients.ssp_base import SSPDeal, SSPDealCreateRequest, SSPDealStatus, SSPType from ad_seller.clients.ssp_deals_api_mcp_client import ( - DealsAPIMCPClient, _SELLER_STATUS_MAP, + DealsAPIMCPClient, ) -from ad_seller.clients.ssp_base import SSPDeal, SSPDealCreateRequest, SSPDealStatus, SSPType - # --------------------------------------------------------------------------- # Helpers @@ -168,7 +167,7 @@ async def test_maps_request_fields_to_mcp_args(self): async def test_uses_defaults_for_missing_optional_fields(self): self.client._mcp_client.call_tool.return_value = _deal_response() - deal = await self.client.create_deal(SSPDealCreateRequest()) + await self.client.create_deal(SSPDealCreateRequest()) call_args = self.client._mcp_client.call_tool.call_args _, args = call_args[0] @@ -272,7 +271,7 @@ async def test_prefixes_name_with_copy_of(self): # clone_deal: (1) deals_status for source, (2) deals_create self.client._mcp_client.call_tool.side_effect = [source, new_deal] - deal = await self.client.clone_deal("src-uuid-1") + await self.client.clone_deal("src-uuid-1") create_call = self.client._mcp_client.call_tool.call_args_list[1] _, args = create_call[0] From c2336c60111d2ce105d880688fd0e0546c13f8a8 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Tue, 21 Jul 2026 20:07:42 +0530 Subject: [PATCH 05/11] fix: echo back requested deal_type in create_deal; remove non-existent /api/v1/ssps from docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deals-api-mcp has no dealType concept so the field was silently defaulting to PMP regardless of what was requested. Now echo the requested deal_type back on the returned SSPDeal so callers get an accurate response. Also removes the GET /api/v1/ssps example from docs — that route does not exist in the seller-agent REST API. --- docs/integration/deals-api-mcp.md | 9 --------- src/ad_seller/clients/ssp_deals_api_mcp_client.py | 7 ++++++- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md index 04b0288..0f40b50 100644 --- a/docs/integration/deals-api-mcp.md +++ b/docs/integration/deals-api-mcp.md @@ -87,15 +87,6 @@ curl "http://localhost:8000/api/v1/deals/8b3f9cfe-7716-48e0-94bf-42fe44595928/ss Returns the deal's buyer seat statuses and any rejected seats flagged as primary issues. -### List Configured SSPs - -```bash -curl http://localhost:8000/api/v1/ssps \ - -H "Authorization: Bearer " -``` - -Confirms `deals_api_mcp` is registered and active. - ## Status Mapping `deals-api-mcp` returns seller status as an integer (`sellerStatus`). The connector normalizes it: diff --git a/src/ad_seller/clients/ssp_deals_api_mcp_client.py b/src/ad_seller/clients/ssp_deals_api_mcp_client.py index 6ddd797..cb8e0fd 100644 --- a/src/ad_seller/clients/ssp_deals_api_mcp_client.py +++ b/src/ad_seller/clients/ssp_deals_api_mcp_client.py @@ -210,7 +210,12 @@ async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: args["currency"] = request.currency raw = await self._mcp_client.call_tool("deals_create", args) - return self._parse_deal(raw) + deal = self._parse_deal(raw) + # deals-api-mcp has no dealType concept — echo back the requested type + # so callers aren't silently told every deal is PMP. + if getattr(request, "deal_type", None): + deal = deal.model_copy(update={"deal_type": request.deal_type}) + return deal async def get_deal(self, deal_id: str) -> SSPDeal: raw = await self._mcp_client.call_tool("deals_status", {"dealId": deal_id}) From 0fb199336ad00c2195ac53048a09ffe541344303 Mon Sep 17 00:00:00 2001 From: Miguel Morales Date: Tue, 21 Jul 2026 17:40:29 -0700 Subject: [PATCH 06/11] keeps openrtb deal id --- docs/integration/deals-api-mcp.md | 5 +++-- src/ad_seller/clients/ssp_base.py | 1 + src/ad_seller/clients/ssp_deals_api_mcp_client.py | 8 ++++---- src/ad_seller/flows/execution_activation_flow.py | 7 ++++++- src/ad_seller/services/deal_service.py | 1 + tests/unit/test_deals_api_mcp_client.py | 9 ++++++++- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md index 0f40b50..8482919 100644 --- a/docs/integration/deals-api-mcp.md +++ b/docs/integration/deals-api-mcp.md @@ -67,14 +67,15 @@ Response: ```json { "deal_id": "8b3f9cfe-7716-48e0-94bf-42fe44595928", + "external_deal_id": "IAB-Q3SPORTS001", "ssp": "IAB Deals MCP", "ssp_type": "custom", "status": "created", - "deal": { "name": "Q3 Sports Video", "cpm": 25.00, ... } + "deal": { "name": "Q3 Sports Video", "cpm": 25.00, "external_deal_id": "IAB-Q3SPORTS001", ... } } ``` -`deal_id` is always the internal UUID (e.g. `8b3f9cfe-...`). The IAB external ID (e.g. `"IAB-..."`) is stored in the SSP's database and is accessible from deals-api-mcp directly when needed for DSP activation. +`deal_id` is the internal UUID (e.g. `8b3f9cfe-...`) required by MCP tools. `external_deal_id` is the OpenRTB / IAB deal ID (e.g. `"IAB-..."`) that DSPs need for activation — it is also present on the nested `deal` object and in `DEAL_SYNCED` event payloads. Omit `ssp_name` to let the seller agent route based on `SSP_ROUTING_RULES`. diff --git a/src/ad_seller/clients/ssp_base.py b/src/ad_seller/clients/ssp_base.py index 3713545..33e8090 100644 --- a/src/ad_seller/clients/ssp_base.py +++ b/src/ad_seller/clients/ssp_base.py @@ -65,6 +65,7 @@ class SSPDeal(BaseModel): """Normalized deal representation from an SSP.""" deal_id: str + external_deal_id: Optional[str] = None # OpenRTB / IAB deal ID (e.g. "IAB-...") name: Optional[str] = None deal_type: SSPDealType = SSPDealType.PMP status: SSPDealStatus = SSPDealStatus.CREATED diff --git a/src/ad_seller/clients/ssp_deals_api_mcp_client.py b/src/ad_seller/clients/ssp_deals_api_mcp_client.py index cb8e0fd..49fb230 100644 --- a/src/ad_seller/clients/ssp_deals_api_mcp_client.py +++ b/src/ad_seller/clients/ssp_deals_api_mcp_client.py @@ -17,8 +17,8 @@ Note on deal IDs: SSPDeal.deal_id holds the internal UUID returned by deals-api-mcp (deal.id). All MCP tools validate dealId as z.string().uuid() — they require this UUID. - The external IAB deal ID (externalDealId, e.g. "IAB-...") is available via - SSPDeal.raw["deal"]["externalDealId"] and is used for DSP activation only. + SSPDeal.external_deal_id holds the OpenRTB / IAB deal ID (externalDealId, + e.g. "IAB-...") used for DSP activation. """ import asyncio @@ -304,9 +304,9 @@ def _parse_deal(self, raw: Any) -> SSPDeal: return SSPDeal( # Internal UUID is what all MCP tools accept (z.string().uuid()). - # externalDealId ("IAB-...") is for DSP activation only — available - # via SSPDeal.raw["deal"]["externalDealId"] when needed. + # external_deal_id is the OpenRTB / IAB ID for DSP activation. deal_id=str(deal.get("id", "unknown")), + external_deal_id=deal.get("externalDealId"), name=deal.get("name"), status=_SELLER_STATUS_MAP.get(seller_status_int, SSPDealStatus.CREATED), cpm=terms.get("dealFloor"), diff --git a/src/ad_seller/flows/execution_activation_flow.py b/src/ad_seller/flows/execution_activation_flow.py index 2327dad..ed2e798 100644 --- a/src/ad_seller/flows/execution_activation_flow.py +++ b/src/ad_seller/flows/execution_activation_flow.py @@ -297,6 +297,7 @@ async def distribute_to_ssps(self) -> None: self.state.execution_orders.setdefault(deal.deal_id, {})["ssp_deal"] = { "ssp_name": ssp_result.ssp_name, "ssp_deal_id": ssp_result.deal_id, + "external_deal_id": ssp_result.external_deal_id, "ssp_status": ssp_result.status.value, } @@ -305,7 +306,11 @@ async def distribute_to_ssps(self) -> None: flow_id=self.state.flow_id, flow_type=self.state.flow_type, deal_id=self.state.deal_id, - payload={"ssp_name": ssp_result.ssp_name, "ssp_deal_id": ssp_result.deal_id}, + payload={ + "ssp_name": ssp_result.ssp_name, + "ssp_deal_id": ssp_result.deal_id, + "external_deal_id": ssp_result.external_deal_id, + }, ) except Exception as e: diff --git a/src/ad_seller/services/deal_service.py b/src/ad_seller/services/deal_service.py index d0b38bd..d557dd1 100644 --- a/src/ad_seller/services/deal_service.py +++ b/src/ad_seller/services/deal_service.py @@ -1028,6 +1028,7 @@ async def distribute_deal_via_ssp(request: Any) -> dict[str, Any]: return { "deal_id": result.deal_id, + "external_deal_id": result.external_deal_id, "ssp": result.ssp_name, "ssp_type": result.ssp_type.value, "status": result.status.value, diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py index d7b1063..7499bdc 100644 --- a/tests/unit/test_deals_api_mcp_client.py +++ b/tests/unit/test_deals_api_mcp_client.py @@ -68,15 +68,17 @@ def setup_method(self): self.client = _make_client() def test_extracts_internal_uuid_as_deal_id(self): - # deal_id stores internal UUID (used by all MCP tools); externalDealId is for DSP only + # deal_id stores internal UUID (used by all MCP tools); external_deal_id is for DSP raw = _deal_response(internal_id="uuid-abc", deal_id="IAB-999", seller_status=0) deal = self.client._parse_deal(raw) assert deal.deal_id == "uuid-abc" + assert deal.external_deal_id == "IAB-999" def test_uses_internal_id_field(self): raw = {"success": True, "deal": {"id": "fallback-id", "sellerStatus": 2, "terms": {}}} deal = self.client._parse_deal(raw) assert deal.deal_id == "fallback-id" + assert deal.external_deal_id is None def test_maps_seller_status_0_to_active(self): deal = self.client._parse_deal(_deal_response(seller_status=0)) @@ -116,6 +118,7 @@ def test_handles_missing_terms_gracefully(self): raw = {"deal": {"id": "x", "externalDealId": "ext-x", "sellerStatus": 0}} deal = self.client._parse_deal(raw) assert deal.deal_id == "x" # internal UUID + assert deal.external_deal_id == "ext-x" assert deal.cpm is None def test_ssp_name_and_type_always_set(self): @@ -162,6 +165,7 @@ async def test_maps_request_fields_to_mcp_args(self): assert args["currency"] == "GBP" assert args["origin"] == "test.example.com" assert deal.deal_id == "new-uuid-1" # internal UUID + assert deal.external_deal_id == "IAB-new-1" @pytest.mark.asyncio async def test_uses_defaults_for_missing_optional_fields(self): @@ -201,6 +205,7 @@ async def test_calls_deals_status_with_deal_id(self): "deals_status", {"dealId": "uuid-42"} ) assert deal.deal_id == "uuid-42" # internal UUID returned by deals_status + assert deal.external_deal_id == "IAB-42" # --------------------------------------------------------------------------- @@ -236,8 +241,10 @@ async def test_returns_list_of_ssp_deals(self): deals = await self.client.list_deals() assert len(deals) == 2 assert deals[0].deal_id == "i1" # internal UUID + assert deals[0].external_deal_id == "e1" assert deals[0].status == SSPDealStatus.ACTIVE assert deals[1].deal_id == "i2" # internal UUID + assert deals[1].external_deal_id == "e2" assert deals[1].status == SSPDealStatus.PAUSED @pytest.mark.asyncio From ded4884411b58d1622482a92a02770dbc34794a3 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Wed, 22 Jul 2026 21:13:18 +0530 Subject: [PATCH 07/11] refactor: move DealsAPIMCPClient to new DealSyncClient connector family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits deals-api-mcp out of the SSP family into a peer DealSyncClient family (alongside SSPClient and AdServerClient), per reviewer design spec. - Add deal_sync_base.py: DealSyncProvider enum, DealSyncClient ABC, DealSyncRegistry (register, get_client, get_default, list_providers) - Add deal_sync_factory.py: build_deal_sync_registry() reads DEAL_SYNC_CONNECTORS env var (replaces SSP_CONNECTORS=deals_api_mcp) - git mv ssp_deals_api_mcp_client.py → deals_api_mcp_client.py; DealsAPIMCPClient now extends DealSyncClient; drop ssp_type attrs, keep ssp_name; persistent-session machinery unchanged - ssp_factory.py: remove deals_api_mcp branch - settings.py: add deal_sync_connectors; regroup deals_api_mcp_* settings - deal_service.py: dual-registry resolution in distribute_deal_via_ssp and troubleshoot_deal_via_ssp; deal-sync response uses channel/provider/provider_name (no ssp_type leakage); ssp_type excluded from deal-sync model_dump; ValueError → 400 - execution_activation_flow.py: parallel SSP + deal-sync blocks; DEAL_SYNCED payload uses channel/provider/provider_name/deal_sync_deal_id - __init__.py: export DealSyncClient, DealSyncProvider, DealSyncRegistry, DealsAPIMCPClient, build_deal_sync_registry - docs: update env var, architecture, response example, file path - tests: update import path; add TestDealSyncFactory (3 tests) Co-Authored-By: Claude Sonnet 4.6 --- docs/integration/deals-api-mcp.md | 15 ++- src/ad_seller/clients/__init__.py | 11 +- src/ad_seller/clients/deal_sync_base.py | 120 ++++++++++++++++++ src/ad_seller/clients/deal_sync_factory.py | 54 ++++++++ ..._mcp_client.py => deals_api_mcp_client.py} | 46 ++++--- src/ad_seller/clients/gam_soap_client.py | 14 +- src/ad_seller/clients/ssp_base.py | 1 + src/ad_seller/clients/ssp_factory.py | 13 -- src/ad_seller/config/settings.py | 6 +- .../flows/execution_activation_flow.py | 105 +++++++++------ src/ad_seller/services/deal_service.py | 100 +++++++++++---- src/ad_seller/storage/quote_history.py | 6 +- tests/unit/test_deals_api_mcp_client.py | 105 +++++++++++++-- 13 files changed, 477 insertions(+), 119 deletions(-) create mode 100644 src/ad_seller/clients/deal_sync_base.py create mode 100644 src/ad_seller/clients/deal_sync_factory.py rename src/ad_seller/clients/{ssp_deals_api_mcp_client.py => deals_api_mcp_client.py} (90%) diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md index 8482919..0877865 100644 --- a/docs/integration/deals-api-mcp.md +++ b/docs/integration/deals-api-mcp.md @@ -6,12 +6,12 @@ The seller agent can distribute deals through [deals-api-mcp](https://github.com ``` seller-agent (Python) - └── DealsAPIMCPClient ← SSPClient implementation + └── DealsAPIMCPClient ← DealSyncClient implementation └── MCP Transport ← MCP Streamable HTTP transport └── deals-api-mcp ← MCP server (TypeScript, SQLite) ``` -`DealsAPIMCPClient` implements the generic `SSPClient` interface and is registered in the SSP registry alongside other connectors. All operations are exposed through the seller agent's standard REST API — no direct access to the connector is needed. +`DealsAPIMCPClient` implements the `DealSyncClient` interface and is registered in the deal-sync registry (a peer of the SSP registry). All operations are exposed through the seller agent's standard REST API — no direct access to the connector is needed. ## Prerequisites @@ -28,7 +28,7 @@ The server listens at `http://localhost:3100/mcp`. See [deals-api-mcp HTTP Strea Enable the connector via environment variables: ```bash -SSP_CONNECTORS=deals_api_mcp +DEAL_SYNC_CONNECTORS=deals_api_mcp DEALS_API_MCP_URL=http://localhost:3100/mcp DEALS_API_MCP_SELLER_ORIGIN=publisher.example.com DEALS_API_MCP_KEY= # omit for NODE_ENV=demo @@ -36,7 +36,7 @@ DEALS_API_MCP_KEY= # omit for NODE_ENV=demo | Variable | Description | |----------|-------------| -| `SSP_CONNECTORS` | Comma-separated list of active connectors. Include `deals_api_mcp` to enable. | +| `DEAL_SYNC_CONNECTORS` | Comma-separated list of active deal-sync providers. Include `deals_api_mcp` to enable. | | `DEALS_API_MCP_URL` | Full URL of the deals-api-mcp `/mcp` endpoint | | `DEALS_API_MCP_SELLER_ORIGIN` | Publisher domain stamped on every created deal | | `DEALS_API_MCP_KEY` | Auth key. Required when `NODE_ENV=production`; omit in demo mode | @@ -68,8 +68,9 @@ Response: { "deal_id": "8b3f9cfe-7716-48e0-94bf-42fe44595928", "external_deal_id": "IAB-Q3SPORTS001", - "ssp": "IAB Deals MCP", - "ssp_type": "custom", + "channel": "deal_sync", + "provider": "deals_api_mcp", + "provider_name": "IAB Deals MCP", "status": "created", "deal": { "name": "Q3 Sports Video", "cpm": 25.00, "external_deal_id": "IAB-Q3SPORTS001", ... } } @@ -137,4 +138,4 @@ sequenceDiagram ## Related - [deals-api-mcp README](https://github.com/IABTechLab/deals-api-mcp#readme) — server setup, all 10 MCP tools, data models -- `src/ad_seller/clients/ssp_deals_api_mcp_client.py` — source for `DealsAPIMCPClient` +- `src/ad_seller/clients/deals_api_mcp_client.py` — source for `DealsAPIMCPClient` diff --git a/src/ad_seller/clients/__init__.py b/src/ad_seller/clients/__init__.py index 5698969..cd3e219 100644 --- a/src/ad_seller/clients/__init__.py +++ b/src/ad_seller/clients/__init__.py @@ -1,7 +1,7 @@ # Author: Green Mountain Systems AI Inc. # Donated to IAB Tech Lab -"""Protocol clients for OpenDirect 2.1, A2A, GAM, SSP, and ad server abstraction.""" +"""Protocol clients for OpenDirect 2.1, A2A, GAM, SSP, deal-sync, and ad server abstraction.""" from .a2a_client import A2AClient, A2AResponse from .ad_server_base import ( @@ -16,6 +16,9 @@ get_ad_server_client, ) from .csv_adapter import CSVAdServerClient +from .deal_sync_base import DealSyncClient, DealSyncProvider, DealSyncRegistry +from .deal_sync_factory import build_deal_sync_registry +from .deals_api_mcp_client import DealsAPIMCPClient from .freewheel_adapter import FreeWheelAdServerClient from .gam_rest_client import GAMRestClient from .gam_soap_client import GAMSoapClient @@ -65,4 +68,10 @@ "RESTSSPClient", "build_ssp_registry", "IndexExchangeSSPClient", + # Deal-sync abstraction + "DealSyncClient", + "DealSyncProvider", + "DealSyncRegistry", + "DealsAPIMCPClient", + "build_deal_sync_registry", ] diff --git a/src/ad_seller/clients/deal_sync_base.py b/src/ad_seller/clients/deal_sync_base.py new file mode 100644 index 0000000..2b648aa --- /dev/null +++ b/src/ad_seller/clients/deal_sync_base.py @@ -0,0 +1,120 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Abstract deal-sync client interface. + +Deal-sync connectors push negotiated deals to an external deal +synchronization service (e.g. the IAB deals-api-mcp server), which +propagates them to buyer-side providers. This is a peer of the other +connector families: + - AdServerClient: inventory sync, deal setup in the publisher's ad server + - SSPClient: deal distribution through SSP exchanges to DSPs + - DealSyncClient: deal sync through an external deal-sync service + +Reuses the normalized deal models from ssp_base (SSPDeal, +SSPDealCreateRequest, SSPDealStatus); giving this family its own +models is out of scope for the connector split. +""" + +from abc import ABC, abstractmethod +from enum import Enum +from typing import Any, ClassVar, Optional + +from .ssp_base import SSPDeal, SSPDealCreateRequest, SSPDealStatus + + +class DealSyncProvider(str, Enum): + """Known deal-sync providers (extensible via config).""" + + DEALS_API_MCP = "deals_api_mcp" + + +class DealSyncClient(ABC): + """Abstract base class for deal-sync integrations. + + Each provider implementation must provide these methods. + The deal-sync registry manages configured providers. + """ + + channel: ClassVar[str] = "deal_sync" + provider: DealSyncProvider + provider_name: str = "Unknown Deal Sync Provider" + + @abstractmethod + async def connect(self) -> None: + """Establish connection to the deal-sync service.""" + ... + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from the deal-sync service.""" + ... + + async def __aenter__(self) -> "DealSyncClient": + await self.connect() + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + await self.disconnect() + + @abstractmethod + async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: + """Create a new deal on the deal-sync service.""" + ... + + @abstractmethod + async def get_deal(self, deal_id: str) -> SSPDeal: + """Get deal details (and sync status) by provider-internal ID.""" + ... + + @abstractmethod + async def list_deals( + self, + *, + status: Optional[SSPDealStatus] = None, + limit: int = 100, + ) -> list[SSPDeal]: + """List deals on this provider.""" + ... + + @abstractmethod + async def update_deal(self, deal_id: str, updates: dict[str, Any]) -> SSPDeal: + """Update mutable deal attributes.""" + ... + + async def health_check(self) -> bool: + """Check if the connection is healthy. Override for custom logic.""" + return True + + +class DealSyncRegistry: + """Registry for configured deal-sync clients, keyed by provider name.""" + + def __init__(self) -> None: + self._clients: dict[str, DealSyncClient] = {} + self._default: Optional[str] = None + + def register(self, name: str, client: DealSyncClient) -> None: + """Register a deal-sync client by provider name.""" + self._clients[name] = client + if self._default is None: + self._default = name + + def get_client(self, name: str) -> DealSyncClient: + """Get a deal-sync client by provider name.""" + if name not in self._clients: + raise KeyError( + f"Deal-sync provider '{name}' not registered. " + f"Available: {list(self._clients.keys())}" + ) + return self._clients[name] + + def get_default(self) -> DealSyncClient: + """Get the default (first-registered) deal-sync client.""" + if not self._default: + raise RuntimeError("No deal-sync clients registered") + return self._clients[self._default] + + def list_providers(self) -> list[str]: + """List registered provider names.""" + return list(self._clients.keys()) diff --git a/src/ad_seller/clients/deal_sync_factory.py b/src/ad_seller/clients/deal_sync_factory.py new file mode 100644 index 0000000..934a4f9 --- /dev/null +++ b/src/ad_seller/clients/deal_sync_factory.py @@ -0,0 +1,54 @@ +# Author: Green Mountain Systems AI Inc. +# Donated to IAB Tech Lab + +"""Deal-sync registry factory — builds deal-sync clients from config. + +Reads DEAL_SYNC_CONNECTORS from settings to build a DealSyncRegistry +with all configured providers. +""" + +import logging +from typing import Any + +from .deal_sync_base import DealSyncRegistry + +logger = logging.getLogger(__name__) + + +def build_deal_sync_registry(settings: Any = None) -> DealSyncRegistry: + """Build a DealSyncRegistry from application settings. + + Reads DEAL_SYNC_CONNECTORS (comma-separated list) and creates the + appropriate client for each configured provider. + """ + if settings is None: + from ..config import get_settings + + settings = get_settings() + + registry = DealSyncRegistry() + + connectors = [s.strip() for s in settings.deal_sync_connectors.split(",") if s.strip()] + + for name in connectors: + name_lower = name.lower() + if name_lower == "deals_api_mcp": + from .deals_api_mcp_client import DealsAPIMCPClient + + if not settings.deals_api_mcp_url: + logger.warning("deals_api_mcp configured but DEALS_API_MCP_URL not set") + continue + + registry.register( + name_lower, + DealsAPIMCPClient( + mcp_url=settings.deals_api_mcp_url, + api_key=settings.deals_api_mcp_key, + seller_origin=settings.deals_api_mcp_seller_origin, + ), + ) + logger.info("Registered deal-sync connector: %s", name_lower) + else: + logger.warning("Unknown deal-sync provider '%s'", name) + + return registry diff --git a/src/ad_seller/clients/ssp_deals_api_mcp_client.py b/src/ad_seller/clients/deals_api_mcp_client.py similarity index 90% rename from src/ad_seller/clients/ssp_deals_api_mcp_client.py rename to src/ad_seller/clients/deals_api_mcp_client.py index 49fb230..f6583bb 100644 --- a/src/ad_seller/clients/ssp_deals_api_mcp_client.py +++ b/src/ad_seller/clients/deals_api_mcp_client.py @@ -1,10 +1,10 @@ # Author: Green Mountain Systems AI Inc. # Donated to IAB Tech Lab -"""SSP client for IAB deals-api-mcp (HTTP Streamable MCP transport). +"""Deal-sync connector for IAB deals-api-mcp (HTTP Streamable MCP transport). Connects to a running deals-api-mcp server via MCP Streamable HTTP and maps -the IAB Deal Sync API v1.0 tool schema to the generic SSPClient interface. +the IAB Deal Sync API v1.0 tool schema to the DealSyncClient interface. deals-api-mcp tools used: - deals_create: create a new deal (required: name, origin, seller, dealFloor, startDate) @@ -26,14 +26,14 @@ from datetime import datetime, timezone from typing import Any, ClassVar, Optional +from .deal_sync_base import DealSyncClient, DealSyncProvider from .freewheel_mcp_client import FreeWheelMCPClient from .ssp_base import ( - SSPClient, SSPDeal, SSPDealCreateRequest, SSPDealStatus, + SSPDealType, SSPTroubleshootResult, - SSPType, ) logger = logging.getLogger(__name__) @@ -48,6 +48,9 @@ 5: SSPDealStatus.ARCHIVED, } +# Reverse: SSPDealStatus → sellerStatus integer (for deals_list filter) +_STATUS_TO_SELLER_INT: dict[SSPDealStatus, int] = {v: k for k, v in _SELLER_STATUS_MAP.items()} + _SELLER_STATUS_LABEL: dict[int, str] = { 0: "Active", 1: "Paused", @@ -57,11 +60,11 @@ } -class DealsAPIMCPClient(SSPClient): - """SSP connector for deals-api-mcp via MCP Streamable HTTP. +class DealsAPIMCPClient(DealSyncClient): + """Deal-sync connector for deals-api-mcp via MCP Streamable HTTP. Wraps FreeWheelMCPClient for transport and maps structured IAB tool - arguments to/from the generic SSPClient interface. + arguments to/from the DealSyncClient interface. deals-api-mcp's TypeScript MCP SDK sets _initialized = true on the first session and never resets it (close() does not clear the flag), so the server @@ -72,8 +75,9 @@ class DealsAPIMCPClient(SSPClient): torn down between requests. """ - ssp_type: SSPType = SSPType.CUSTOM - ssp_name: str = "IAB Deals MCP" + provider = DealSyncProvider.DEALS_API_MCP + provider_name: str = "IAB Deals MCP" + ssp_name: str = "IAB Deals MCP" # feeds SSPDeal.ssp_name in _parse_deal # ── Class-level persistent session ───────────────────────────────────── _shared_mcp: ClassVar[Optional[FreeWheelMCPClient]] = None @@ -97,7 +101,6 @@ def __init__( api_key: Optional[str] = None, seller_origin: str = "publisher.example.com", ) -> None: - self.ssp_type = SSPType.CUSTOM self.ssp_name = "IAB Deals MCP" self._mcp_url = mcp_url self._api_key = api_key @@ -188,16 +191,25 @@ async def _run_session() -> None: async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: """Map SSPDealCreateRequest → deals_create structured args.""" + cpm = getattr(request, "cpm", None) + if cpm is None: + raise ValueError("cpm is required to create a deal via deals-api-mcp") + now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + deal_type = getattr(request, "deal_type", None) args: dict[str, Any] = { "name": getattr(request, "name", None) or "Untitled Deal", "origin": self._seller_origin, - "seller": getattr(request, "advertiser", None) or self.ssp_name, - "dealFloor": getattr(request, "cpm", None) or 1.0, + "seller": self._seller_origin, + "dealFloor": cpm, "startDate": getattr(request, "start_date", None) or now_iso, } + # PG deals must be flagged as guaranteed + if deal_type == SSPDealType.PG: + args["guar"] = 1 + if getattr(request, "end_date", None): args["endDate"] = request.end_date if getattr(request, "impressions_goal", None): @@ -213,8 +225,8 @@ async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: deal = self._parse_deal(raw) # deals-api-mcp has no dealType concept — echo back the requested type # so callers aren't silently told every deal is PMP. - if getattr(request, "deal_type", None): - deal = deal.model_copy(update={"deal_type": request.deal_type}) + if deal_type: + deal = deal.model_copy(update={"deal_type": deal_type}) return deal async def get_deal(self, deal_id: str) -> SSPDeal: @@ -228,6 +240,8 @@ async def list_deals( limit: int = 100, ) -> list[SSPDeal]: args: dict[str, Any] = {"pageSize": min(limit, 100)} + if status is not None and status in _STATUS_TO_SELLER_INT: + args["sellerStatus"] = _STATUS_TO_SELLER_INT[status] raw = await self._mcp_client.call_tool("deals_list", args) if isinstance(raw, dict): items = raw.get("deals", raw.get("items", [])) @@ -282,7 +296,6 @@ async def troubleshoot_deal(self, deal_id: str) -> SSPTroubleshootResult: deal_id=deal_id, status=self._seller_status_label(raw), primary_issues=issues, - ssp_type=self.ssp_type, raw=raw, ) @@ -291,7 +304,7 @@ async def troubleshoot_deal(self, deal_id: str) -> SSPTroubleshootResult: def _parse_deal(self, raw: Any) -> SSPDeal: """Parse a deals-api-mcp tool response into SSPDeal.""" if not isinstance(raw, dict): - return SSPDeal(deal_id="unknown", ssp_type=self.ssp_type, ssp_name=self.ssp_name) + return SSPDeal(deal_id="unknown", ssp_name=self.ssp_name) # deals_create: {"success": true, "deal": {...}} # deals_status: {"success": true, "deal": {...}, "status": {...}} @@ -311,7 +324,6 @@ def _parse_deal(self, raw: Any) -> SSPDeal: status=_SELLER_STATUS_MAP.get(seller_status_int, SSPDealStatus.CREATED), cpm=terms.get("dealFloor"), currency=terms.get("currency", "USD"), - ssp_type=self.ssp_type, ssp_name=self.ssp_name, raw=raw, ) diff --git a/src/ad_seller/clients/gam_soap_client.py b/src/ad_seller/clients/gam_soap_client.py index edc82e3..51b940f 100644 --- a/src/ad_seller/clients/gam_soap_client.py +++ b/src/ad_seller/clients/gam_soap_client.py @@ -232,7 +232,9 @@ def create_order( try: order["externalOrderId"] = int(external_order_id) except (ValueError, TypeError): - order["notes"] = f"{order.get('notes', '')} [deal_id:{external_order_id}]".strip() + order["notes"] = ( + f"{order.get('notes', '')} [deal_id:{external_order_id}]".strip() + ) result = order_service.createOrders([order]) order_data = result[0] @@ -745,7 +747,7 @@ def _fmt_date(dt: Any) -> str: d = getattr(dt, "date", None) if d is None: return str(dt) - return f"{getattr(d, 'year', '') - 0:04d}-{getattr(d, 'month', ''):02d}-{getattr(d, 'day', ''):02d}" + return f"{getattr(d, 'year', '')-0:04d}-{getattr(d, 'month', ''):02d}-{getattr(d, 'day', ''):02d}" return { "id": str(getattr(o, "id", "")), @@ -776,7 +778,9 @@ def list_line_items_for_order(self, order_id: str) -> list[dict[str, Any]]: "id": str(getattr(li, "id", "")), "name": getattr(li, "name", ""), "status": str(getattr(li, "status", "")), - "impressions_goal": getattr(getattr(li, "primaryGoal", None), "units", -1), + "impressions_goal": getattr( + getattr(li, "primaryGoal", None), "units", -1 + ), "cost_type": str(getattr(li, "costType", "CPM")), } for li in (getattr(result, "results", None) or []) @@ -844,7 +848,9 @@ def run_delivery_report( downloader = self._client.GetDataDownloader(version=self.api_version) buf = io.BytesIO() - downloader.DownloadReportToFile(job_id, "CSV_EXCEL", buf, use_gzip_compression=False) + downloader.DownloadReportToFile( + job_id, "CSV_EXCEL", buf, use_gzip_compression=False + ) buf.seek(0) lines = buf.read().decode("utf-8").splitlines() diff --git a/src/ad_seller/clients/ssp_base.py b/src/ad_seller/clients/ssp_base.py index 33e8090..cffb512 100644 --- a/src/ad_seller/clients/ssp_base.py +++ b/src/ad_seller/clients/ssp_base.py @@ -38,6 +38,7 @@ class SSPType(str, Enum): INDEX_EXCHANGE = "index_exchange" OPENX = "openx" XANDR = "xandr" + DEAL_SYNC = "deal_sync" # IAB Deal Sync API hub (e.g. deals-api-mcp) CUSTOM = "custom" diff --git a/src/ad_seller/clients/ssp_factory.py b/src/ad_seller/clients/ssp_factory.py index c89587e..fe4591a 100644 --- a/src/ad_seller/clients/ssp_factory.py +++ b/src/ad_seller/clients/ssp_factory.py @@ -113,19 +113,6 @@ def _create_ssp_client(name: str, settings: Any) -> Any: api_key=settings.index_exchange_api_key, ) - elif name_lower == "deals_api_mcp": - from .ssp_deals_api_mcp_client import DealsAPIMCPClient - - if not settings.deals_api_mcp_url: - logger.warning("deals_api_mcp configured but DEALS_API_MCP_URL not set") - return None - - return DealsAPIMCPClient( - mcp_url=settings.deals_api_mcp_url, - api_key=settings.deals_api_mcp_key, - seller_origin=settings.deals_api_mcp_seller_origin, - ) - else: logger.warning( "Unknown SSP '%s'. To add support, create a client in ssp_factory.py " diff --git a/src/ad_seller/config/settings.py b/src/ad_seller/config/settings.py index 616b78e..590ff07 100644 --- a/src/ad_seller/config/settings.py +++ b/src/ad_seller/config/settings.py @@ -146,7 +146,7 @@ class Settings(BaseSettings): # SSP Connectors (publishers can configure multiple SSPs) # Comma-separated list of SSP names to enable - ssp_connectors: str = "" # e.g. "pubmatic,magnite,deals_api_mcp" + ssp_connectors: str = "" # e.g. "pubmatic,magnite" # Routing rules: inventory_type:ssp_name pairs, comma-separated ssp_routing_rules: str = "" # e.g. "ctv:pubmatic,display:magnite" # PubMatic SSP @@ -158,6 +158,10 @@ class Settings(BaseSettings): # Index Exchange SSP (REST API) index_exchange_api_url: Optional[str] = None index_exchange_api_key: Optional[str] = None + + # Deal Sync Connectors (external deal-sync services, peer of SSP connectors) + # Comma-separated list of provider names to enable + deal_sync_connectors: str = "" # e.g. "deals_api_mcp" (env: DEAL_SYNC_CONNECTORS) # IAB Deals MCP (deals-api-mcp server — HTTP Streamable transport) deals_api_mcp_url: Optional[str] = None # e.g. http://localhost:3100/mcp deals_api_mcp_key: Optional[str] = None # IAB_DEALS_API_KEY on the MCP server diff --git a/src/ad_seller/flows/execution_activation_flow.py b/src/ad_seller/flows/execution_activation_flow.py index ed2e798..0e9ac44 100644 --- a/src/ad_seller/flows/execution_activation_flow.py +++ b/src/ad_seller/flows/execution_activation_flow.py @@ -259,17 +259,9 @@ async def distribute_to_ssps(self) -> None: settings = get_settings() - if not settings.ssp_connectors: - return # No SSPs configured — skip - from ..clients.ssp_base import SSPDealCreateRequest, SSPDealType - from ..clients.ssp_factory import build_ssp_registry - - registry = build_ssp_registry(settings) - if not registry.list_ssps(): - return - # Map deal type + # Map deal type once — used by both SSP and deal-sync blocks deal_type_str = ( deal.deal_type.value if hasattr(deal.deal_type, "value") else str(deal.deal_type) ) @@ -285,37 +277,74 @@ async def distribute_to_ssps(self) -> None: cpm=deal.price, ) - # Route to appropriate SSP - ssp = registry.get_client_for( - inventory_type=getattr(deal, "product_id", None), - deal_type=deal_type_str, - ) - - async with ssp: - ssp_result = await ssp.create_deal(create_req) - - self.state.execution_orders.setdefault(deal.deal_id, {})["ssp_deal"] = { - "ssp_name": ssp_result.ssp_name, - "ssp_deal_id": ssp_result.deal_id, - "external_deal_id": ssp_result.external_deal_id, - "ssp_status": ssp_result.status.value, - } - - await emit_event( - event_type=EventType.DEAL_SYNCED, - flow_id=self.state.flow_id, - flow_type=self.state.flow_type, - deal_id=self.state.deal_id, - payload={ - "ssp_name": ssp_result.ssp_name, - "ssp_deal_id": ssp_result.deal_id, - "external_deal_id": ssp_result.external_deal_id, - }, - ) + # SSP block + if settings.ssp_connectors: + from ..clients.ssp_factory import build_ssp_registry + + ssp_registry = build_ssp_registry(settings) + if ssp_registry.list_ssps(): + ssp = ssp_registry.get_client_for( + inventory_type=getattr(deal, "product_id", None), + deal_type=deal_type_str, + ) + async with ssp: + ssp_result = await ssp.create_deal(create_req) + + self.state.execution_orders.setdefault(deal.deal_id, {})["ssp_deal"] = { + "channel": "ssp", + "ssp_name": ssp_result.ssp_name, + "ssp_deal_id": ssp_result.deal_id, + "external_deal_id": ssp_result.external_deal_id, + "ssp_status": ssp_result.status.value, + } + + await emit_event( + event_type=EventType.DEAL_SYNCED, + flow_id=self.state.flow_id, + flow_type=self.state.flow_type, + deal_id=self.state.deal_id, + payload={ + "channel": "ssp", + "ssp_name": ssp_result.ssp_name, + "ssp_deal_id": ssp_result.deal_id, + "external_deal_id": ssp_result.external_deal_id, + }, + ) + + # Deal-sync block + if settings.deal_sync_connectors: + from ..clients.deal_sync_factory import build_deal_sync_registry + + sync_registry = build_deal_sync_registry(settings) + if sync_registry.list_providers(): + sync_client = sync_registry.get_default() + async with sync_client: + sync_result = await sync_client.create_deal(create_req) + + self.state.execution_orders.setdefault(deal.deal_id, {})["deal_sync"] = { + "provider": sync_client.provider.value, + "deal_sync_deal_id": sync_result.deal_id, + "external_deal_id": sync_result.external_deal_id, + "status": sync_result.status.value, + } + + await emit_event( + event_type=EventType.DEAL_SYNCED, + flow_id=self.state.flow_id, + flow_type=self.state.flow_type, + deal_id=self.state.deal_id, + payload={ + "channel": "deal_sync", + "provider": sync_client.provider.value, + "provider_name": sync_client.provider_name, + "deal_sync_deal_id": sync_result.deal_id, + "external_deal_id": sync_result.external_deal_id, + }, + ) except Exception as e: - # SSP distribution failure is non-fatal — deal still exists in ad server - self.state.warnings.append(f"SSP distribution failed (non-fatal): {e}") + # Distribution failure is non-fatal — deal still exists in ad server + self.state.warnings.append(f"Deal distribution failed (non-fatal): {e}") @listen(distribute_to_ssps) async def update_execution_status(self) -> None: diff --git a/src/ad_seller/services/deal_service.py b/src/ad_seller/services/deal_service.py index d557dd1..c12311a 100644 --- a/src/ad_seller/services/deal_service.py +++ b/src/ad_seller/services/deal_service.py @@ -968,37 +968,53 @@ async def get_deal_buyer_status(deal_id: str, buyer_url: str) -> dict[str, Any]: async def distribute_deal_via_ssp(request: Any) -> dict[str, Any]: - """Distribute a deal through configured SSP(s).""" + """Distribute a deal through configured SSP(s) or deal-sync providers.""" + from ..clients.deal_sync_factory import build_deal_sync_registry from ..clients.ssp_base import SSPDealCreateRequest, SSPDealType from ..clients.ssp_factory import build_ssp_registry - registry = build_ssp_registry() + ssp_registry = build_ssp_registry() + sync_registry = build_deal_sync_registry() - if not registry.list_ssps(): + if not ssp_registry.list_ssps() and not sync_registry.list_providers(): raise HTTPException( status_code=503, detail={ - "error": "no_ssps_configured", - "message": "No SSP connectors configured. Set SSP_CONNECTORS in environment.", + "error": "no_connectors_configured", + "message": ( + "No connectors configured. " + "Set SSP_CONNECTORS or DEAL_SYNC_CONNECTORS in environment." + ), }, ) - # Get the right SSP client + # Resolve client — try SSP first, then deal-sync + client: Any = None + is_deal_sync = False try: if request.ssp_name: - ssp = registry.get_client(request.ssp_name) + try: + client = ssp_registry.get_client(request.ssp_name) + except KeyError: + client = sync_registry.get_client(request.ssp_name) + is_deal_sync = True else: - ssp = registry.get_client_for( - inventory_type=request.inventory_type, - deal_type=request.deal_type, - ) + if ssp_registry.list_ssps(): + client = ssp_registry.get_client_for( + inventory_type=request.inventory_type, + deal_type=request.deal_type, + ) + else: + client = sync_registry.get_default() + is_deal_sync = True except (KeyError, RuntimeError) as e: raise HTTPException( status_code=400, detail={ "error": "ssp_routing_failed", "message": str(e), - "available_ssps": registry.list_ssps(), + "available_ssps": ssp_registry.list_ssps(), + "available_deal_sync_providers": sync_registry.list_providers(), }, ) @@ -1023,12 +1039,27 @@ async def distribute_deal_via_ssp(request: Any) -> dict[str, Any]: targeting=request.targeting, ) - async with ssp: - result = await ssp.create_deal(create_request) + try: + async with client: + result = await client.create_deal(create_request) + except ValueError as e: + raise HTTPException(status_code=400, detail={"error": "invalid_request", "message": str(e)}) + + if is_deal_sync: + return { + "deal_id": result.deal_id, + "external_deal_id": result.external_deal_id, + "channel": client.channel, + "provider": client.provider.value, + "provider_name": client.provider_name, + "status": result.status.value, + "deal": result.model_dump(exclude={"raw", "ssp_type", "ssp_name"}), + } return { "deal_id": result.deal_id, "external_deal_id": result.external_deal_id, + "channel": "ssp", "ssp": result.ssp_name, "ssp_type": result.ssp_type.value, "status": result.status.value, @@ -1037,27 +1068,40 @@ async def distribute_deal_via_ssp(request: Any) -> dict[str, Any]: async def troubleshoot_deal_via_ssp(deal_id: str, ssp_name: str) -> dict[str, Any]: - """Troubleshoot a deal via SSP diagnostics.""" + """Troubleshoot a deal via SSP or deal-sync provider diagnostics.""" + from ..clients.deal_sync_factory import build_deal_sync_registry from ..clients.ssp_factory import build_ssp_registry - registry = build_ssp_registry() + ssp_registry = build_ssp_registry() + sync_registry = build_deal_sync_registry() + # Try SSP first, then deal-sync + client: Any = None try: - ssp = registry.get_client(ssp_name) + client = ssp_registry.get_client(ssp_name) except KeyError: - raise HTTPException( - status_code=400, - detail={ - "error": "unknown_ssp", - "message": f"SSP '{ssp_name}' not configured.", - "available_ssps": registry.list_ssps(), - }, - ) + try: + client = sync_registry.get_client(ssp_name) + except KeyError: + raise HTTPException( + status_code=400, + detail={ + "error": "unknown_ssp", + "message": f"SSP '{ssp_name}' not configured.", + "available_ssps": ssp_registry.list_ssps(), + "available_deal_sync_providers": sync_registry.list_providers(), + }, + ) + + async with client: + result = await client.troubleshoot_deal(deal_id) - async with ssp: - result = await ssp.troubleshoot_deal(deal_id) + from ..clients.deal_sync_base import DealSyncClient - return result.model_dump(exclude={"raw"}) + # SSP clients: preserve ssp_type (existing field); deal-sync clients: exclude it + # (SSPDeal.ssp_type defaults to "custom" for deal-sync — not meaningful to callers) + exclude: set[str] = {"raw", "ssp_type"} if isinstance(client, DealSyncClient) else {"raw"} + return result.model_dump(exclude=exclude) # ============================================================================= diff --git a/src/ad_seller/storage/quote_history.py b/src/ad_seller/storage/quote_history.py index 5bee687..23e8055 100644 --- a/src/ad_seller/storage/quote_history.py +++ b/src/ad_seller/storage/quote_history.py @@ -78,7 +78,9 @@ async def record_quote( } # Store by quote_id - await self._storage.set(f"quote_history:{quote_id}", record) + await self._storage.set( + f"quote_history:{quote_id}", record + ) # Maintain a buyer+product index for fast lookup index_key = f"quote_history_index:{buyer_id}:{product_id}" @@ -181,7 +183,7 @@ async def verify_pricing( reason=( f"Proposed CPM ${proposed_cpm:.2f} matches quote " f"{quote['quote_id']} (${quoted_cpm:.2f}, " - f"{relative_diff * 100:.1f}% difference)." + f"{relative_diff*100:.1f}% difference)." ), ) diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py index 7499bdc..7827108 100644 --- a/tests/unit/test_deals_api_mcp_client.py +++ b/tests/unit/test_deals_api_mcp_client.py @@ -10,11 +10,17 @@ import pytest -from ad_seller.clients.ssp_base import SSPDeal, SSPDealCreateRequest, SSPDealStatus, SSPType -from ad_seller.clients.ssp_deals_api_mcp_client import ( +from ad_seller.clients.deals_api_mcp_client import ( _SELLER_STATUS_MAP, DealsAPIMCPClient, ) +from ad_seller.clients.ssp_base import ( + SSPDeal, + SSPDealCreateRequest, + SSPDealStatus, + SSPDealType, + SSPType, +) # --------------------------------------------------------------------------- # Helpers @@ -112,7 +118,7 @@ def test_extracts_floor_and_currency(self): def test_returns_unknown_for_non_dict_input(self): deal = self.client._parse_deal("not a dict") assert deal.deal_id == "unknown" - assert deal.ssp_type == SSPType.CUSTOM + assert deal.ssp_type == SSPType.CUSTOM # SSPDeal model default def test_handles_missing_terms_gracefully(self): raw = {"deal": {"id": "x", "externalDealId": "ext-x", "sellerStatus": 0}} @@ -124,7 +130,7 @@ def test_handles_missing_terms_gracefully(self): def test_ssp_name_and_type_always_set(self): deal = self.client._parse_deal(_deal_response()) assert deal.ssp_name == "IAB Deals MCP" - assert deal.ssp_type == SSPType.CUSTOM + assert deal.ssp_type == SSPType.CUSTOM # SSPDeal model default; excluded from API responses # --------------------------------------------------------------------------- @@ -156,7 +162,7 @@ async def test_maps_request_fields_to_mcp_args(self): tool_name, args = call_args[0] assert tool_name == "deals_create" assert args["name"] == "Q3 Video" - assert args["seller"] == "Acme Corp" + assert args["seller"] == "test.example.com" # configured seller origin, not advertiser assert args["dealFloor"] == 30.0 assert args["startDate"] == "2026-07-01T00:00:00Z" assert args["endDate"] == "2026-09-30T00:00:00Z" @@ -171,19 +177,40 @@ async def test_maps_request_fields_to_mcp_args(self): async def test_uses_defaults_for_missing_optional_fields(self): self.client._mcp_client.call_tool.return_value = _deal_response() - await self.client.create_deal(SSPDealCreateRequest()) + await self.client.create_deal(SSPDealCreateRequest(cpm=5.0)) call_args = self.client._mcp_client.call_tool.call_args _, args = call_args[0] assert args["name"] == "Untitled Deal" - assert args["dealFloor"] == 1.0 + assert args["dealFloor"] == 5.0 assert "endDate" not in args assert "units" not in args + @pytest.mark.asyncio + async def test_raises_on_missing_cpm(self): + with pytest.raises(ValueError, match="cpm is required"): + await self.client.create_deal(SSPDealCreateRequest()) + + @pytest.mark.asyncio + async def test_pg_deal_sets_guar_flag(self): + self.client._mcp_client.call_tool.return_value = _deal_response(internal_id="pg-uuid-1") + await self.client.create_deal(SSPDealCreateRequest(cpm=50.0, deal_type=SSPDealType.PG)) + + _, args = self.client._mcp_client.call_tool.call_args[0] + assert args.get("guar") == 1 + + @pytest.mark.asyncio + async def test_non_pg_deal_has_no_guar_flag(self): + self.client._mcp_client.call_tool.return_value = _deal_response(internal_id="pmp-uuid-1") + await self.client.create_deal(SSPDealCreateRequest(cpm=20.0, deal_type=SSPDealType.PMP)) + + _, args = self.client._mcp_client.call_tool.call_args[0] + assert "guar" not in args + @pytest.mark.asyncio async def test_returns_ssp_deal_instance(self): self.client._mcp_client.call_tool.return_value = _deal_response(internal_id="ret-uuid-1") - result = await self.client.create_deal(SSPDealCreateRequest()) + result = await self.client.create_deal(SSPDealCreateRequest(cpm=10.0)) assert isinstance(result, SSPDeal) @@ -255,6 +282,22 @@ async def test_caps_page_size_at_100(self): _, args = self.client._mcp_client.call_tool.call_args[0] assert args["pageSize"] == 100 + @pytest.mark.asyncio + async def test_passes_status_filter_to_mcp(self): + self.client._mcp_client.call_tool.return_value = {"deals": []} + await self.client.list_deals(status=SSPDealStatus.ACTIVE) + + _, args = self.client._mcp_client.call_tool.call_args[0] + assert args["sellerStatus"] == 0 # ACTIVE maps to sellerStatus=0 + + @pytest.mark.asyncio + async def test_no_status_filter_omits_seller_status_param(self): + self.client._mcp_client.call_tool.return_value = {"deals": []} + await self.client.list_deals() + + _, args = self.client._mcp_client.call_tool.call_args[0] + assert "sellerStatus" not in args + @pytest.mark.asyncio async def test_returns_empty_list_on_non_dict_response(self): self.client._mcp_client.call_tool.return_value = "unexpected" @@ -366,3 +409,49 @@ def test_all_known_codes_present(self): def test_code_3_is_not_mapped(self): # Code 3 is unassigned in the IAB spec — must not silently map to a status assert 3 not in _SELLER_STATUS_MAP + + +# --------------------------------------------------------------------------- +# DealSyncRegistry factory registration +# --------------------------------------------------------------------------- + + +class TestDealSyncFactory: + def test_registers_deals_api_mcp_when_configured(self): + from unittest.mock import MagicMock + + from ad_seller.clients.deal_sync_factory import build_deal_sync_registry + from ad_seller.clients.deals_api_mcp_client import DealsAPIMCPClient + + settings = MagicMock() + settings.deal_sync_connectors = "deals_api_mcp" + settings.deals_api_mcp_url = "http://localhost:3100/mcp" + settings.deals_api_mcp_key = None + settings.deals_api_mcp_seller_origin = "publisher.example.com" + + registry = build_deal_sync_registry(settings) + assert "deals_api_mcp" in registry.list_providers() + assert isinstance(registry.get_client("deals_api_mcp"), DealsAPIMCPClient) + + def test_registers_nothing_when_url_missing(self): + from unittest.mock import MagicMock + + from ad_seller.clients.deal_sync_factory import build_deal_sync_registry + + settings = MagicMock() + settings.deal_sync_connectors = "deals_api_mcp" + settings.deals_api_mcp_url = None + + registry = build_deal_sync_registry(settings) + assert registry.list_providers() == [] + + def test_empty_connectors_returns_empty_registry(self): + from unittest.mock import MagicMock + + from ad_seller.clients.deal_sync_factory import build_deal_sync_registry + + settings = MagicMock() + settings.deal_sync_connectors = "" + + registry = build_deal_sync_registry(settings) + assert registry.list_providers() == [] From 4cd984d78b50b0d128546113e9bc94e12db8b548 Mon Sep 17 00:00:00 2001 From: Miguel Morales Date: Wed, 22 Jul 2026 14:44:36 -0700 Subject: [PATCH 08/11] raise if no real floor is available --- docs/integration/deals-api-mcp.md | 5 +- src/ad_seller/clients/deals_api_mcp_client.py | 8 +- tests/unit/test_deals_api_mcp_client.py | 167 ++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md index 0877865..5bf9e0c 100644 --- a/docs/integration/deals-api-mcp.md +++ b/docs/integration/deals-api-mcp.md @@ -133,7 +133,10 @@ sequenceDiagram !!! note "Persistent session" `deals-api-mcp`'s TypeScript MCP SDK sets `_initialized = true` on the first `initialize` and never resets it — `close()` does not clear the flag. A second `initialize` after any session termination permanently fails with `400 Bad Request: Server already initialized` for the lifetime of the process. - `DealsAPIMCPClient` handles this via a **class-level persistent background task**: the MCP session is created once on first use and held open indefinitely. Every subsequent `async with ssp:` block reuses the existing session without re-initializing. + `DealsAPIMCPClient` handles this via a **class-level persistent background task**: the MCP session is created once on first use and held open indefinitely. Every subsequent `async with` block reuses the existing session without re-initializing. + +!!! warning "Restart required after disconnect" + If the MCP connection drops, the connector stays unusable until the seller-agent process is restarted. Reconnecting would hit the same `400 Server already initialized` failure. The full fix is blocked on the server-side transport bug tracked as [IABTechLab/deals-api-mcp#7](https://github.com/IABTechLab/deals-api-mcp/issues/7). ## Related diff --git a/src/ad_seller/clients/deals_api_mcp_client.py b/src/ad_seller/clients/deals_api_mcp_client.py index f6583bb..74b2ee8 100644 --- a/src/ad_seller/clients/deals_api_mcp_client.py +++ b/src/ad_seller/clients/deals_api_mcp_client.py @@ -260,12 +260,18 @@ async def clone_deal( source_deal = source_raw.get("deal", {}) if isinstance(source_raw, dict) else {} terms = source_deal.get("terms", {}) if isinstance(source_deal.get("terms"), dict) else {} now_iso = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + overrides = overrides or {} + + # Never invent a floor — same rule as create_deal. + floor = overrides.get("dealFloor", terms.get("dealFloor")) + if floor is None: + raise ValueError("dealFloor is required to clone a deal via deals-api-mcp") args: dict[str, Any] = { "name": f"Copy of {source_deal.get('name', source_deal_id)}", "origin": self._seller_origin, "seller": source_deal.get("seller", self.ssp_name), - "dealFloor": terms.get("dealFloor", 1.0), + "dealFloor": floor, "startDate": terms.get("startDate", now_iso), } if terms.get("endDate"): diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py index 7827108..aef47b6 100644 --- a/tests/unit/test_deals_api_mcp_client.py +++ b/tests/unit/test_deals_api_mcp_client.py @@ -342,6 +342,47 @@ async def test_applies_overrides(self): assert args["dealFloor"] == 99.0 assert args["name"] == "Custom" + @pytest.mark.asyncio + async def test_raises_when_source_has_no_floor(self): + source = { + "success": True, + "deal": { + "id": "src-uuid-3", + "externalDealId": "IAB-src-3", + "name": "No Floor", + "sellerStatus": 2, + "terms": {"startDate": "2026-01-01T00:00:00Z"}, + }, + } + self.client._mcp_client.call_tool.side_effect = [source] + + with pytest.raises(ValueError, match="dealFloor is required"): + await self.client.clone_deal("src-uuid-3") + + # Must not call deals_create after the validation failure + assert self.client._mcp_client.call_tool.call_count == 1 + + @pytest.mark.asyncio + async def test_override_floor_allows_clone_without_source_floor(self): + source = { + "success": True, + "deal": { + "id": "src-uuid-4", + "externalDealId": "IAB-src-4", + "name": "No Floor", + "sellerStatus": 2, + "terms": {"startDate": "2026-01-01T00:00:00Z"}, + }, + } + new_deal = _deal_response(internal_id="clone-uuid-4", deal_id="IAB-clone-4") + self.client._mcp_client.call_tool.side_effect = [source, new_deal] + + await self.client.clone_deal("src-uuid-4", overrides={"dealFloor": 12.0}) + + create_call = self.client._mcp_client.call_tool.call_args_list[1] + _, args = create_call[0] + assert args["dealFloor"] == 12.0 + # --------------------------------------------------------------------------- # troubleshoot_deal @@ -455,3 +496,129 @@ def test_empty_connectors_returns_empty_registry(self): registry = build_deal_sync_registry(settings) assert registry.list_providers() == [] + + +# --------------------------------------------------------------------------- +# Persistent MCP session lifecycle +# --------------------------------------------------------------------------- + + +def _reset_session_state() -> None: + """Tear down class-level persistent session state between tests.""" + cls = DealsAPIMCPClient + if cls._session_done is not None: + cls._session_done.set() + task = cls._session_task + if task is not None and not task.done(): + task.cancel() + cls._shared_mcp = None + cls._session_task = None + cls._session_ready = None + cls._session_done = None + cls._session_error = None + cls._session_url = None + cls._session_lock = None + + +class TestPersistentSession: + def setup_method(self): + _reset_session_state() + + def teardown_method(self): + _reset_session_state() + + @pytest.mark.asyncio + async def test_starts_shared_session_on_first_aenter(self): + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock, patch + + fake_session = MagicMock() + fake_session.initialize = AsyncMock() + fake_session.__aenter__ = AsyncMock(return_value=fake_session) + fake_session.__aexit__ = AsyncMock(return_value=None) + + @asynccontextmanager + async def fake_transport(*_args, **_kwargs): + yield (MagicMock(), MagicMock(), None) + + with ( + patch( + "mcp.client.streamable_http.streamablehttp_client", + side_effect=fake_transport, + ) as transport_mock, + patch("mcp.ClientSession", return_value=fake_session), + ): + client = DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp") + async with client: + assert DealsAPIMCPClient._shared_mcp is not None + assert DealsAPIMCPClient._shared_mcp._connected is True + assert DealsAPIMCPClient._session_task is not None + assert not DealsAPIMCPClient._session_task.done() + assert client._mcp_client is DealsAPIMCPClient._shared_mcp + + transport_mock.assert_called_once() + fake_session.initialize.assert_awaited_once() + + @pytest.mark.asyncio + async def test_reuses_session_on_second_aenter(self): + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock, patch + + fake_session = MagicMock() + fake_session.initialize = AsyncMock() + fake_session.__aenter__ = AsyncMock(return_value=fake_session) + fake_session.__aexit__ = AsyncMock(return_value=None) + + @asynccontextmanager + async def fake_transport(*_args, **_kwargs): + yield (MagicMock(), MagicMock(), None) + + with ( + patch( + "mcp.client.streamable_http.streamablehttp_client", + side_effect=fake_transport, + ) as transport_mock, + patch("mcp.ClientSession", return_value=fake_session), + ): + client_a = DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp") + client_b = DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp") + + async with client_a: + first_task = DealsAPIMCPClient._session_task + + async with client_b: + assert DealsAPIMCPClient._session_task is first_task + assert client_b._mcp_client is DealsAPIMCPClient._shared_mcp + + transport_mock.assert_called_once() + fake_session.initialize.assert_awaited_once() + + @pytest.mark.asyncio + async def test_latches_and_reraises_session_start_error(self): + from contextlib import asynccontextmanager + from unittest.mock import patch + + boom = RuntimeError("initialize failed") + + @asynccontextmanager + async def failing_transport(*_args, **_kwargs): + raise boom + yield # pragma: no cover + + with patch( + "mcp.client.streamable_http.streamablehttp_client", + side_effect=failing_transport, + ): + client = DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp") + with pytest.raises(RuntimeError, match="initialize failed"): + async with client: + pass + + assert DealsAPIMCPClient._session_error is boom + + # A later enter retries start; with the same transport failure it + # latches and raises again (upstream reject-after-disconnect case). + with pytest.raises(RuntimeError, match="initialize failed"): + async with DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp"): + pass + assert DealsAPIMCPClient._session_error is boom From 17317401af6ddc64acdade615c6fb20242e47bdd Mon Sep 17 00:00:00 2001 From: Miguel Morales Date: Wed, 22 Jul 2026 15:51:00 -0700 Subject: [PATCH 09/11] implements reconnect after drop --- docs/integration/deals-api-mcp.md | 7 +-- src/ad_seller/clients/deals_api_mcp_client.py | 51 ++++++++++++++----- tests/unit/test_deals_api_mcp_client.py | 49 +++++++++++++++++- 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md index 5bf9e0c..a2b50dd 100644 --- a/docs/integration/deals-api-mcp.md +++ b/docs/integration/deals-api-mcp.md @@ -131,12 +131,9 @@ sequenceDiagram ``` !!! note "Persistent session" - `deals-api-mcp`'s TypeScript MCP SDK sets `_initialized = true` on the first `initialize` and never resets it — `close()` does not clear the flag. A second `initialize` after any session termination permanently fails with `400 Bad Request: Server already initialized` for the lifetime of the process. + `DealsAPIMCPClient` keeps a **class-level persistent background task** so the MCP session is created on first use and reused across subsequent `async with` blocks without re-initializing. - `DealsAPIMCPClient` handles this via a **class-level persistent background task**: the MCP session is created once on first use and held open indefinitely. Every subsequent `async with` block reuses the existing session without re-initializing. - -!!! warning "Restart required after disconnect" - If the MCP connection drops, the connector stays unusable until the seller-agent process is restarted. Reconnecting would hit the same `400 Server already initialized` failure. The full fix is blocked on the server-side transport bug tracked as [IABTechLab/deals-api-mcp#7](https://github.com/IABTechLab/deals-api-mcp/issues/7). + If the connection drops (or the MCP URL changes), the next `__aenter__` stops the old background task and starts a fresh session — no process restart required. ## Related diff --git a/src/ad_seller/clients/deals_api_mcp_client.py b/src/ad_seller/clients/deals_api_mcp_client.py index 74b2ee8..34ac209 100644 --- a/src/ad_seller/clients/deals_api_mcp_client.py +++ b/src/ad_seller/clients/deals_api_mcp_client.py @@ -66,13 +66,11 @@ class DealsAPIMCPClient(DealSyncClient): Wraps FreeWheelMCPClient for transport and maps structured IAB tool arguments to/from the DealSyncClient interface. - deals-api-mcp's TypeScript MCP SDK sets _initialized = true on the first - session and never resets it (close() does not clear the flag), so the server - supports exactly ONE session per process lifetime. To satisfy this constraint - we keep a class-level persistent background task that holds the MCP session - open for the entire process. __aenter__ starts it on first call and reuses - it on every subsequent call; __aexit__ is a no-op so the session is never - torn down between requests. + A class-level background task holds one shared MCP session open so + successive ``async with`` blocks reuse it without re-initializing. + ``__aexit__`` is a no-op between requests. If the connection drops (or the + URL changes), the next ``__aenter__`` tears down the old task and starts a + fresh session. """ provider = DealSyncProvider.DEALS_API_MCP @@ -142,17 +140,46 @@ async def __aenter__(self) -> "DealsAPIMCPClient": return self async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: - # Session is persistent — do not disconnect between requests. - # deals-api-mcp's TypeScript MCP SDK will reject a second initialize - # for the lifetime of the server process after any session termination. + # Keep the shared session open between requests; reconnect happens on + # the next __aenter__ if the background task has died. pass + @classmethod + async def _stop_shared_session(cls) -> None: + """Signal the background session to exit and wait for it to finish.""" + done = cls._session_done + task = cls._session_task + if done is not None: + done.set() + if task is not None and not task.done(): + try: + await asyncio.wait_for(task, timeout=5.0) + except (TimeoutError, asyncio.CancelledError, Exception): + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + if cls._shared_mcp is not None: + cls._shared_mcp._connected = False + cls._shared_mcp._session = None + cls._session_task = None + cls._session_ready = None + cls._session_done = None + cls._session_error = None + async def _start_shared_session(self) -> None: - """Start a new background task that holds the shared MCP session open.""" + """Start a new background task that holds the shared MCP session open. + + Stops any prior shared session first so a drop (or URL change) can + reconnect without leaking the old transport task. + """ from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client cls = DealsAPIMCPClient + await cls._stop_shared_session() + cls._session_ready = asyncio.Event() cls._session_done = asyncio.Event() cls._session_error = None @@ -175,7 +202,7 @@ async def _run_session() -> None: shared_mcp._connected = True ready.set() logger.info("Persistent MCP session established at %s", mcp_url) - await done.wait() # holds connection open indefinitely + await done.wait() # holds connection open until stop/reconnect except Exception as exc: cls._session_error = exc if not ready.is_set(): diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py index aef47b6..8674d01 100644 --- a/tests/unit/test_deals_api_mcp_client.py +++ b/tests/unit/test_deals_api_mcp_client.py @@ -617,8 +617,55 @@ async def failing_transport(*_args, **_kwargs): assert DealsAPIMCPClient._session_error is boom # A later enter retries start; with the same transport failure it - # latches and raises again (upstream reject-after-disconnect case). + # latches and raises again. with pytest.raises(RuntimeError, match="initialize failed"): async with DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp"): pass assert DealsAPIMCPClient._session_error is boom + + @pytest.mark.asyncio + async def test_reconnects_after_session_drop(self): + from contextlib import asynccontextmanager + from unittest.mock import AsyncMock, patch + + fake_session = MagicMock() + fake_session.initialize = AsyncMock() + fake_session.__aenter__ = AsyncMock(return_value=fake_session) + fake_session.__aexit__ = AsyncMock(return_value=None) + + @asynccontextmanager + async def fake_transport(*_args, **_kwargs): + yield (MagicMock(), MagicMock(), None) + + with ( + patch( + "mcp.client.streamable_http.streamablehttp_client", + side_effect=fake_transport, + ) as transport_mock, + patch("mcp.ClientSession", return_value=fake_session), + ): + client = DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp") + async with client: + first_task = DealsAPIMCPClient._session_task + first_mcp = DealsAPIMCPClient._shared_mcp + assert first_task is not None + assert first_mcp is not None + assert first_mcp._connected is True + + # Simulate a dropped connection: background task exits. + assert DealsAPIMCPClient._session_done is not None + DealsAPIMCPClient._session_done.set() + await first_task + assert first_task.done() + assert first_mcp._connected is False + + async with DealsAPIMCPClient(mcp_url="http://localhost:3100/mcp") as client_b: + assert DealsAPIMCPClient._session_task is not first_task + assert not DealsAPIMCPClient._session_task.done() + assert DealsAPIMCPClient._shared_mcp is not first_mcp + assert DealsAPIMCPClient._shared_mcp._connected is True + assert client_b._mcp_client is DealsAPIMCPClient._shared_mcp + assert DealsAPIMCPClient._session_error is None + + assert transport_mock.call_count == 2 + assert fake_session.initialize.await_count == 2 From 210240504bf2238db0bacd7be7b51cf4845242d4 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Thu, 23 Jul 2026 16:56:16 +0530 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20reconnect=20on=20stale=20session?= =?UTF-8?q?=20without=20hanging=20=E2=80=94=20=5Fcall=5Ftool=20timeout=20+?= =?UTF-8?q?=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live E2E testing showed that after a server restart, session_alive returns True (SDK SSE reconnect loop keeps background task alive) so the stale session ID is reused. The tool call then hangs indefinitely: fresh server returns 400 but no SSE response ever arrives. Fix: wrap all call_tool calls through _call_tool which applies a 30s timeout. On timeout or HTTP 400/404, forces _start_shared_session and retries once. First request after a drop now succeeds in ≤30s; all subsequent requests are instant on the new session. Update docs to accurately reflect the reconnect mechanism. --- docs/integration/deals-api-mcp.md | 2 +- src/ad_seller/clients/deals_api_mcp_client.py | 64 +++++++++++++++++-- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/integration/deals-api-mcp.md b/docs/integration/deals-api-mcp.md index a2b50dd..d127613 100644 --- a/docs/integration/deals-api-mcp.md +++ b/docs/integration/deals-api-mcp.md @@ -133,7 +133,7 @@ sequenceDiagram !!! note "Persistent session" `DealsAPIMCPClient` keeps a **class-level persistent background task** so the MCP session is created on first use and reused across subsequent `async with` blocks without re-initializing. - If the connection drops (or the MCP URL changes), the next `__aenter__` stops the old background task and starts a fresh session — no process restart required. + If the connection drops, the stale session is detected on the next tool call (via a 30 s timeout), the background task is restarted, and the call is retried automatically — no process restart required. The first request after a drop may take up to 30 seconds while reconnection completes; subsequent requests are unaffected. ## Related diff --git a/src/ad_seller/clients/deals_api_mcp_client.py b/src/ad_seller/clients/deals_api_mcp_client.py index 34ac209..a3820a1 100644 --- a/src/ad_seller/clients/deals_api_mcp_client.py +++ b/src/ad_seller/clients/deals_api_mcp_client.py @@ -38,6 +38,10 @@ logger = logging.getLogger(__name__) + +class _StaleSessionError(Exception): + """Internal sentinel raised when a tool call signals the session is gone.""" + # deals-api-mcp sellerStatus integer → SSPDealStatus # SellerStatus enum: 0=Active, 1=Paused, 2=Pending, 4=Complete, 5=Archived _SELLER_STATUS_MAP: dict[int, SSPDealStatus] = { @@ -214,6 +218,52 @@ async def _run_session() -> None: cls._session_task = asyncio.create_task(_run_session()) await cls._session_ready.wait() + # ── Tool call wrapper ────────────────────────────────────────────────── + + async def _call_tool(self, tool_name: str, args: dict[str, Any]) -> Any: + """Call an MCP tool, detecting stale sessions and reconnecting once. + + Two failure modes after a server restart: + - Hang: tool call POST gets 400 but SDK waits for SSE response that + never arrives (session_alive lied — SDK reconnect loop kept the + background task running with the old session ID). A timeout breaks + the hang and triggers reconnect. + - Immediate error: SDK raises httpx.HTTPStatusError(400/404). Caught + here and also triggers reconnect. + """ + import httpx + + _TOOL_TIMEOUT = 30.0 # seconds before treating a call as hung/stale + + async def _attempt() -> Any: + try: + return await asyncio.wait_for( + self._mcp_client.call_tool(tool_name, args), + timeout=_TOOL_TIMEOUT, + ) + except (TimeoutError, asyncio.TimeoutError): + raise _StaleSessionError("tool call timed out — stale session suspected") + except httpx.HTTPStatusError as exc: + if exc.response.status_code in (400, 404): + raise _StaleSessionError(str(exc)) from exc + raise + except Exception as exc: + msg = str(exc).lower() + if "400" in msg or "404" in msg or "bad request" in msg or "not found" in msg: + raise _StaleSessionError(str(exc)) from exc + raise + + try: + return await _attempt() + except _StaleSessionError: + logger.info("Stale MCP session detected — reconnecting to %s", self._mcp_url) + async with self._get_lock(): + await self._start_shared_session() + if DealsAPIMCPClient._session_error: + raise DealsAPIMCPClient._session_error + self._mcp_client = DealsAPIMCPClient._shared_mcp # type: ignore[assignment] + return await _attempt() + # ── Deal Operations ──────────────────────────────────────────────────── async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: @@ -248,7 +298,7 @@ async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: if getattr(request, "currency", None): args["currency"] = request.currency - raw = await self._mcp_client.call_tool("deals_create", args) + raw = await self._call_tool("deals_create", args) deal = self._parse_deal(raw) # deals-api-mcp has no dealType concept — echo back the requested type # so callers aren't silently told every deal is PMP. @@ -257,7 +307,7 @@ async def create_deal(self, request: SSPDealCreateRequest) -> SSPDeal: return deal async def get_deal(self, deal_id: str) -> SSPDeal: - raw = await self._mcp_client.call_tool("deals_status", {"dealId": deal_id}) + raw = await self._call_tool("deals_status", {"dealId": deal_id}) return self._parse_deal(raw) async def list_deals( @@ -269,7 +319,7 @@ async def list_deals( args: dict[str, Any] = {"pageSize": min(limit, 100)} if status is not None and status in _STATUS_TO_SELLER_INT: args["sellerStatus"] = _STATUS_TO_SELLER_INT[status] - raw = await self._mcp_client.call_tool("deals_list", args) + raw = await self._call_tool("deals_list", args) if isinstance(raw, dict): items = raw.get("deals", raw.get("items", [])) return [self._parse_deal({"deal": d}) for d in items] @@ -281,7 +331,7 @@ async def clone_deal( overrides: Optional[dict[str, Any]] = None, ) -> SSPDeal: """Clone by fetching the source deal and creating a new one with overrides.""" - source_raw = await self._mcp_client.call_tool("deals_status", {"dealId": source_deal_id}) + source_raw = await self._call_tool("deals_status", {"dealId": source_deal_id}) # Build create args from source deal's terms, apply overrides source_deal = source_raw.get("deal", {}) if isinstance(source_raw, dict) else {} @@ -306,15 +356,15 @@ async def clone_deal( if overrides: args.update(overrides) - raw = await self._mcp_client.call_tool("deals_create", args) + raw = await self._call_tool("deals_create", args) return self._parse_deal(raw) async def update_deal(self, deal_id: str, updates: dict[str, Any]) -> SSPDeal: - raw = await self._mcp_client.call_tool("deals_update", {"id": deal_id, **updates}) + raw = await self._call_tool("deals_update", {"id": deal_id, **updates}) return self._parse_deal(raw) async def troubleshoot_deal(self, deal_id: str) -> SSPTroubleshootResult: - raw = await self._mcp_client.call_tool("deals_status", {"dealId": deal_id}) + raw = await self._call_tool("deals_status", {"dealId": deal_id}) issues: list[str] = [] if isinstance(raw, dict): From ac72e6c78aa536953d59decc1fc5a65c51127fb7 Mon Sep 17 00:00:00 2001 From: Sirajmx Date: Thu, 23 Jul 2026 18:12:27 +0530 Subject: [PATCH 11/11] fix: list_deals status filter and add TestUpdateDeal unit tests - Fix list_deals sending sellerStatus instead of status; deals-api-mcp schema expects status (integer), sellerStatus was silently dropped so every filtered query returned all deals regardless of filter value - Update TestListDeals assertions to match correct field name (status) - Add TestUpdateDeal: covers tool name, ID pass-through, updates spread, and response parsing via _parse_deal (cpm, external_deal_id, status) --- src/ad_seller/clients/deals_api_mcp_client.py | 2 +- tests/unit/test_deals_api_mcp_client.py | 41 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/ad_seller/clients/deals_api_mcp_client.py b/src/ad_seller/clients/deals_api_mcp_client.py index a3820a1..a9637b1 100644 --- a/src/ad_seller/clients/deals_api_mcp_client.py +++ b/src/ad_seller/clients/deals_api_mcp_client.py @@ -318,7 +318,7 @@ async def list_deals( ) -> list[SSPDeal]: args: dict[str, Any] = {"pageSize": min(limit, 100)} if status is not None and status in _STATUS_TO_SELLER_INT: - args["sellerStatus"] = _STATUS_TO_SELLER_INT[status] + args["status"] = _STATUS_TO_SELLER_INT[status] raw = await self._call_tool("deals_list", args) if isinstance(raw, dict): items = raw.get("deals", raw.get("items", [])) diff --git a/tests/unit/test_deals_api_mcp_client.py b/tests/unit/test_deals_api_mcp_client.py index 8674d01..b5d776e 100644 --- a/tests/unit/test_deals_api_mcp_client.py +++ b/tests/unit/test_deals_api_mcp_client.py @@ -288,15 +288,15 @@ async def test_passes_status_filter_to_mcp(self): await self.client.list_deals(status=SSPDealStatus.ACTIVE) _, args = self.client._mcp_client.call_tool.call_args[0] - assert args["sellerStatus"] == 0 # ACTIVE maps to sellerStatus=0 + assert args["status"] == 0 # ACTIVE maps to status=0 (deals-api-mcp schema) @pytest.mark.asyncio - async def test_no_status_filter_omits_seller_status_param(self): + async def test_no_status_filter_omits_status_param(self): self.client._mcp_client.call_tool.return_value = {"deals": []} await self.client.list_deals() _, args = self.client._mcp_client.call_tool.call_args[0] - assert "sellerStatus" not in args + assert "status" not in args @pytest.mark.asyncio async def test_returns_empty_list_on_non_dict_response(self): @@ -384,6 +384,41 @@ async def test_override_floor_allows_clone_without_source_floor(self): assert args["dealFloor"] == 12.0 +# --------------------------------------------------------------------------- +# update_deal +# --------------------------------------------------------------------------- + + +class TestUpdateDeal: + def setup_method(self): + self.client = _make_client() + + @pytest.mark.asyncio + async def test_passes_id_and_updates_to_mcp(self): + self.client._mcp_client.call_tool.return_value = { + "deal": {"id": "u1", "externalDealId": "ext-u1", "sellerStatus": 0, "terms": {"dealFloor": 12.0}} + } + await self.client.update_deal("u1", {"name": "Updated Name", "dealFloor": 12.0}) + + tool_name, args = self.client._mcp_client.call_tool.call_args[0] + assert tool_name == "deals_update" + assert args["id"] == "u1" + assert args["name"] == "Updated Name" + assert args["dealFloor"] == 12.0 + + @pytest.mark.asyncio + async def test_returns_updated_ssp_deal(self): + self.client._mcp_client.call_tool.return_value = { + "deal": {"id": "u2", "externalDealId": "ext-u2", "sellerStatus": 0, "terms": {"dealFloor": 5.0}} + } + result = await self.client.update_deal("u2", {"dealFloor": 5.0}) + + assert result.deal_id == "u2" + assert result.external_deal_id == "ext-u2" + assert result.cpm == 5.0 + assert result.status == SSPDealStatus.ACTIVE + + # --------------------------------------------------------------------------- # troubleshoot_deal # ---------------------------------------------------------------------------