Metadata-Version: 2.5
Name: vads-publisher-sdk
Version: 0.1.0
Summary: VADS publisher SDK: attach disclosed, sponsored ad objects to MCP tool results (fail-open).
Project-URL: Homepage, https://vads.au
Author-email: Apdak Pty Ltd <support@vads.au>
License: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Provides-Extra: dev
Requires-Dist: jsonschema>=4.18; extra == 'dev'
Requires-Dist: mcp<3,>=2.0; extra == 'dev'
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: mcp
Requires-Dist: mcp<3,>=2.0; extra == 'mcp'
Description-Content-Type: text/markdown

# vads-publisher-sdk

[VADS](https://vads.au) lets Model Context Protocol (MCP) servers earn
revenue by attaching disclosed, sponsored ad objects to tool results,
without ever delaying or breaking the tool call itself. This is the Python
publisher SDK: it talks to the VADS Decision Service, builds the ad object,
and manages its lifecycle (decided -> attached -> acknowledged) in the
background, all fail-open.

- Import name: `vads_publisher`. Python 3.10+. Fully typed (`py.typed`).
- Runtime dependency: `httpx`. The MCP integration helper needs the `mcp`
  extra.

## Install

```bash
pip install "vads-publisher-sdk[mcp]"
```

Drop `[mcp]` if you're using `VadsPublisher` directly against a different
framework.

## Quickstart

There are **two separate integration points**. Wire both, or ads are placed
but never billed as impressions (see "Fail-open behaviour" below).

```python
import anyio
from mcp.server.mcpserver import MCPServer
from vads_publisher import AdContext, VadsPublisher
from vads_publisher.mcp import VadsMCP

server = MCPServer("weather")


@server.tool()
async def get_forecast(city: str) -> str:
    return f"{city}: 18C, light rain"


vads = VadsMCP(
    VadsPublisher(
        api_key="pk_xxxxxxxxxxxx.yyyy...",  # Portal -> site -> API keys
        slot_id="6f1c1d1e-...",  # Portal -> site -> slots
        declared_client_route="claude-desktop/1.0",  # a validated approved-client route
        # api_base_url and redirect_base_url default to the production VADS
        # endpoints; override only for a non-production environment.
        allowed_categories={"weather"},  # the site's registered enumerations
        allowed_keywords={"forecast", "rain"},
    )
)

# Integration point 1: decoration. Which tools carry an ad, and their
# allowlisted context. Static metadata only, never tool arguments or output.
vads.instrument(server, tools={"get_forecast": AdContext(category="weather", keywords=("forecast",))})

# Integration point 2: the post-send hook. Acknowledges an ad only after the
# response has actually been handed to the transport. Replaces
# `server.run()` / `run_stdio_async()`.
anyio.run(vads.run_stdio, server)
```

### Other frameworks, or a custom transport

`VadsMCP.wrap_write_stream(stream)` wraps any MCP server-side write stream
for one connection; use it with `lowlevel_server.run(read, wrap(write), ...)`.
Without MCP at all, use `VadsPublisher` directly:

```python
from vads_publisher.envelope import META_KEY  # "au.vads/envelope"

req = publisher.request_ad("search", AdContext(category="shopping"))  # start alongside the tool
result = await run_tool()
ad = await req.resolve(tool_elapsed=...)  # waits at most the latency budget; None means no ad
if ad:
    response.setdefault("_meta", {})[META_KEY] = ad.envelope  # place it before hand-off
    ad.mark_attached()  # reports "attached" (background)
    await send(response)
    ad.acknowledge()  # from your post-send hook only, after send() returned
```

## Configuration

`VadsPublisher`'s keyword arguments, with defaults:

| Argument | Default | Notes |
|---|---|---|
| `api_key` | required | Site API key `"<prefix>.<secret>"`. |
| `slot_id` | required | The ad slot UUID from the publisher portal. |
| `declared_client_route` | required | Which approved client/version this traffic is. |
| `api_base_url` | `"https://app.vads.au"` | The Decision Service origin. |
| `redirect_base_url` | `"https://r.vads.au"` | The Redirect Worker origin. Only used as a fallback when a decision doesn't already carry its own `cta_url`. |
| `pricing_model` | `"cpm"` | Fallback only; the decision's own pricing model normally decides. |
| `min_handle_ttl` | `60.0` (seconds) | An ad whose engagement link expires sooner than this isn't served. |
| `latency_budget` | `LatencyBudget()` (150 ms fixed) | See below. |
| `max_envelope_bytes` | `2048` | Envelope byte-size cap; an oversized ad is dropped, not truncated. |
| `allowed_categories` / `allowed_keywords` | `None` | The site's registered `category`/`keywords` enumerations, enforced client-side. |
| `on_event` | `None` | Observability callback; must not raise. |
| `breaker` | `CircuitBreaker()` (5 failures, 30 s cooldown) | Circuit breaker for the decision call. |

`api_base_url` and `redirect_base_url` can be overridden independently, e.g.
to point at a staging environment; nothing here reads environment variables
on its own, so read `os.environ["VADS_API_KEY"]` etc. yourself, as in
`examples/weather_server.py`.

## The two integration points

1. **Decoration** (`VadsMCP.instrument`, or `VadsPublisher.request_ad` +
   `AdRequest.resolve`): starts a decision alongside the tool call and, if
   one is filled, places the ad envelope into the tool result before it's
   handed back to the transport.
2. **Post-send hook** (`VadsMCP.run_stdio` / `wrap_write_stream`, or a
   manual call to `PendingAd.acknowledge()`): fires only after the response
   bytes have actually been sent. This is what reports the impression as
   delivered; without it, nothing is ever acknowledged.

Both are required for normal CPM billing. Without the post-send hook, ads
are still placed and attached (and the SDK logs one warning), just never
acknowledged.

## Fail-open behaviour and the latency budget

Nothing here ever raises into your tool handler. On no fill, a rejected
decision, a timeout, a network or server error, an over-budget wait, an
open circuit breaker, a non-allowlisted context, or a malformed/oversized/
expiring ad, the original tool result goes out completely unchanged, along
with a `VadsEvent` on your `on_event` callback (if set) and a log line. An
`is_error` result is never decorated.

`LatencyBudget`'s default is a fixed 150 ms: the maximum *extra*
wall-clock time the SDK may wait, measured from when the tool itself
finishes, not from when the decision started. Since the decision request
starts alongside the tool (`request_ad`), a decision that resolves faster
than the tool adds no latency at all. The decision call itself gets zero
retries on this path. An adaptive form is also available:
`LatencyBudget(fraction=0.1, floor=0.025, ceiling=0.15)`, an EWMA of each
tool's own measured latency, clamped between `floor` and `ceiling`.

After 5 consecutive decision failures (default), the circuit breaker skips
decisions entirely for 30 seconds before letting one probe through.
Attaching and acknowledging happen in the background, off the response
path, and do retry.

## Privacy: allowlisted context only

The only context ever sent is `{category, keywords, environment}` — plain
ASCII slug values drawn from your own site's registered tool metadata.
**Tool arguments and tool output are never sent.** This is enforced
structurally (`AdContext` is a frozen dataclass; a closed `AdContextDict`
TypedDict) and again at runtime, before any I/O, against
`allowed_categories`/`allowed_keywords`.

## Session ID (optional, for advertiser frequency caps)

`request_ad` accepts an optional `session_id`: your own per-user session or
conversation identifier, distinct from `declared_client_route` (which
identifies the integration, not the end user). It lets advertisers cap how
often the same user sees their campaign. It's treated as an untrusted
signal, is never a reason a decision is blocked, and is hashed server-side —
the raw value is never stored.

```python
publisher.request_ad("get_forecast", AdContext(category="weather"), session_id=user_session_id)
```

## How ads appear

A filled decision adds an envelope to the tool result's `_meta`, under the
key `au.vads/envelope` (`vads_publisher.envelope.META_KEY`), leaving the
real `content` untouched:

```json
{
  "content": [{"type": "text", "text": "Sunny in Oslo"}],
  "_meta": {
    "au.vads/envelope": {
      "v": 1,
      "ads": [{
        "id": "b98a0a98-3fba-41c3-a8b2-281e6fa9e39e",
        "format": "sponsored_listing",
        "title": "Try Demo Co.",
        "body": "The best example on the internet.",
        "cta_url": "https://r.vads.au/r/1.359b62efce8aa2e3a64d174db74e1a6f",
        "disclosure": "sponsored"
      }]
    }
  }
}
```

`cta_url` always points at the VADS Redirect Worker, never directly at the
advertiser. `VadsMCP(..., placement="top_level")` instead puts the same
envelope at a top-level `_vads` key, for clients that don't preserve
`_meta` (note: the official Python MCP client drops it again on receipt).
`VadsMCP(..., text_block=True)` also appends a plain, clearly disclosed
text block after the real content:

```
[Sponsored] Try Demo Co.: The best example on the internet. (https://r.vads.au/r/1.359b62efce8aa2e3a64d174db74e1a6f)
```

## Low-level client

For direct control over the wire calls, `AsyncVadsClient` (and its sync
twin `VadsClient`) are available too:

```python
from vads_publisher import AsyncVadsClient

async with AsyncVadsClient(api_key) as c:  # base_url defaults to https://app.vads.au
    d = await c.create_decision(
        slot_id=slot, tool_name="search", declared_client_route="claude-desktop/1.0", context={"category": "weather"}
    )
    if d.filled:
        await c.attach(d.decision_id)
        if d.pricing_model == "cpm":
            ack = await c.ack(d.decision_id, d.ads[0].digest)
            ack.publisher_amount  # Decimal, exact
```

Money fields (`platform_fee`, `publisher_amount`, `amount`) always arrive as
exact `Decimal`, never `float`.

## Getting an API key and slot ID

Sign up and create a site at [app.vads.au](https://app.vads.au) to get a
site API key and an ad slot ID for the "Configuration" table above.

## Support

support@vads.au

## License

MIT (c) 2026 Apdak Pty Ltd. See [LICENSE](./LICENSE).
