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 themcpextra.
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
- Decoration (
VadsMCP.instrument, orVadsPublisher.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. - Post-send hook (
VadsMCP.run_stdio/wrap_write_stream, or a manual call toPendingAd.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
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)
| File | Size | Uploaded | |
|---|---|---|---|
| vads_publisher_sdk-0.1.0.tar.gz | 74.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|