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
20 changes: 20 additions & 0 deletions MIGRATION_v6_to_v7.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,23 @@ AdCP 3.0 is upgraded on reads and downgraded on writes. AdCP 3.1 requires the
`media_buy.features.canonical_creatives` capability or unambiguous
request-local evidence. AdCP 3.2 will be canonical by contract once supported;
advertising `canonical_creatives: false` there will be an error.

## Synchronous completion webhooks

`auto_emit_completion_webhooks` now defaults to `False`. AdCP forbids a task
webhook when the initial response is already terminal: the result is available
inline and no registry task exists for a webhook `task_id`.

If an existing buyer depends on receiving both copies, temporarily pass
`auto_emit_completion_webhooks=True` to `serve()` or
`create_adcp_server_from_platform()`. This retains the former behavior as a
non-conformant compatibility extension with a synthetic, unpollable `sync-*`
task ID. Update the buyer to consume the inline result, then remove the opt-in.

This setting only controls synthetic synchronous-completion delivery. Terminal
webhooks for real `TaskHandoff` requests remain enabled when the request supplies
`push_notification_config` and a webhook sender or supervisor is configured. The
framework rejects a push-configured handoff before task creation when no transport
is available, rather than returning `submitted` and silently dropping the callback.
Adopters that deliver terminal task webhooks themselves can set the independent
`auto_emit_task_webhooks=False` ownership flag.
32 changes: 22 additions & 10 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -1277,16 +1277,28 @@ MCP for production agents.

## Webhooks

When `auto_emit_completion_webhooks=True` (the default), the framework fires a
sync-completion webhook after every successfully-dispatched tool call whose task
type is in the spec's webhook-eligible set (`create_media_buy`, `activate_signal`,
and their siblings). Buyers who register `push_notification_config.url` receive
these notifications automatically.

The framework requires a sender or supervisor at boot — it raises `AdcpError`
rather than silently dropping notifications if neither is wired and auto-emit is on.
Set `auto_emit_completion_webhooks=False` only if you emit webhooks manually inside
your platform methods.
Synchronous terminal responses do not emit task webhooks. This is the default:
`auto_emit_completion_webhooks=False`. The buyer already has the result inline,
and no registry task exists for a webhook `task_id`.

`TaskHandoff` is different. When the initial response is `submitted` and the
request includes `push_notification_config`, the framework delivers the required
terminal completion or failure webhook through the configured sender or supervisor.
The sync-completion compatibility flag does not disable that async delivery. A
push-configured handoff is rejected before task creation when no delivery transport
is configured, so the server cannot return `submitted` and then silently drop the
required callback.

`auto_emit_task_webhooks=True` controls framework ownership of these real task
notifications. Set it to `False` only when adopter code owns terminal webhook
delivery itself. This is separate from `auto_emit_completion_webhooks`, which only
controls the legacy synthetic sync behavior.

Existing integrations that relied on duplicate inline and webhook delivery can
temporarily set `auto_emit_completion_webhooks=True`. This is a non-conformant
compatibility extension: it synthesizes an unpollable `sync-*` task ID. The
framework requires a sender or supervisor at boot when this mode is enabled.
Migrate buyers to consume the inline terminal response, then remove the opt-in.

### Sender constructors

Expand Down
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Related skills: `build-seller-agent`, `build-generative-seller-agent`,

## Webhooks

- [`hello_seller_with_webhooks.py`](hello_seller_with_webhooks.py) — wire an `InMemoryWebhookDeliverySupervisor` so completion webhooks reach buyers who register `push_notification_config.url`; uses `WebhookSender.from_bearer_token`.
- [`hello_seller_with_webhooks.py`](hello_seller_with_webhooks.py) — legacy, non-conformant sync-completion compatibility example using `InMemoryWebhookDeliverySupervisor`; normal `TaskHandoff` notifications require no sync opt-in.

See `docs/handler-authoring.md#webhooks` for the full `WebhookSender`
constructor comparison (bearer vs RFC 9421 JWK signing).
Expand Down
8 changes: 3 additions & 5 deletions examples/hello_seller.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,10 +373,8 @@ def _get_packages(req: Any) -> list[dict[str, Any]]:
# server. Default port 3001 over streamable-http; override via
# ``serve(seller, port=...)``.
#
# ``auto_emit_completion_webhooks=False`` opts out here because this
# example has no signing key. Production sellers want webhooks on so
# buyers who register ``push_notification_config.url`` get sync-
# completion notifications. Pick a constructor and pass
# Synchronous terminal responses do not emit task webhooks. For real
# TaskHandoff completion notifications, pick a constructor and pass
# ``webhook_supervisor=`` (retry + circuit breaker, recommended) or
# ``webhook_sender=`` (transport only):
#
Expand All @@ -399,4 +397,4 @@ def _get_packages(req: Any) -> list[dict[str, Any]]:
# serve(HelloSeller(), name="hello-seller", webhook_supervisor=supervisor)
#
# See docs/handler-authoring.md#webhooks for the full wiring recipe.
serve(HelloSeller(), name="hello-seller", auto_emit_completion_webhooks=False)
serve(HelloSeller(), name="hello-seller")
8 changes: 4 additions & 4 deletions examples/hello_seller_async_handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,9 @@ def _echo_packages(req: Any) -> list[dict[str, Any]]:
serve(
HelloSellerHybrid(),
name="hello-seller-hybrid",
# Opt out of F12 auto-emit so the example boots without a
# ``webhook_sender``. Production sellers wire ``webhook_sender=``
# so buyers who register ``push_notification_config.url`` get
# completion notifications when their TaskHandoff finishes.
# Pin the conformant sync-completion default explicitly.
# Production sellers wire ``webhook_sender=`` so buyers who
# register ``push_notification_config.url`` get completion
# notifications when their TaskHandoff finishes.
auto_emit_completion_webhooks=False,
)
5 changes: 2 additions & 3 deletions examples/hello_seller_audience.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ def sync_audiences(
def main() -> None:
"""Boot the seller on http://localhost:3001/mcp.

``auto_emit_completion_webhooks=False`` opts out so this example
boots without a ``webhook_sender``. In production, wire
``webhook_sender=`` for buyer notification.
Synchronous terminal responses remain inline-only by default. Wire
``webhook_sender=`` when adding ``TaskHandoff`` support.
"""
serve(HelloAudienceSeller(), auto_emit_completion_webhooks=False)

Expand Down
5 changes: 2 additions & 3 deletions examples/hello_seller_brand_rights.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,8 @@ def acquire_rights(
def main() -> None:
"""Boot the seller on http://localhost:3001/mcp.

``auto_emit_completion_webhooks=False`` opts out so this example
boots without a ``webhook_sender``. In production, wire
``webhook_sender=`` for buyer notification.
Synchronous terminal responses remain inline-only by default. Wire
``webhook_sender=`` when adding ``TaskHandoff`` support.
"""
serve(HelloBrandRightsSeller(), auto_emit_completion_webhooks=False)

Expand Down
5 changes: 2 additions & 3 deletions examples/hello_seller_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,8 @@ def sync_catalogs(self, req: Any, ctx: RequestContext[Any]) -> list[dict[str, An
def main() -> None:
"""Boot the seller on http://localhost:3001/mcp.

``auto_emit_completion_webhooks=False`` opts out so this example
boots without a ``webhook_sender``. In production, wire
``webhook_sender=`` for buyer notification.
Synchronous terminal responses remain inline-only by default. Wire
``webhook_sender=`` when adding ``TaskHandoff`` support.
"""
serve(HelloCatalogSeller(), auto_emit_completion_webhooks=False)

Expand Down
9 changes: 4 additions & 5 deletions examples/hello_seller_creative.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,16 +102,15 @@ def main() -> None:
governance tools (per-specialism filter).
* ``tools/call build_creative`` returns the synthesized manifest.

The ``auto_emit_completion_webhooks=False`` opt-out keeps this
example minimal. In production, wire ``webhook_sender=`` so
buyers who register ``push_notification_config.url`` get
completion notifications:
Synchronous terminal responses remain inline-only. If this seller
returns a ``TaskHandoff``, wire ``webhook_sender=`` so buyers who
register ``push_notification_config.url`` get terminal notifications:

from adcp.webhook_sender import WebhookSender
sender = WebhookSender.from_jwk(...)
serve(HelloCreativeSeller(), webhook_sender=sender)
"""
serve(HelloCreativeSeller(), auto_emit_completion_webhooks=False)
serve(HelloCreativeSeller())


if __name__ == "__main__":
Expand Down
7 changes: 3 additions & 4 deletions examples/hello_seller_signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,13 @@ async def _async_activation(self, task_ctx: Any) -> dict[str, Any]:
def main() -> None:
"""Boot the seller on http://localhost:3001/mcp.

``auto_emit_completion_webhooks=False`` opts out of the sync
completion-webhook auto-emit so this example boots without a
``webhook_sender``. In production, wire ``webhook_sender=`` so
Synchronous terminal responses remain inline-only by default. In
production, wire ``webhook_sender=`` so
buyers who register ``push_notification_config.url`` on
``activate_signal`` get notifications when a TaskHandoff
completes.
"""
serve(HelloSignalsSeller(), auto_emit_completion_webhooks=False)
serve(HelloSignalsSeller())


if __name__ == "__main__":
Expand Down
14 changes: 9 additions & 5 deletions examples/hello_seller_with_webhooks.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
"""Hello-seller-with-webhooks — canonical ``WebhookSender`` + supervisor wiring.

Extends ``hello_seller.py`` with a wired :class:`InMemoryWebhookDeliverySupervisor`
so sync-completion webhooks are delivered to buyers who register
``push_notification_config.url``. Uses :meth:`WebhookSender.from_bearer_token`
as the auth mode — no key management, simplest first step.
and explicitly enables the legacy sync-completion compatibility mode. Uses
:meth:`WebhookSender.from_bearer_token` as the auth mode — no key management,
simplest first step.

Run::

WEBHOOK_BEARER_TOKEN=<your-token> uv run python examples/hello_seller_with_webhooks.py

The server boots on http://localhost:3001/mcp. Any buyer that registers
The server boots on http://localhost:3001/mcp. Any buyer that registers
``push_notification_config.url`` on a ``create_media_buy`` request receives a
completion notification POSTed with ``Authorization: Bearer <token>``.
duplicate completion notification POSTed with ``Authorization: Bearer <token>``.
This behavior is non-conformant and exists only to migrate integrations that
depended on the SDK's former default. New integrations consume the inline
terminal response; normal async ``TaskHandoff`` notifications need no opt-in.

To use RFC 9421 JWK signing instead (AdCP spec baseline, required for buyers
that verify body signatures), swap :meth:`~WebhookSender.from_bearer_token`
Expand Down Expand Up @@ -55,4 +58,5 @@
HelloSeller(),
name="hello-seller-with-webhooks",
webhook_supervisor=supervisor,
auto_emit_completion_webhooks=True,
)
18 changes: 7 additions & 11 deletions examples/v3_reference_seller/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,10 +293,9 @@ def main() -> None:
# with ``mode='live'`` in their ``AccountStore.resolve`` and declare
# :attr:`V3ReferenceSeller.upstream_url` to their production URL.
# Wire the webhook supervisor iff signing material is present. When
# the env vars are unset, the seller falls back to the
# ``auto_emit_completion_webhooks=False`` posture below — a buyer
# registering ``push_notification_config.url`` will not receive
# auto-emitted completion webhooks, but boot succeeds without a key.
# the env vars are unset, a buyer registering
# ``push_notification_config.url`` cannot receive TaskHandoff terminal
# webhooks, but boot succeeds without a key.
# The framework's #384 validator binds these two posture knobs
# together: capabilities advertise signing iff the supervisor is
# wired with an RFC 9421 key.
Expand Down Expand Up @@ -390,14 +389,11 @@ def main() -> None:
if debug_token is not None
else None
),
# Auto-emit binds to the supervisor: when a webhook-signing PEM
# is wired via the ADCP_WEBHOOK_SIGNING_KEY_PATH env var, the
# supervisor signs every auto-emitted completion webhook per
# RFC 9421 and the seller advertises the matching capability.
# When unwired, auto-emit stays off so the F12 boot gate doesn't
# trip on the missing sender (no silent webhook drops).
# When a webhook-signing PEM is wired, the supervisor signs
# spec-required TaskHandoff terminal webhooks per RFC 9421 and
# the seller advertises the matching capability. Synchronous
# terminal responses remain inline-only.
webhook_supervisor=webhook_supervisor,
auto_emit_completion_webhooks=webhook_supervisor is not None,
# FastMCP's TransportSecurityMiddleware enforces DNS-rebinding
# protection: its default ``allowed_hosts`` accepts only
# loopback (``127.0.0.1:*``, ``localhost:*``, ``[::1]:*``), so
Expand Down
8 changes: 4 additions & 4 deletions scripts/run_emma_matrix.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ SDK at ${ADCP_SDK_PATH}. Setup:
Build:
1. DecisioningPlatform subclass claiming \`sales-non-guaranteed\`.
2. Stub all 5 required sales methods with believable in-memory state.
3. CRITICAL: pass \`auto_emit_completion_webhooks=False\` to \`serve()\` (boot-time webhook gate from PR #339 requires this when no webhook_sender is wired).
3. Rely on the conformant \`auto_emit_completion_webhooks=False\` default; do not enable the legacy sync-completion compatibility mode.
4. Boot via \`from adcp.decisioning import serve\` and hit it with: tools/list, get_products, create_media_buy, sync_creatives, get_media_buy_delivery via the mcp client.
5. Verify tools/list shows ONLY sales tools (per-specialism filter from PR #339) — should NOT include build_creative, acquire_rights, check_governance, get_signals.

Expand Down Expand Up @@ -85,7 +85,7 @@ Build:
2. get_signals returns a small catalog (3 signals: demographic, in-market, purchase-intent).
3. activate_signal sync-success arm.
4. CRITICAL: on a SECOND activate_signal call, return a TaskHandoff via ctx.handoff_to_task. Verify framework projects to {task_id, status:"submitted"}.
5. CRITICAL: pass \`auto_emit_completion_webhooks=False\` to \`serve()\`.
5. Rely on the conformant \`auto_emit_completion_webhooks=False\` default.
6. Boot, hit tools/list + get_signals + activate_signal (sync) + activate_signal (handoff).
7. Verify tools/list narrows to just signals tools (per-specialism filter from PR #339).

Expand Down Expand Up @@ -122,7 +122,7 @@ SDK at ${ADCP_SDK_PATH}. Setup:
Build:
1. DecisioningPlatform subclass claiming \`creative-generative\`.
2. build_creative wires AudioStack Generate API (mock the key, stub realistic responses).
3. CRITICAL: pass \`auto_emit_completion_webhooks=False\` to \`serve()\`.
3. Rely on the conformant \`auto_emit_completion_webhooks=False\` default.
4. Boot, hit tools/list and build_creative.
5. Verify tools/list narrows to creative tools only (no sales/signals/governance leaks).

Expand Down Expand Up @@ -153,7 +153,7 @@ SDK at ${ADCP_SDK_PATH}. Setup:
Build:
1. DecisioningPlatform subclass claiming \`creative-generative\`.
2. build_creative wires Stability /v2beta/stable-image/generate (mock key, realistic shape).
3. CRITICAL: pass \`auto_emit_completion_webhooks=False\` to \`serve()\`.
3. Rely on the conformant \`auto_emit_completion_webhooks=False\` default.
4. Test the SHORTHAND ergonomic arm: return a bare CreativeManifest (Pydantic model), NOT a fully-shaped BuildCreativeSuccessResponse.
5. CRITICAL: deliberately make a mistake on the first attempt (e.g., omit width/height on ImageContent). Verify the error response is FOCUSED (just ImageAsset.width / ImageAsset.height) — NOT the 60-line dump that 5/10 verdict reported pre-PR-#340.
6. Boot, hit tools/list and build_creative.
Expand Down
48 changes: 39 additions & 9 deletions src/adcp/decisioning/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@
is_task_handoff,
is_workflow_handoff,
)
from adcp.decisioning.webhook_emit import emit_terminal_completion_webhook
from adcp.decisioning.webhook_emit import (
SPEC_WEBHOOK_TASK_TYPES,
_extract_push_notification_url_and_token,
emit_terminal_completion_webhook,
)

if TYPE_CHECKING:
from collections.abc import Awaitable, Callable
Expand Down Expand Up @@ -1368,10 +1372,11 @@ async def _invoke_platform_method(
(async) arm uses it — the sync arm's auto-emit is a separate
call in the handler shim.

:param webhook_auto_emit: Forwarded to :func:`_project_handoff`;
mirrors the handler's ``auto_emit_completion_webhooks`` so an
adopter emitting webhooks manually never gets a framework
double-delivery on the handoff path.
:param webhook_auto_emit: Forwarded to :func:`_project_handoff` as
the async task-webhook delivery gate. Production handlers keep
this enabled because a submitted task with push configuration
requires a terminal webhook. Direct low-level callers may
disable it when they own that delivery themselves.

:param pre_handoff_reject: Optional zero-arg callback invoked when
the adapter returned a :class:`TaskHandoff`, BEFORE
Expand Down Expand Up @@ -1705,16 +1710,41 @@ async def _project_handoff(
``None`` (and the no-push case) skips delivery — the buyer polls
``tasks/get`` instead. The framework's polling path is unchanged.

:param webhook_auto_emit: Mirrors the handler's
``auto_emit_completion_webhooks`` flag. When ``False`` the
adopter emits webhooks manually inside their handler; the
framework skips the terminal emission so it never double-delivers.
:param webhook_auto_emit: Async task-webhook delivery gate. The
production handler defaults this to ``True`` independently of
the legacy sync-completion compatibility flag. Callers pass
``False`` only when they own terminal task delivery.

The handoff fn is extracted via the type-identity dispatch in
:func:`adcp.decisioning.types.is_task_handoff`. Subclassed
TaskHandoff instances (deliberate non-feature) silently take the
sync-return path before reaching this function.
"""
if (
webhook_auto_emit
and method_name in SPEC_WEBHOOK_TASK_TYPES
and _extract_push_notification_url_and_token(request_params) is not None
and webhook_target is None
):
rejection = AdcpError(
"INVALID_REQUEST",
message=(
"push_notification_config requires webhook_sender or "
"webhook_supervisor before this request can enter the "
"TaskHandoff lifecycle"
),
recovery="correctable",
field="push_notification_config",
suggestion=(
"Configure webhook delivery, omit push_notification_config "
"and poll tasks/get, or set auto_emit_task_webhooks=False "
"only when adopter code owns terminal webhook delivery"
),
)
if on_failure is not None:
await _safe_on_failure_call(on_failure, rejection, method_name)
raise rejection

fn = handoff._fn

# Extract the buyer's ``context`` extension from the original
Expand Down
Loading
Loading