Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 56 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ jobs:
adcp storyboard run \
http://acme.localhost:3001/mcp media_buy_seller \
--auth dev-bearer-token-acme-1 \
--test-kit ../../tests/fixtures/storyboard-test-kit.yaml \
--json --allow-http \
> v3-storyboard-result.json

Expand All @@ -683,18 +684,69 @@ jobs:
"

- name: Assert storyboard passed
# Hard gate. The runner's ``overall_status`` MUST be ``passing``;
# any failure fails the job.
# Hard gate. This translator intentionally has no
# ``comply_test_controller``: it exercises a real upstream rather
# than owning the runner's state-control API. SDK 13 therefore grades
# its coverage ``partial`` even when every executable step passes.
# Accept only that exact topology — zero failed steps/tracks, useful
# executed coverage, and no skip class outside controller absence,
# its dependent steps, missing controller tools, or not-applicable
# capabilities. Any real failure or new skip class still blocks CI.
run: |
python -c "
import json, sys, pathlib
p = pathlib.Path('examples/v3_reference_seller/v3-storyboard-result.json')
with p.open() as f:
d = json.load(f)
if d.get('overall_status') != 'passing':
status = d.get('overall_status')
summary = d.get('summary') or {}
skip_counts = summary.get('skipped_by_reason') or {}
allowed_partial_skips = {
'missing_test_controller',
'missing_tool',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium: missing_tool is accepted unconditionally, but nothing in the gate ties it to the absent controller. The comment reads it as "missing controller tools," yet SDK 13 emits missing_tool for any tool the storyboard needs and can't find on the upstream — including a real AdCP task tool the translator should support. Because the accept only requires controller_detected is False, steps_failed == 0, and missing_test_controller > 0, an upstream that silently drops a genuine capability tool produces a missing_tool skip, keeps steps_passed > 0 from the rest, and grades partial → CI goes green on a real coverage regression this conformance gate exists to catch. missing_test_controller and prerequisite_failed are genuinely controller-cascade; missing_tool is not scoped to it. Consider dropping missing_tool from the allowed set, or asserting its skips are controller-tool-scoped.

'prerequisite_failed',
'not_applicable',
}
unexpected_skips = sorted(set(skip_counts) - allowed_partial_skips)
steps = [
step
for track in d.get('tracks', [])
for scenario in track.get('scenarios', [])
for step in scenario.get('steps', [])
]
unexpected_missing_tools = [
step.get('task')
for step in steps
if step.get('skip_reason') == 'missing_tool'
and step.get('task') != 'comply_test_controller'
]
unexpected_prerequisites = [
step.get('error')
for step in steps
if step.get('skip_reason') == 'prerequisite_failed'
and 'skipped (missing_tool)' not in (step.get('error') or '')
]
partial_is_expected = (
status == 'partial'
and summary.get('steps_failed') == 0
and summary.get('tracks_failed') == 0
and (summary.get('steps_passed') or 0) > 0
and (skip_counts.get('missing_test_controller') or 0) > 0
and not unexpected_skips
and not unexpected_missing_tools
and not unexpected_prerequisites
and d.get('controller_detected') is False
)
if status != 'passing' and not partial_is_expected:
print(json.dumps(d, indent=2))
if unexpected_skips:
print(f'Unexpected partial skip classes: {unexpected_skips}')
if unexpected_missing_tools:
print(f'Unexpected missing tools: {unexpected_missing_tools}')
if unexpected_prerequisites:
print(f'Unexpected prerequisite skips: {unexpected_prerequisites}')
sys.exit(1)
print('Storyboard passing.')
print(f'Storyboard accepted: status={status}, summary={summary}')
"

- name: Assert upstream traffic (anti-façade gate)
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
include ADCP_VERSION
include README.md
include LICENSE
include MIGRATION*.md
recursive-include src/adcp py.typed
# Bundled AdCP JSON schemas. ``scripts/bundle_schemas.py`` mirrors
# ``schemas/cache/`` into ``src/adcp/_schemas/`` before ``python -m
Expand Down
12 changes: 11 additions & 1 deletion MIGRATION_v6_to_v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This guide applies to applications upgrading from any Python SDK 6.x release
to 7.x. SDK 7 makes canonical creatives the primary application contract,
updates the bundled protocol schemas to AdCP 3.1.14, and tightens several
updates the bundled protocol schemas to AdCP 3.1.15, and tightens several
security and concurrency boundaries.

The SDK package version and negotiated AdCP protocol version are independent.
Expand Down Expand Up @@ -32,6 +32,10 @@ SDK 7 continues to interoperate with AdCP 3.0 and 3.1 agents.
waiting for a duplicate completion webhook.
8. Exercise callback validation and multi-tenant isolation in staging before
production rollout.
9. Use `WebhookReceiver` for public MCP callback endpoints so RFC 9421
verification and delivery deduplication happen before application code.
Configure `webhook_secret` on `ADCPClient` only for registrations that
explicitly select the deprecated `HMAC-SHA256` fallback.

## Canonical creatives are the primary API

Expand Down Expand Up @@ -197,6 +201,12 @@ DNS pinning must provide a custom sender and enforce resolution at connection
time. A push-configured handoff with no available delivery transport is now
rejected before task creation instead of being accepted and silently dropped.

Public MCP callbacks should enter through `WebhookReceiver`, which verifies the
AdCP RFC 9421 profile, deduplicates delivery, and parses the authenticated raw
body. `ADCPClient.handle_webhook()` is the legacy HMAC convenience path; use it
only when the callback registration explicitly selects `HMAC-SHA256` and the
client is configured with the same `webhook_secret`.

Account registries, sessions, proposals, notification stores, and reference
seller state now enforce tenant ownership. Test fixtures or application code
that relied on a cross-tenant fallback must be updated to carry the authenticated
Expand Down
36 changes: 36 additions & 0 deletions MIGRATION_v7_to_v8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Migrating from Python SDK 7 to 8

SDK 8 makes the legacy `ADCPClient.handle_webhook()` convenience path fail
closed. Calls without a configured `webhook_secret` no longer accept unsigned
MCP callbacks.

For AdCP-conformant public endpoints, migrate delivery to `WebhookReceiver`.
It verifies RFC 9421 signatures, deduplicates retries, and parses the
authenticated raw body. Construct it using the
[complete receiver quickstart](README.md#signed-webhooks-adcp-30-receiver-quickstart),
then pass the unchanged request to it:

```python
outcome = await receiver.receive(
method=request.method,
url=str(request.url),
headers=dict(request.headers),
body=await request.body(),
)
```

If a 3.x registration explicitly selects the deprecated `HMAC-SHA256`
fallback, configure the same shared secret on `ADCPClient` and pass the raw
request body to `handle_webhook()`. An endpoint that is isolated from untrusted
networks may temporarily retain unsigned legacy callbacks with
`allow_unauthenticated_webhooks=True`; multi-agent clients must scope this
escape by agent ID.

## Webhook activity metadata

`ActivityType.WEBHOOK_RECEIVED` no longer copies the complete callback into
`Activity.metadata["payload"]`. The metadata now contains only `task_id`,
`status`, and `protocol`; `operation_id` and `task_type` remain top-level
activity fields. Update telemetry consumers that read results or tokens from
the old payload field. Process business data from the verified webhook result
instead of exporting it through activity telemetry.
47 changes: 30 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,16 +276,16 @@ async with ADCPMultiAgentClient(

## AdCP version support

The 7.x line is built against **AdCP 3.1.14 stable**, makes canonical creatives
the primary Python contract, and negotiates AdCP 3.0, 3.1, and 3.2 wire
dialects. The SDK package version and protocol version are intentionally
independent:
The SDK 8 beta line is built against **AdCP 3.1.15 stable**, makes canonical
creatives the primary Python contract, and negotiates AdCP 3.0, 3.1, and 3.2
wire dialects. The SDK package version and protocol version are intentionally
independent; AdCP 3.2 beta support will land separately:

```python
import adcp

adcp.get_adcp_sdk_version() # SDK package version, e.g. "7.0.0rc1"
adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.14"
adcp.get_adcp_sdk_version() # SDK package version, e.g. "8.0.0b1"
adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.1.15"
```

If you talk to an agent on a newer spec than this SDK validates, the response
Expand All @@ -298,7 +298,8 @@ forward traffic degrades gracefully rather than failing.
- **[API Reference](https://adcontextprotocol.github.io/adcp-client-python/)** - Complete API documentation with type signatures and examples
- **[Protocol Spec](https://github.com/adcontextprotocol/adcp)** - Ad Context Protocol specification
- **[Handler authoring](docs/handler-authoring.md)** - Building an AdCP-compliant agent on `adcp.server`
- **[Migrating from SDK 6 to 7](MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes
- **[Migrating from SDK 6 to 7](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v6_to_v7.md)** - Breaking API, security, concurrency, and webhook changes
- **[Migrating from SDK 7 to 8](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v7_to_v8.md)** - Secure webhook defaults and telemetry changes
- **[Testing your AdCP server](docs/testing-your-adcp-server.md)** - In-process harness for unit tests plus storyboard-runner compliance grading
- **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy
- **[Examples](examples/)** - Code examples and usage patterns
Expand Down Expand Up @@ -494,41 +495,53 @@ for result in results:
print(f"Async: webhook to {result.submitted.webhook_url}")
```

### Webhook Handling
Single endpoint handles all webhooks:
### Legacy HMAC webhook handling

For registrations that explicitly select `HMAC-SHA256`, a single endpoint can
route callbacks through the client helper. Capture the raw body before parsing;
those exact bytes are what the signature authenticates:

```python
import json
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/webhook/{task_type}/{agent_id}/{operation_id}")
async def webhook(task_type: str, agent_id: str, operation_id: str, request: Request):
payload = await request.json()
payload["task_type"] = task_type
payload["operation_id"] = operation_id
raw_body = await request.body()
payload = json.loads(raw_body)

# Route to agent client - handlers fire automatically
agent = client.agent(agent_id)
await agent.handle_webhook(
payload,
request.headers.get("x-adcp-signature")
payload=payload,
task_type=task_type,
operation_id=operation_id,
signature=request.headers.get("x-adcp-signature"),
timestamp=request.headers.get("x-adcp-timestamp"),
raw_body=raw_body,
)

return {"received": True}
```

### Security
Webhook signature verification built-in:
### Legacy HMAC callback verification

For AdCP 3.x registrations that explicitly select the deprecated
`HMAC-SHA256` authentication mode, shared-secret verification is available on
the client helper:

```python
client = ADCPMultiAgentClient(
agents=agents,
webhook_secret=os.getenv("WEBHOOK_SECRET")
)
# Signatures verified automatically on handle_webhook()
# Legacy HMAC signatures are verified on handle_webhook().
```

For the protocol-default RFC 9421 mode, use `WebhookReceiver` as shown below.

### Signed webhooks (AdCP 3.0): receiver quickstart

AdCP 3.0 webhooks are signed under the RFC 9421 profile
Expand Down
65 changes: 45 additions & 20 deletions examples/v3_reference_seller/src/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@
)
from adcp.types.legacy import (
LegacyFormat,
LegacyFormatId,
)
from adcp.types.legacy import (
LegacyListCreativeFormatsRequest as ListCreativeFormatsRequest,
Expand Down Expand Up @@ -2019,32 +2020,56 @@ async def list_creative_formats_legacy(
format-list endpoint (formats are publisher-defined, baked
into the upstream's product catalog). Real adopters drive this
from a creative-format registry."""
del req, ctx
del ctx
agent_url = "https://reference.adcp.org"

def catalog_format(format_id: str, name: str, description: str) -> LegacyFormat:
# The generated FormatReferenceStructuredObject uses AnyUrl, which
# appends a slash to origin-only URLs. Legacy format identity is a
# byte-preserving tuple, so construct the trusted static catalog
# with the SDK's wire-preserving boundary type.
return LegacyFormat.model_construct(
format_id=LegacyFormatId(agent_url=agent_url, id=format_id),
name=name,
description=description,
)

formats = [
LegacyFormat.model_validate(
{
"format_id": {"agent_url": agent_url, "id": "display_300x250"},
"name": "Display 300x250 (medium rectangle)",
"description": "IAB standard 300x250 display banner.",
}
catalog_format(
"display_300x250",
"Display 300x250 (medium rectangle)",
"IAB standard 300x250 display banner.",
),
LegacyFormat.model_validate(
{
"format_id": {"agent_url": agent_url, "id": "display_728x90"},
"name": "Display 728x90 (leaderboard)",
"description": "IAB standard 728x90 display banner.",
}
catalog_format(
"display_728x90",
"Display 728x90 (leaderboard)",
"IAB standard 728x90 display banner.",
),
LegacyFormat.model_validate(
{
"format_id": {"agent_url": agent_url, "id": "video_16x9_30s"},
"name": "Video 16:9 30s",
"description": "Standard 30-second 16:9 video creative.",
}
catalog_format(
"video_30s",
"Video 30s",
"Standard 30-second video creative.",
),
catalog_format(
"video_16x9_30s",
"Video 16:9 30s",
"Standard 30-second 16:9 video creative.",
),
]
self._record("creatives.formats", {})
if req.format_ids:
requested = {
(str(format_id.agent_url).rstrip("/"), format_id.id) for format_id in req.format_ids
}
formats = [
format_
for format_ in formats
if (
str(format_.format_id.agent_url).rstrip("/"),
format_.format_id.id,
)
in requested
]
self._record("creatives.formats", {"format_ids": len(req.format_ids or [])})
return ListCreativeFormatsResponse(formats=formats)

# ----- list_creatives --------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions examples/v3_reference_seller/tests/test_smoke_broadening.py
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,34 @@ async def test_list_creative_formats_is_static_no_upstream_call() -> None:
assert respx_mock.calls.call_count == 0


@pytest.mark.asyncio
async def test_list_creative_formats_filters_requested_ids() -> None:
"""The static catalog honors the exact IDs returned by get_products."""
from adcp.types import LegacyListCreativeFormatsRequest

platform = _platform_with_upstream()
ctx = _build_ctx()
resp = await platform.list_creative_formats_legacy(
LegacyListCreativeFormatsRequest.model_validate(
{
"format_ids": [
{
"agent_url": "https://reference.adcp.org/",
"id": "video_30s",
}
]
}
),
ctx,
)

assert [format_.format_id.id for format_ in resp.formats] == ["video_30s"]
assert resp.model_dump(mode="json")["formats"][0]["format_id"] == {
"agent_url": "https://reference.adcp.org",
"id": "video_30s",
}


@pytest.mark.asyncio
@respx.mock(base_url=_RESPX_BASE_URL)
async def test_update_media_buy_rejects_foreign_advertiser_order(respx_mock: Any) -> None:
Expand Down
3 changes: 3 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"release-type": "python",
"package-name": "adcp",
"changelog-path": "CHANGELOG.md",
"versioning": "prerelease",
"prerelease-type": "beta",
"prerelease": true,
"bump-minor-pre-major": true,
"bump-patch-for-minor-pre-major": false,
"include-component-in-tag": false
Expand Down
Loading
Loading