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: default → current_tenant survives.
AuthenticationError from principal_id is None → current_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
- 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.)
- 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.
- 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).
Affected versions:
adcp==4.4.0,4.4.1,4.4.2. Reproduces against botha2a-sdk==1.0.1and1.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:tools/callget_products{"products": []}✅message/sendget_productsAdCPAuthenticationError("Authentication required by tenant policy")❌The platform method's view:
current_principal.get()returnsNoneonly on the A2A path.current_tenant.get()works fine on both — only the principal contextvar drops.Where it breaks
adcp.server.auth.BearerTokenAuthMiddleware.dispatchsets the contextvar before calling downstream:On MCP this propagates fine — the platform method sees
current_principal.get() == "<caller_identity>". On A2A, by the timeplatform.get_products(req, ctx)runs,current_principal.get() == Nonewhilecurrent_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-sdkruns 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 byBearerTokenAuthMiddleware, so when that contextvar is lost there's no fallback.Minimal repro (
adcp+a2a-sdkonly)Live observation
From a
salesagentdeployment runningadcp 4.4.2 + a2a-sdk 1.0.1 + protobuf 6.33.6:Tenant context: default→current_tenantsurvives.AuthenticationErrorfromprincipal_id is None→current_principaldoes 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 readcurrent_principal.get()directly — the docstring onBearerTokenAuthMiddlewareadvertises that as the primary read path:That contract holds on MCP and breaks on A2A.
Asks
current_principalis supposed to propagate through the A2A request-handler boundary, or whether adopters need to read identity fromctx.auth_principal/ctx.account.metadatainstead. (We already triedctx.auth_principal— not populated on A2A.)current_principalset across the dispatch boundary the same waycurrent_tenantis — most likely by re-binding the contextvar inside the A2A skill dispatcher (or before the task it spawns) using the value the middleware computed.ctx.auth_principal: document thatcurrent_principal.get()is unreliable on A2A and add a stable, populated attribute onRequestContextthat adopters can read inplatform.<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).