Skip to content

Commit 659cd7c

Browse files
bokelleyclaude
andauthored
feat(server): response_enhancer callback (JS #2161 parity) (#933)
* feat(server): add response_enhancer callback (JS #2161 parity) Add a server-wide response_enhancer callback that stamps cross-cutting fields on every response class — framework-tool successes, custom-tool successes (get_task_status / list_tasks), the pre-auth get_adcp_capabilities discovery response, and structured adcp_error responses — uniformly across the MCP and A2A transports. The Python SDK has no single response finalizer, so the enhancer is wired at the three dispatch seams that converge the response classes, all AFTER inject_context (so a buggy enhancer cannot re-introduce a stripped credential into the echo envelope): 1. Success — create_tool_caller (mcp_tools.py): after inject_context and before validate_response, so a conformance-breaking mutation surfaces as VALIDATION_ERROR rather than shipping malformed. Covers framework tools, custom tools, and get_adcp_capabilities on both transports. 2. MCP errors — build_mcp_error_result (translate.py). 3. A2A errors — _send_adcp_error (a2a_server.py). Also closes the A2A comply_test_controller gap: that skill bypasses create_tool_caller via _call_test_controller, so the enhancer is applied there too (context echoed first to preserve the credential-echo invariant). The enhancer supports both a context-blind Callable[[dict], None] and a context-aware Callable[[str, dict, ToolContext | None], None], dispatched by signature arity. It runs synchronously, mutates in place, and a raised exception is caught + logged at WARNING — a buggy enhancer must not become a transport error. response_enhancer threads through ServeConfig, serve(), all three _serve_* transport paths, create_mcp_server / create_a2a_server, and the in-process build_asgi_app / build_test_client test harness. adcp.decisioning.serve.serve forwards it via **serve_kwargs. The ResponseEnhancer type alias is exported from adcp.server (matching ServeConfig / SkillMiddleware; adcp.__all__ snapshot unchanged). Idempotency note: the cache commits the pre-enhancement response, so a replay re-runs the enhancer — non-idempotent enhancers diverge on replay. Closes #926 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(server): document response_enhancer coverage seams The ResponseEnhancer / serve() docstrings claimed the enhancer applies to "every response". Two paths are not enhanced: - the MCP comply_test_controller sandbox path returns the handler result directly without a success-path enhancer call (its A2A counterpart is stamped); - a handler that returns (vs raises) a raw L3 {"adcp_error": {...}} envelope is skipped by the success-path guard and never reaches the raised-error finalizers. Document the exact enhanced paths and these two gaps on the canonical ResponseEnhancer docstring, point _apply_response_enhancer at it, and soften the serve() docstrings from "every response". Docstring-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c23b406 commit 659cd7c

9 files changed

Lines changed: 922 additions & 17 deletions

File tree

src/adcp/decisioning/serve.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,9 @@ def serve(
527527
:param serve_kwargs: Forwarded to :func:`adcp.server.serve`. Use
528528
for ``host``, ``port``, ``transport``, ``test_controller``,
529529
``context_factory``, ``middleware``, ``validation``,
530+
``response_enhancer`` (a server-wide
531+
:data:`~adcp.server.ResponseEnhancer` applied to every response
532+
on both transports),
530533
``config`` (:class:`adcp.server.ServeConfig` bundle), etc.
531534
Pass ``config=ServeConfig(transport="a2a", ...)`` to supply
532535
all server options as a single typed object rather than

src/adcp/server/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ async def get_products(params, context=None):
9999
TERMINAL_CODES,
100100
TRANSIENT_CODES,
101101
AccountError,
102+
ResponseEnhancer,
102103
adcp_error,
103104
cancel_media_buy_response,
104105
inject_context,
@@ -212,6 +213,7 @@ async def get_products(params, context=None):
212213
"MCPToolSet",
213214
"MCPSessionStats",
214215
"RequestMetadata",
216+
"ResponseEnhancer",
215217
"ServeConfig",
216218
"create_mcp_tools",
217219
"create_mcp_server",

src/adcp/server/a2a_server.py

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737

3838
from adcp.exceptions import ADCPError
3939
from adcp.server.base import ADCPHandler, ToolContext
40+
from adcp.server.helpers import ResponseEnhancer, _apply_response_enhancer
4041
from adcp.server.spec_compat import PreValidationHooks
4142

4243
# Decisioning-layer ``AdcpError`` (from ``adcp.decisioning.types``) is the
@@ -200,10 +201,12 @@ def __init__(
200201
validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION,
201202
pre_validation_hooks: PreValidationHooks | None = None,
202203
test_controller_account_resolver: Any | None = None,
204+
response_enhancer: ResponseEnhancer | None = None,
203205
) -> None:
204206
self._handler = handler
205207
self._context_factory = context_factory
206208
self._test_controller_account_resolver = test_controller_account_resolver
209+
self._response_enhancer = response_enhancer
207210
# Store as a tuple so the executor can't be mutated from underneath
208211
# at runtime (a flaky test or a handler reaching self._middleware
209212
# can't corrupt the dispatch chain). Tuple ordering = runtime
@@ -232,6 +235,7 @@ def __init__(
232235
validation=validation,
233236
pre_validation_hook=hook,
234237
default_unnegotiated_adcp_version=None,
238+
response_enhancer=response_enhancer,
235239
)
236240

237241
if test_controller is not None:
@@ -253,16 +257,33 @@ def _register_test_controller(self, store: TestControllerStore) -> None:
253257
"""
254258

255259
resolver = self._test_controller_account_resolver
260+
response_enhancer = self._response_enhancer
256261

257262
async def _call_test_controller(
258263
params: dict[str, Any], context: ToolContext | None = None
259264
) -> Any:
260-
return await _handle_test_controller(
265+
result = await _handle_test_controller(
261266
store,
262267
params,
263268
context=context,
264269
account_resolver=resolver,
265270
)
271+
# This skill bypasses ``create_tool_caller`` (the success-path
272+
# enhancer site), so apply the enhancer here too — otherwise
273+
# comply responses would silently skip the seller's
274+
# cross-cutting stamp. Echo context first so the enhancer runs
275+
# after the credential-stripped envelope is assembled (the
276+
# later ``_send_result`` ``inject_context`` then no-ops),
277+
# preserving the credential-echo invariant the other sites
278+
# uphold.
279+
if isinstance(result, dict):
280+
from adcp.server.helpers import inject_context
281+
282+
inject_context(params, result)
283+
_apply_response_enhancer(
284+
response_enhancer, "comply_test_controller", result, context
285+
)
286+
return result
266287

267288
self._tool_callers["comply_test_controller"] = _call_test_controller
268289

@@ -303,7 +324,9 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
303324
# channel is reserved for transport-level errors (auth
304325
# rejected, rate-limited pre-dispatch).
305326
logger.info("AdCP application error for skill %s: %s", skill_name, exc)
306-
await self._send_adcp_error(event_queue, context, exc, params)
327+
await self._send_adcp_error(
328+
event_queue, context, exc, params, skill_name=skill_name, tool_context=tool_context
329+
)
307330
except Exception:
308331
logger.exception("Error executing skill %s", skill_name)
309332
await self._send_error(event_queue, context, f"Skill execution failed: {skill_name}")
@@ -512,6 +535,9 @@ async def _send_adcp_error(
512535
context: RequestContext,
513536
exc: Any,
514537
params: dict[str, Any] | None = None,
538+
*,
539+
skill_name: str = "",
540+
tool_context: ToolContext | None = None,
515541
) -> None:
516542
"""Publish a failed task carrying an AdCP ``adcp_error`` payload.
517543
@@ -564,6 +590,13 @@ async def _send_adcp_error(
564590
if params is not None:
565591
inject_context(params, data)
566592

593+
# Run the seller's response enhancer on the error envelope AFTER
594+
# the context echo (so a stripped credential can't be
595+
# re-introduced) — symmetric with the MCP error path
596+
# (``build_mcp_error_result``) and the success path. A buggy
597+
# enhancer is caught and logged inside the helper.
598+
_apply_response_enhancer(self._response_enhancer, skill_name, data, tool_context)
599+
567600
task = _make_task(
568601
context,
569602
state=pb.TaskState.TASK_STATE_FAILED,
@@ -819,15 +852,13 @@ def _validate_card_url(url: str) -> str:
819852
parsed = urlparse(url)
820853
if not parsed.scheme or not parsed.netloc:
821854
raise ValueError(
822-
f"public_url resolver returned {url!r} — "
823-
"must be an absolute URL with scheme and host."
855+
f"public_url resolver returned {url!r} — must be an absolute URL with scheme and host."
824856
)
825857
hostname = parsed.hostname or ""
826858
is_loopback = hostname in ("localhost", "127.0.0.1", "::1") or hostname.endswith(".localhost")
827859
if parsed.scheme != "https" and not is_loopback:
828860
raise ValueError(
829-
f"public_url resolver returned {url!r} — "
830-
"scheme must be 'https' for non-loopback hosts."
861+
f"public_url resolver returned {url!r} — scheme must be 'https' for non-loopback hosts."
831862
)
832863
return url
833864

@@ -941,6 +972,7 @@ def create_a2a_server(
941972
context_builder: Any | None = None,
942973
auth: BearerTokenAuth | None = None,
943974
public_url: str | PublicUrlResolver | None = None,
975+
response_enhancer: ResponseEnhancer | None = None,
944976
) -> Any:
945977
"""Create an A2A Starlette application from an ADCP handler.
946978
@@ -1067,6 +1099,15 @@ def agent_card_url(request: Request) -> str:
10671099
10681100
The ``PUBLIC_URL`` env-var fallback applies only when
10691101
``public_url`` is ``None``; a callable takes priority.
1102+
response_enhancer: Optional server-wide
1103+
:data:`~adcp.server.ResponseEnhancer` applied to every
1104+
response — successes, ``adcp_error`` envelopes, and the
1105+
``comply_test_controller`` skill — after the context echo and
1106+
(for successes) before schema validation. Mirrors the MCP-side
1107+
``create_mcp_server(response_enhancer=...)`` so a single
1108+
callback stamps both transports. See
1109+
:data:`~adcp.server.ResponseEnhancer` for the supported arities
1110+
and failure semantics.
10701111
10711112
Returns:
10721113
A Starlette app ready to be run with uvicorn.
@@ -1088,6 +1129,7 @@ def agent_card_url(request: Request) -> str:
10881129
validation=validation,
10891130
pre_validation_hooks=pre_validation_hooks,
10901131
test_controller_account_resolver=test_controller_account_resolver,
1132+
response_enhancer=response_enhancer,
10911133
)
10921134

10931135
if task_store is None:

src/adcp/server/helpers.py

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@
1313

1414
from __future__ import annotations
1515

16+
import inspect
17+
import logging
1618
import warnings
1719
from collections.abc import Awaitable, Callable
1820
from datetime import datetime, timezone
19-
from typing import Any
21+
from typing import Any, cast
2022

21-
from adcp.server.base import AccountAwareToolContext
23+
from adcp.server.base import AccountAwareToolContext, ToolContext
24+
25+
logger = logging.getLogger("adcp.server")
2226

2327
# All 32 codes from the ADCP spec (enums/error-code.json) plus SDK extensions.
2428
# Recovery classification: transient (retry), correctable (fix request), terminal.
@@ -380,6 +384,138 @@ def inject_context(
380384
return response
381385

382386

387+
# ============================================================================
388+
# Response Enhancer
389+
# ============================================================================
390+
391+
ResponseEnhancer = (
392+
Callable[[dict[str, Any]], None] | Callable[[str, dict[str, Any], "ToolContext | None"], None]
393+
)
394+
"""Server-wide callback that stamps cross-cutting fields on responses.
395+
396+
Configure it via ``serve(response_enhancer=...)`` (or the matching
397+
:class:`~adcp.server.ServeConfig` field). The framework calls it after the
398+
context-echo envelope is assembled and before schema validation.
399+
400+
Coverage — paths the enhancer runs on, both MCP and A2A unless noted:
401+
402+
- framework-tool successes;
403+
- custom-tool successes (``get_task_status`` / ``list_tasks``);
404+
- the pre-auth ``get_adcp_capabilities`` discovery response;
405+
- error responses produced by a *raised* ``AdcpError`` /
406+
``ADCPTaskError`` (including credential-policy errors), which the
407+
transport error finalizers stamp. The common ``adcp_error()`` helper
408+
shape (``{"errors": [...]}``) carries no top-level ``adcp_error`` key,
409+
so a handler that returns it is stamped on the success path;
410+
- the A2A ``comply_test_controller`` sandbox skill.
411+
412+
Coverage gaps — paths the enhancer does NOT run on:
413+
414+
- the **MCP** ``comply_test_controller`` sandbox path
415+
(``test_controller.py``); it returns the handler result directly
416+
without a success-path enhancer call, so its A2A counterpart is
417+
stamped but its MCP counterpart is not. Sandbox-only.
418+
- a handler that *returns* (rather than raises) a raw AdCP L3 envelope
419+
``{"adcp_error": {...}}``. The success-path guard
420+
(``if "adcp_error" not in result``) skips it, and a returned envelope
421+
never reaches the raised-error finalizers, so it ships un-enhanced.
422+
Raise the error (or return the ``{"errors": [...]}`` helper shape) to
423+
have it stamped.
424+
425+
Two arities are supported, dispatched by positional-parameter count:
426+
427+
- **Context-blind** ``(result_dict) -> None`` — the common case; mutate the
428+
response dict in place to stamp a field on each response it runs on.
429+
- **Context-aware** ``(method_name, result_dict, context) -> None`` — when
430+
the stamp depends on the tool or the caller. ``context`` is the
431+
:class:`~adcp.server.ToolContext` for this dispatch, or ``None`` for an
432+
unauthenticated / pre-auth discovery call.
433+
434+
The enhancer mutates the response dict in place; its return value is
435+
ignored. It runs **synchronously** (it is not awaited). A raised exception
436+
is caught and logged at ``WARNING`` — the un-enhanced response ships rather
437+
than turning a buggy enhancer into a transport error.
438+
439+
Because the enhancer runs *after* the wire response is stripped of any
440+
credential the buyer echoed in ``context``, it cannot re-introduce a
441+
credential into the response envelope.
442+
443+
Idempotency note: the server-side idempotency cache commits the
444+
*pre-enhancement* response, so a replayed request re-runs the enhancer.
445+
Non-idempotent enhancers (timestamps, random IDs) will therefore diverge
446+
between the original response and its replays.
447+
"""
448+
449+
450+
def _enhancer_is_context_aware(enhancer: ResponseEnhancer) -> bool:
451+
"""Return ``True`` when *enhancer* takes the 3-arg context-aware shape.
452+
453+
Dispatch is by positional-parameter arity: a single positional
454+
parameter is the context-blind ``(result_dict)`` shape; three is the
455+
context-aware ``(method_name, result_dict, context)`` shape. A callable
456+
with ``*args`` is treated as context-aware so adopters writing a
457+
catch-all signature still receive the method name and context.
458+
459+
Signature introspection failures (C callables, exotic wrappers) fall
460+
back to the context-blind shape — the safe default that matches the
461+
most common adopter intent.
462+
"""
463+
try:
464+
sig = inspect.signature(enhancer)
465+
except (TypeError, ValueError):
466+
return False
467+
positional = [
468+
p for p in sig.parameters.values() if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
469+
]
470+
has_var_positional = any(p.kind is p.VAR_POSITIONAL for p in sig.parameters.values())
471+
return len(positional) >= 3 or has_var_positional
472+
473+
474+
def _apply_response_enhancer(
475+
enhancer: ResponseEnhancer | None,
476+
method_name: str,
477+
result: dict[str, Any],
478+
context: ToolContext | None,
479+
) -> dict[str, Any]:
480+
"""Run the configured *enhancer* against *result*, mutating it in place.
481+
482+
Returns the same ``result`` dict reference (no clone) so callers can use
483+
the return value or the original interchangeably. When *enhancer* is
484+
``None`` the dict is returned unchanged.
485+
486+
The enhancer is invoked synchronously. Its return value is ignored — it
487+
must mutate ``result`` in place. A raised exception is caught and logged
488+
at ``WARNING`` (including *method_name*); the original ``result`` is
489+
returned un-enhanced so a buggy enhancer never becomes a transport
490+
error.
491+
492+
This is the per-call shim; whether a given dispatch path reaches it is
493+
decided by the call sites. See :data:`ResponseEnhancer` for the
494+
authoritative list of enhanced paths and the two coverage gaps (the MCP
495+
``comply_test_controller`` sandbox path and handler-returned raw L3
496+
``{"adcp_error": {...}}`` envelopes).
497+
"""
498+
if enhancer is None:
499+
return result
500+
try:
501+
if _enhancer_is_context_aware(enhancer):
502+
context_aware = cast(
503+
"Callable[[str, dict[str, Any], ToolContext | None], None]", enhancer
504+
)
505+
context_aware(method_name, result, context)
506+
else:
507+
context_blind = cast("Callable[[dict[str, Any]], None]", enhancer)
508+
context_blind(result)
509+
except Exception:
510+
logger.warning(
511+
"response_enhancer raised for %s — shipping the un-enhanced "
512+
"response. This is a bug in the enhancer, not in the response.",
513+
method_name,
514+
exc_info=True,
515+
)
516+
return result
517+
518+
383519
# ============================================================================
384520
# Cancellation Helper
385521
# ============================================================================

0 commit comments

Comments
 (0)