From 2d04a4686b702089d81431ad94cd1c9336cb06b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:37:21 +0000 Subject: [PATCH 1/2] feat(server): expose RequestContext.transport and current_transport ContextVar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #617 Adopters building webhook services need to know whether the current request arrived via MCP or A2A so they can select the correct payload shape (Task/TaskStatusUpdateEvent vs McpWebhookPayload). The transport was already known to the dispatcher (via RequestMetadata.transport) but was buried in the opaque ToolContext.metadata dict with no typed surface. - Add `transport: Literal["mcp", "a2a"] | None` field to RequestContext; populated in _build_request_context from tool_ctx.metadata["transport"] (always present in production paths; None only in bare test fixtures) - Add `current_transport: ContextVar[Literal["mcp", "a2a"] | None]` to adcp.server.auth; set in _build_request_context so webhook services called from handlers can read it without a RequestContext in scope - Export current_transport from adcp.server (alongside current_tenant) - Also export current_principal and current_principal_metadata from adcp.server — pre-existing gap where these were accessible only via the private adcp.server.auth module path https://claude.ai/code/session_01UifYgpi26gbfXmhrNRFDvK --- src/adcp/decisioning/context.py | 11 ++++++++++- src/adcp/decisioning/dispatch.py | 8 ++++++++ src/adcp/server/__init__.py | 6 ++++++ src/adcp/server/auth.py | 5 ++++- tests/test_decisioning_dispatch.py | 11 +++++++++++ 5 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/adcp/decisioning/context.py b/src/adcp/decisioning/context.py index 2a40980a..5cb32807 100644 --- a/src/adcp/decisioning/context.py +++ b/src/adcp/decisioning/context.py @@ -18,7 +18,7 @@ from collections.abc import Awaitable, Callable, Mapping from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, ClassVar, Generic +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal from typing_extensions import TypeVar @@ -533,6 +533,14 @@ class RequestContext(ToolContext, Generic[TMeta]): * Idempotency scope? → don't touch; the framework owns this. * Logging request provenance? → log all four; they're cheap. + :param transport: The wire protocol that dispatched this call — + ``"mcp"`` or ``"a2a"``. ``None`` only when ``RequestContext`` + is constructed directly in tests without a transport-aware + ``ToolContext`` (production dispatch always populates this + field). Note: even when the server is started with + ``transport="both"``, individual requests always resolve to + exactly one of ``"mcp"`` or ``"a2a"`` — this field never + carries ``"both"``. :param state: Sync reads of framework-owned in-flight workflow state. Default is :class:`adcp.decisioning.state._NotYetWiredStateReader` — returns empty values + emits one-time UserWarning per @@ -560,6 +568,7 @@ class RequestContext(ToolContext, Generic[TMeta]): auth_info: AuthInfo | None = None auth_principal: str | None = None buyer_agent: BuyerAgent | None = None + transport: Literal["mcp", "a2a"] | None = None now: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) state: StateReader = field(default_factory=_make_default_state_reader) resolve: ResourceResolver = field(default_factory=_make_default_resolver) diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 8e254dde..90c9da1a 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -1059,6 +1059,7 @@ def _build_request_context( # Local import keeps the layering local — read the bearer ContextVar # without forcing a top-level dep on adcp.server.auth. from adcp.server.auth import current_principal as _current_principal + from adcp.server.auth import current_transport as _current_transport if auth_info is None: bearer_principal = _current_principal.get() @@ -1092,6 +1093,12 @@ def _build_request_context( else: caller_identity = tool_ctx.caller_identity + # Extract transport from metadata. In production paths RequestMetadata + # always populates metadata["transport"] before calling the context + # factory; None here means a test fixture supplied a bare ToolContext. + transport = tool_ctx.metadata.get("transport") + _current_transport.set(transport) + # Build the RequestContext with the explicit state/resolve kwargs # if provided; otherwise let the dataclass default factories # supply the v6.0 stubs. @@ -1100,6 +1107,7 @@ def _build_request_context( "caller_identity": caller_identity, "tenant_id": tool_ctx.tenant_id, "metadata": dict(tool_ctx.metadata), + "transport": transport, "account": account, "auth_info": auth_info, "auth_principal": auth_principal, diff --git a/src/adcp/server/__init__.py b/src/adcp/server/__init__.py index 16055573..8521ab93 100644 --- a/src/adcp/server/__init__.py +++ b/src/adcp/server/__init__.py @@ -64,6 +64,9 @@ async def get_products(params, context=None): TokenValidator, auth_context_factory, constant_time_token_match, + current_principal, + current_principal_metadata, + current_transport, validator_from_token_map, ) from adcp.server.base import ( @@ -207,6 +210,9 @@ async def get_products(params, context=None): "TokenValidator", "auth_context_factory", "constant_time_token_match", + "current_principal", + "current_principal_metadata", + "current_transport", "validator_from_token_map", # Idempotency middleware (AdCP #2315 seller side) "IdempotencyStore", diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index 4b27c66c..8a8551e1 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -80,7 +80,7 @@ async def validate_token(token: str) -> Principal | None: from collections.abc import Awaitable, Mapping from contextvars import ContextVar from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Protocol, TypeVar +from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar _V = TypeVar("_V") @@ -192,6 +192,9 @@ def __call__(self, token: str) -> Awaitable[Principal | None]: ... current_principal_metadata: ContextVar[dict[str, Any] | None] = ContextVar( "adcp_auth_principal_metadata", default=None ) +current_transport: ContextVar[Literal["mcp", "a2a"] | None] = ContextVar( + "adcp_transport", default=None +) class BearerTokenAuthMiddleware(BaseHTTPMiddleware): diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 9ce8af80..a4edc1bc 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -410,6 +410,17 @@ def test_build_request_context_threads_account_and_auth() -> None: assert ctx.caller_identity == "caller_x" assert ctx.tenant_id == "tenant_y" assert ctx.metadata == {"foo": "bar"} + # Fixture ToolContext has no "transport" in metadata — transport is None. + assert ctx.transport is None + + +def test_build_request_context_extracts_transport_from_metadata() -> None: + """Transport is lifted from ToolContext.metadata into the typed field.""" + for transport_value in ("mcp", "a2a"): + tool_ctx = ToolContext(metadata={"transport": transport_value, "tool_name": "get_products"}) + account: Account[Any] = Account(id="acct_b") + ctx = _build_request_context(tool_ctx, account, None) + assert ctx.transport == transport_value def test_build_request_context_uses_composite_key_when_store_supplied() -> None: From 78ed926799835539000122250bf72d636a42981c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:46:46 +0000 Subject: [PATCH 2/2] fix(server): address pre-PR review findings on transport field - Validate that metadata["transport"] is "mcp", "a2a", or None before assigning to RequestContext.transport; raises ValueError on invalid values so misconfigured context_factories fail visibly - Strip SDK-internal keys ("transport", "tool_name") from the handler-visible RequestContext.metadata so ctx.transport is the sole typed surface and adopters can't accidentally rely on the dict path - Add Literal import to dispatch.py for the explicit transport annotation - Add current_transport ContextVar assertion and "tool_name" not in ctx.metadata assertions to the parametrized transport extraction test - Extend RequestContext.transport docstring to cross-link current_transport and note that custom context_factories that omit metadata["transport"] also produce None https://claude.ai/code/session_01UifYgpi26gbfXmhrNRFDvK --- src/adcp/decisioning/context.py | 12 +++++++----- src/adcp/decisioning/dispatch.py | 25 ++++++++++++++++++++++--- tests/test_decisioning_dispatch.py | 20 +++++++++++++------- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/src/adcp/decisioning/context.py b/src/adcp/decisioning/context.py index 5cb32807..eaadcc82 100644 --- a/src/adcp/decisioning/context.py +++ b/src/adcp/decisioning/context.py @@ -534,13 +534,15 @@ class RequestContext(ToolContext, Generic[TMeta]): * Logging request provenance? → log all four; they're cheap. :param transport: The wire protocol that dispatched this call — - ``"mcp"`` or ``"a2a"``. ``None`` only when ``RequestContext`` - is constructed directly in tests without a transport-aware - ``ToolContext`` (production dispatch always populates this - field). Note: even when the server is started with + ``"mcp"`` or ``"a2a"``. ``None`` when ``RequestContext`` is + constructed in tests without a transport-aware ``ToolContext``, + or when a custom ``context_factory`` omits + ``metadata["transport"]``. Production dispatch always populates + this field. Note: even when the server is started with ``transport="both"``, individual requests always resolve to exactly one of ``"mcp"`` or ``"a2a"`` — this field never - carries ``"both"``. + carries ``"both"``. For code running outside a handler call + stack, read :data:`adcp.server.current_transport` instead. :param state: Sync reads of framework-owned in-flight workflow state. Default is :class:`adcp.decisioning.state._NotYetWiredStateReader` — returns empty values + emits one-time UserWarning per diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 90c9da1a..785201a9 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -44,7 +44,7 @@ import typing import warnings from concurrent.futures import ThreadPoolExecutor -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from adcp.decisioning.account_projection import ( strip_credentials_from_wire_result, @@ -1096,9 +1096,28 @@ def _build_request_context( # Extract transport from metadata. In production paths RequestMetadata # always populates metadata["transport"] before calling the context # factory; None here means a test fixture supplied a bare ToolContext. - transport = tool_ctx.metadata.get("transport") + raw_transport = tool_ctx.metadata.get("transport") + if raw_transport not in ("mcp", "a2a", None): + raise ValueError( + f"metadata['transport'] must be 'mcp', 'a2a', or absent; got {raw_transport!r}" + ) + transport: Literal["mcp", "a2a"] | None = raw_transport + + # Set the ContextVar for code outside the handler call stack (webhook + # services, background helpers) that don't receive a RequestContext. + # No reset token is saved: asyncio tasks each get their own context + # copy, so set() is task-scoped and doesn't bleed across requests. + # Callers that need the previous value must save/restore it themselves + # (the test suite exercises this via asyncio.copy_context() isolation). _current_transport.set(transport) + # SDK-owned keys set by auth_context_factory / build_context examples + # ("transport", "tool_name") are framework-internal — strip them from + # the handler-visible metadata so adopters can't accidentally rely on + # undocumented dict paths and ctx.transport is the sole typed surface. + _sdk_metadata_keys = frozenset({"transport", "tool_name"}) + clean_metadata = {k: v for k, v in tool_ctx.metadata.items() if k not in _sdk_metadata_keys} + # Build the RequestContext with the explicit state/resolve kwargs # if provided; otherwise let the dataclass default factories # supply the v6.0 stubs. @@ -1106,7 +1125,7 @@ def _build_request_context( "request_id": tool_ctx.request_id, "caller_identity": caller_identity, "tenant_id": tool_ctx.tenant_id, - "metadata": dict(tool_ctx.metadata), + "metadata": clean_metadata, "transport": transport, "account": account, "auth_info": auth_info, diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index a4edc1bc..8f80e108 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -414,13 +414,19 @@ def test_build_request_context_threads_account_and_auth() -> None: assert ctx.transport is None -def test_build_request_context_extracts_transport_from_metadata() -> None: - """Transport is lifted from ToolContext.metadata into the typed field.""" - for transport_value in ("mcp", "a2a"): - tool_ctx = ToolContext(metadata={"transport": transport_value, "tool_name": "get_products"}) - account: Account[Any] = Account(id="acct_b") - ctx = _build_request_context(tool_ctx, account, None) - assert ctx.transport == transport_value +@pytest.mark.parametrize("transport_value", ["mcp", "a2a"]) +def test_build_request_context_extracts_transport_from_metadata(transport_value: str) -> None: + """Transport is lifted from ToolContext.metadata into the typed field and ContextVar.""" + from adcp.server.auth import current_transport + + tool_ctx = ToolContext(metadata={"transport": transport_value, "tool_name": "get_products"}) + account: Account[Any] = Account(id="acct_b") + ctx = _build_request_context(tool_ctx, account, None) + assert ctx.transport == transport_value + assert current_transport.get() == transport_value + # SDK-owned keys are stripped from handler-visible metadata. + assert "transport" not in ctx.metadata + assert "tool_name" not in ctx.metadata def test_build_request_context_uses_composite_key_when_store_supplied() -> None: