Skip to content

Commit 20e5d53

Browse files
bokelleyclaude
andauthored
feat(server): expose RequestContext.transport and current_transport ContextVar (#627)
* feat(server): expose RequestContext.transport and current_transport ContextVar 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 * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9eb962c commit 20e5d53

5 files changed

Lines changed: 68 additions & 4 deletions

File tree

src/adcp/decisioning/context.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from collections.abc import Awaitable, Callable, Mapping
1919
from dataclasses import dataclass, field
2020
from datetime import datetime, timezone
21-
from typing import TYPE_CHECKING, Any, ClassVar, Generic
21+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
2222

2323
from typing_extensions import TypeVar
2424

@@ -533,6 +533,16 @@ class RequestContext(ToolContext, Generic[TMeta]):
533533
* Idempotency scope? → don't touch; the framework owns this.
534534
* Logging request provenance? → log all four; they're cheap.
535535
536+
:param transport: The wire protocol that dispatched this call —
537+
``"mcp"`` or ``"a2a"``. ``None`` when ``RequestContext`` is
538+
constructed in tests without a transport-aware ``ToolContext``,
539+
or when a custom ``context_factory`` omits
540+
``metadata["transport"]``. Production dispatch always populates
541+
this field. Note: even when the server is started with
542+
``transport="both"``, individual requests always resolve to
543+
exactly one of ``"mcp"`` or ``"a2a"`` — this field never
544+
carries ``"both"``. For code running outside a handler call
545+
stack, read :data:`adcp.server.current_transport` instead.
536546
:param state: Sync reads of framework-owned in-flight workflow
537547
state. Default is :class:`adcp.decisioning.state._NotYetWiredStateReader`
538548
— returns empty values + emits one-time UserWarning per
@@ -560,6 +570,7 @@ class RequestContext(ToolContext, Generic[TMeta]):
560570
auth_info: AuthInfo | None = None
561571
auth_principal: str | None = None
562572
buyer_agent: BuyerAgent | None = None
573+
transport: Literal["mcp", "a2a"] | None = None
563574
now: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
564575
state: StateReader = field(default_factory=_make_default_state_reader)
565576
resolve: ResourceResolver = field(default_factory=_make_default_resolver)

src/adcp/decisioning/dispatch.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
import typing
4545
import warnings
4646
from concurrent.futures import ThreadPoolExecutor
47-
from typing import TYPE_CHECKING, Any
47+
from typing import TYPE_CHECKING, Any, Literal
4848

4949
from adcp.decisioning.account_projection import (
5050
strip_credentials_from_wire_result,
@@ -1059,6 +1059,7 @@ def _build_request_context(
10591059
# Local import keeps the layering local — read the bearer ContextVar
10601060
# without forcing a top-level dep on adcp.server.auth.
10611061
from adcp.server.auth import current_principal as _current_principal
1062+
from adcp.server.auth import current_transport as _current_transport
10621063

10631064
if auth_info is None:
10641065
bearer_principal = _current_principal.get()
@@ -1092,14 +1093,40 @@ def _build_request_context(
10921093
else:
10931094
caller_identity = tool_ctx.caller_identity
10941095

1096+
# Extract transport from metadata. In production paths RequestMetadata
1097+
# always populates metadata["transport"] before calling the context
1098+
# factory; None here means a test fixture supplied a bare ToolContext.
1099+
raw_transport = tool_ctx.metadata.get("transport")
1100+
if raw_transport not in ("mcp", "a2a", None):
1101+
raise ValueError(
1102+
f"metadata['transport'] must be 'mcp', 'a2a', or absent; got {raw_transport!r}"
1103+
)
1104+
transport: Literal["mcp", "a2a"] | None = raw_transport
1105+
1106+
# Set the ContextVar for code outside the handler call stack (webhook
1107+
# services, background helpers) that don't receive a RequestContext.
1108+
# No reset token is saved: asyncio tasks each get their own context
1109+
# copy, so set() is task-scoped and doesn't bleed across requests.
1110+
# Callers that need the previous value must save/restore it themselves
1111+
# (the test suite exercises this via asyncio.copy_context() isolation).
1112+
_current_transport.set(transport)
1113+
1114+
# SDK-owned keys set by auth_context_factory / build_context examples
1115+
# ("transport", "tool_name") are framework-internal — strip them from
1116+
# the handler-visible metadata so adopters can't accidentally rely on
1117+
# undocumented dict paths and ctx.transport is the sole typed surface.
1118+
_sdk_metadata_keys = frozenset({"transport", "tool_name"})
1119+
clean_metadata = {k: v for k, v in tool_ctx.metadata.items() if k not in _sdk_metadata_keys}
1120+
10951121
# Build the RequestContext with the explicit state/resolve kwargs
10961122
# if provided; otherwise let the dataclass default factories
10971123
# supply the v6.0 stubs.
10981124
ctx_kwargs: dict[str, Any] = {
10991125
"request_id": tool_ctx.request_id,
11001126
"caller_identity": caller_identity,
11011127
"tenant_id": tool_ctx.tenant_id,
1102-
"metadata": dict(tool_ctx.metadata),
1128+
"metadata": clean_metadata,
1129+
"transport": transport,
11031130
"account": account,
11041131
"auth_info": auth_info,
11051132
"auth_principal": auth_principal,

src/adcp/server/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ async def get_products(params, context=None):
6464
TokenValidator,
6565
auth_context_factory,
6666
constant_time_token_match,
67+
current_principal,
68+
current_principal_metadata,
69+
current_transport,
6770
validator_from_token_map,
6871
)
6972
from adcp.server.base import (
@@ -207,6 +210,9 @@ async def get_products(params, context=None):
207210
"TokenValidator",
208211
"auth_context_factory",
209212
"constant_time_token_match",
213+
"current_principal",
214+
"current_principal_metadata",
215+
"current_transport",
210216
"validator_from_token_map",
211217
# Idempotency middleware (AdCP #2315 seller side)
212218
"IdempotencyStore",

src/adcp/server/auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ async def validate_token(token: str) -> Principal | None:
8080
from collections.abc import Awaitable, Mapping
8181
from contextvars import ContextVar
8282
from dataclasses import dataclass, field
83-
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
83+
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar
8484

8585
_V = TypeVar("_V")
8686

@@ -192,6 +192,9 @@ def __call__(self, token: str) -> Awaitable[Principal | None]: ...
192192
current_principal_metadata: ContextVar[dict[str, Any] | None] = ContextVar(
193193
"adcp_auth_principal_metadata", default=None
194194
)
195+
current_transport: ContextVar[Literal["mcp", "a2a"] | None] = ContextVar(
196+
"adcp_transport", default=None
197+
)
195198

196199

197200
class BearerTokenAuthMiddleware(BaseHTTPMiddleware):

tests/test_decisioning_dispatch.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,23 @@ def test_build_request_context_threads_account_and_auth() -> None:
410410
assert ctx.caller_identity == "caller_x"
411411
assert ctx.tenant_id == "tenant_y"
412412
assert ctx.metadata == {"foo": "bar"}
413+
# Fixture ToolContext has no "transport" in metadata — transport is None.
414+
assert ctx.transport is None
415+
416+
417+
@pytest.mark.parametrize("transport_value", ["mcp", "a2a"])
418+
def test_build_request_context_extracts_transport_from_metadata(transport_value: str) -> None:
419+
"""Transport is lifted from ToolContext.metadata into the typed field and ContextVar."""
420+
from adcp.server.auth import current_transport
421+
422+
tool_ctx = ToolContext(metadata={"transport": transport_value, "tool_name": "get_products"})
423+
account: Account[Any] = Account(id="acct_b")
424+
ctx = _build_request_context(tool_ctx, account, None)
425+
assert ctx.transport == transport_value
426+
assert current_transport.get() == transport_value
427+
# SDK-owned keys are stripped from handler-visible metadata.
428+
assert "transport" not in ctx.metadata
429+
assert "tool_name" not in ctx.metadata
413430

414431

415432
def test_build_request_context_uses_composite_key_when_store_supplied() -> None:

0 commit comments

Comments
 (0)