From cf04cf35782ce0f6cb0beafa0878f329314084a7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 4 May 2026 10:57:44 -0400 Subject: [PATCH 1/2] fix(server): auth_context_factory populates adcp.auth_info for bearer flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #576 (partial — handler.py pop follows in next commit) https://claude.ai/code/session_01R254Wmibw6fxiDDTvhuw9b --- src/adcp/server/auth.py | 30 ++++++++++++++++++++++++++- tests/test_auth_middleware.py | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index 9099b838..4aa0c013 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -400,16 +400,44 @@ def auth_context_factory(meta: RequestMetadata) -> ToolContext: :class:`ToolContext` — agents that want a typed subclass (e.g. :class:`~adcp.server.AccountAwareToolContext`) should copy the three-line body and return their own subclass instead. + + Also sets ``metadata["adcp.auth_info"]`` to a typed + :class:`~adcp.decisioning.AuthInfo` when the request is + authenticated, so :meth:`~adcp.decisioning.PlatformHandler._extract_auth_info` + surfaces a non-``None`` :attr:`~adcp.decisioning.RequestContext.auth_info` + for bearer flows — the same typed surface signed-request flows already + populate. ``credential`` is ``None`` for bearer flows because inbound + bearer tokens are not for upstream propagation; adopters who need + :class:`~adcp.decisioning.BuyerAgentRegistry` dispatch must supply a + typed credential in a custom ``context_factory`` subclass. + + ``adcp.auth_info`` is server-internal and never wire-echoed by the + framework. Do not pass ``ctx.metadata`` wholesale to a JSON serializer + — the ``AuthInfo`` object is not JSON-serializable. """ + principal_identity = current_principal.get() principal_metadata = current_principal_metadata.get() or {} combined_metadata: dict[str, Any] = { **principal_metadata, "tool_name": meta.tool_name, "transport": meta.transport, } + if principal_identity is not None: + # Lazy import to keep module-load order safe — decisioning.context + # imports adcp.server.base but not adcp.server.auth, so there is no + # circular dependency, but hoisting this to module level would create + # one if the import graph ever changes. Call-time import matches + # the pattern already used in dispatch._build_request_context. + from adcp.decisioning.context import AuthInfo # noqa: PLC0415 + + combined_metadata["adcp.auth_info"] = AuthInfo( + kind="bearer", + principal=principal_identity, + credential=None, # explicit None: no synthesis, no DeprecationWarning + ) return ToolContext( request_id=meta.request_id, - caller_identity=current_principal.get(), + caller_identity=principal_identity, tenant_id=current_tenant.get(), metadata=combined_metadata, ) diff --git a/tests/test_auth_middleware.py b/tests/test_auth_middleware.py index 0d1c624f..c7354f57 100644 --- a/tests/test_auth_middleware.py +++ b/tests/test_auth_middleware.py @@ -562,6 +562,44 @@ def test_auth_context_factory_with_no_principal() -> None: assert ctx.caller_identity is None assert ctx.tenant_id is None + assert "adcp.auth_info" not in (ctx.metadata or {}) + + +def test_auth_context_factory_populates_auth_info_when_authenticated() -> None: + """auth_context_factory must set ctx.metadata['adcp.auth_info'] to a typed + AuthInfo(kind='bearer') when a principal is present, so ctx.auth_info is + non-None for bearer flows in downstream RequestContext. Regression guard + for issue #576.""" + from adcp.decisioning.context import AuthInfo + from adcp.server import RequestMetadata + + principal_token = current_principal.set("alice") + tenant_token = current_tenant.set("t1") + try: + meta = RequestMetadata(tool_name="get_products", transport="mcp") + ctx = auth_context_factory(meta) + finally: + current_principal.reset(principal_token) + current_tenant.reset(tenant_token) + + info = ctx.metadata.get("adcp.auth_info") + assert isinstance(info, AuthInfo), f"expected AuthInfo, got {type(info)}" + assert info.kind == "bearer" + assert info.principal == "alice" + assert info.credential is None # inbound tokens are not for upstream propagation + + +def test_auth_context_factory_omits_auth_info_without_principal() -> None: + """Non-discovery requests with no principal (principal=None) must NOT set + adcp.auth_info in metadata — the key is only set when authenticated.""" + from adcp.server import RequestMetadata + + # Use a non-discovery tool so this test is distinct from + # test_auth_context_factory_with_no_principal above. + meta = RequestMetadata(tool_name="get_products", transport="mcp") + ctx = auth_context_factory(meta) + + assert "adcp.auth_info" not in (ctx.metadata or {}) # Full-stack composition (middleware + create_mcp_server + handler) is From 51f8d63d66bd603e82a28c4ae6371a3d1c3ce9ad Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 4 May 2026 15:07:06 +0000 Subject: [PATCH 2/2] fix(server): pop adcp.auth_info from tool_ctx.metadata after extraction _build_ctx now pops metadata["adcp.auth_info"] after calling _extract_auth_info, preventing the AuthInfo object from surviving into RequestContext.metadata where it would be opaque to downstream serializers. Mirrors the existing adcp.buyer_agent pop pattern. https://claude.ai/code/session_01R254Wmibw6fxiDDTvhuw9b --- src/adcp/decisioning/handler.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index 1847bebc..54e205a7 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -961,6 +961,11 @@ def _build_ctx( the next dispatch. """ auth_info = self._extract_auth_info(tool_ctx) + # Pop adcp.auth_info after extraction so the AuthInfo object doesn't + # survive into RequestContext.metadata, where it would be opaque to + # downstream serializers. Mirrors adcp.buyer_agent on the next line. + if tool_ctx.metadata: + tool_ctx.metadata.pop("adcp.auth_info", None) buyer_agent = tool_ctx.metadata.pop("adcp.buyer_agent", None) if tool_ctx.metadata else None return _build_request_context( tool_ctx,