Skip to main content

vads-publisher-sdk

VADS 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

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).

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:

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.

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:

{
  "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:

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 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.

Release files for vads-publisher-sdk 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vads-publisher-sdk 0.1.0
File Size Uploaded
vads_publisher_sdk-0.1.0.tar.gz 74.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vads-publisher-sdk 0.1.0
File Interpreter ABI Platform
vads_publisher_sdk-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 124.8 kB

Release files / vads_publisher_sdk-0.1.0.tar.gz

Download URL vads_publisher_sdk-0.1.0.tar.gz
Size 74.5 kB
Tags Source
SHA-256 checksum
How to use checksums
525aa21f72335ee658e5e00083ec44e10ded54cb5c2e1526ce7c04d425b0c625
BLAKE2b-256 checksum
How to use checksums
6f1a96f739cddd455e2eae448a853e52141e49c8562eca159da10c6098b43e98
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.6

Release files / vads_publisher_sdk-0.1.0-py3-none-any.whl

Download URL vads_publisher_sdk-0.1.0-py3-none-any.whl
Size 50.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e87e4978bb4eb55cbd526005885acf3ddbf3f98d03dca6a4b49ee231be7e621a
BLAKE2b-256 checksum
How to use checksums
e6e339d3fe81596a8841e38afb36b69ac27bedc52495a38f57f6120e6d440129
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.6

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page