Skip to content

A2A: current_principal contextvar is None inside platform handlers (current_tenant works) — same token works on MCP #590

@bokelley

Description

@bokelley

Affected versions: adcp==4.4.0, 4.4.1, 4.4.2. Reproduces against both a2a-sdk==1.0.1 and 1.0.2 — not specific to either, this is about contextvar propagation through the A2A request-handler dispatch boundary.

Symptom

Same bearer token, same handler, same DecisioningPlatform. Two transports, two outcomes:

Transport Call Result
MCP tools/call get_products {"products": []}
A2A message/send get_products AdCPAuthenticationError("Authentication required by tenant policy")

The platform method's view: current_principal.get() returns None only on the A2A path. current_tenant.get() works fine on both — only the principal contextvar drops.

Where it breaks

adcp.server.auth.BearerTokenAuthMiddleware.dispatch sets the contextvar before calling downstream:

# adcp/server/auth.py:308 (4.4.x)
principal_token = current_principal.set(principal.caller_identity)
tenant_token = current_tenant.set(principal.tenant_id)
metadata_token = current_principal_metadata.set(...)
return await call_next(request)

On MCP this propagates fine — the platform method sees current_principal.get() == "<caller_identity>". On A2A, by the time platform.get_products(req, ctx) runs, current_principal.get() == None while current_tenant.get() is still populated. Different propagation behavior on the same Starlette binary, same middleware, same call.

Likely culprit: the A2A request handler in a2a-sdk runs the actual skill dispatch on a different async task / cancel scope than the MCP transport, and only one of the two contextvars survives the boundary. Tenant survives because it's also set deeper in the stack (SubdomainTenantMiddleware); principal is only ever set by BearerTokenAuthMiddleware, so when that contextvar is lost there's no fallback.

Minimal repro (adcp + a2a-sdk only)

# server.py
from adcp.decisioning import DecisioningPlatform, serve
from adcp.decisioning.capabilities import DecisioningCapabilities, MediaBuy, SupportedProtocol
from adcp.server import BearerTokenAuth, Principal
from adcp.server.auth import current_principal, current_tenant


class DiagPlatform(DecisioningPlatform):
    capabilities = DecisioningCapabilities(
        media_buy=MediaBuy(supported_pricing_models=["cpm"]),
        supported_protocols=[SupportedProtocol.media_buy],
    )

    async def get_products(self, req, ctx):
        return {
            "products": [],
            "_diag": {
                "current_principal": current_principal.get(),
                "current_tenant": current_tenant.get(),
            },
        }


def validate(token: str) -> Principal | None:
    if token == "good-token":
        return Principal(caller_identity="alice", tenant_id="t1")
    return None


serve(
    DiagPlatform(),
    transport="both",                       # MCP at /mcp, A2A at /
    auth=BearerTokenAuth(
        validate_token=validate,
        header_name="x-adcp-auth",
        bearer_prefix_required=False,
    ),
)
# A2A
curl -s -X POST http://localhost:3001/ \
  -H "x-adcp-auth: good-token" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"message/send","id":"a","params":{"message":{"role":"user","messageId":"m","kind":"message","parts":[{"kind":"data","data":{"skill":"get_products","parameters":{}}}]}}}'
# Expected: _diag.current_principal == "alice"
# Actual:   _diag.current_principal == null

# MCP (same token, same skill)
curl -s -X POST http://localhost:3001/mcp/ \
  -H "x-adcp-auth: good-token" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":"m","params":{"name":"get_products","arguments":{}}}'
# Expected and Actual: _diag.current_principal == "alice"

Live observation

From a salesagent deployment running adcp 4.4.2 + a2a-sdk 1.0.1 + protobuf 6.33.6:

adcp-server-1  | 2026-05-05 19:46:32 - src.core.tools.products - INFO - [GET_PRODUCTS] Tenant context: default
adcp-server-1  | 2026-05-05 19:46:32 - adcp.decisioning.dispatch - ERROR - Unhandled exception in platform.get_products
adcp-server-1  |   File ".../core/platforms/_delegate.py", line 87, in _build_identity
adcp-server-1  |     principal_id = current_principal.get() or getattr(ctx, "auth_principal", None)
adcp-server-1  |   File ".../src/core/tools/products.py", line 201, in _get_products_impl
adcp-server-1  |     raise AdCPAuthenticationError("Authentication required by tenant policy")

Tenant context: defaultcurrent_tenant survives.
AuthenticationError from principal_id is Nonecurrent_principal does not.

Falling back to getattr(ctx, "auth_principal", None) doesn't help — that attribute also isn't populated on A2A requests.

Why it matters

Every adopter with a tenant policy of require_auth (the salesagent default) gets locked out of A2A entirely while MCP works. Sellers that tighten policy to require principal-bound calls have no working A2A surface today. This is why we wired our integration to read current_principal.get() directly — the docstring on BearerTokenAuthMiddleware advertises that as the primary read path:

On success, populates current_principal, current_tenant, and current_principal_metadata for the duration of the downstream call.

That contract holds on MCP and breaks on A2A.

Asks

  1. Confirm whether current_principal is supposed to propagate through the A2A request-handler boundary, or whether adopters need to read identity from ctx.auth_principal / ctx.account.metadata instead. (We already tried ctx.auth_principal — not populated on A2A.)
  2. If the contextvar boundary is the bug: keep current_principal set across the dispatch boundary the same way current_tenant is — most likely by re-binding the contextvar inside the A2A skill dispatcher (or before the task it spawns) using the value the middleware computed.
  3. If the recommended path is ctx.auth_principal: document that current_principal.get() is unreliable on A2A and add a stable, populated attribute on RequestContext that adopters can read in platform.<method> regardless of transport. Today neither side of the API is reliable on A2A.

Tagging the salesagent deployment we hit this on: https://github.com/prebid/salesagent (current branch: bokelley/wrapper-webhook-fix, PR #17).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions