Skip to content

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

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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