What
BearerTokenAuthMiddleware is documented MCP-only (src/adcp/server/auth.py:62-65):
"A2A uses a different transport; wire a2a-sdk's ServerCallContext.user via a2a-sdk auth middleware on that side."
But adopters wiring a unified seller via serve(transport="both") only get the one ASGI middleware list — there's no first-class way to wire matching auth on the A2A leg through the framework.
Concrete blast: when BearerTokenAuthMiddleware is passed via asgi_middleware=[...] on serve(transport="both"), it wraps the whole binary including A2A's public /.well-known/agent-card.json discovery endpoint, so unauthenticated discovery requests get rejected with 401, breaking A2A buyer auto-discovery entirely.
Repro (production-shape, not toy)
salesagent's core/main.py: one binary serving MCP + A2A from the same handler/router, one Principal table, one token validates both transports. Buyer just picks a protocol. We hit the same gap.
Workaround we shipped (with a real security cost)
We wrote a 30-line ScopedAuthMiddleware that runs BearerTokenAuthMiddleware only on /mcp/* paths. A2A is currently unauthenticated in our build. That's a regression we accepted because the framework didn't give us a clean way to wire auth on both legs simultaneously, and we needed /.well-known/agent-card.json reachable for discovery. Filing this so we can drop the workaround.
What we'd want
Option (b) from a chat with @bokelley: add an A2ABearerAuthMiddleware sibling (validates the same token shape but populates ServerCallContext.user rather than reading an HTTP header inline) plus a serve(auth=...) shortcut that wires both legs with one config:
serve(
handler,
transport="both",
auth=BearerTokenAuth(validate_token=_validate_token, header_name="x-adcp-auth"),
...,
)
Internally serve mounts BearerTokenAuthMiddleware on the MCP leg and A2ABearerAuthMiddleware on the A2A leg from the single auth= config. Adopters who need transport-specific auth keep the lower-level path (separate middlewares per leg).
Considered alternatives:
- (a) Make
BearerTokenAuthMiddleware transport-aware — awkward, A2A's auth model is different in shape (a2a-sdk reads ServerCallContext.user populated upstream, not an HTTP header re-validated on every request). A transport-aware mux either reimplements both auth styles or shells out to two underlying middlewares — at which point you've reinvented (b) inside a wrapper.
- (c) Fail-fast if BearerTokenAuthMiddleware is used as outer ASGI middleware on
transport="both" — punts; doesn't actually solve the problem.
Filed by
salesagent kill-nginx/M2 spike — surfaced in core/SDK_FEEDBACK.md round 3.
What
BearerTokenAuthMiddlewareis documented MCP-only (src/adcp/server/auth.py:62-65):But adopters wiring a unified seller via
serve(transport="both")only get the one ASGI middleware list — there's no first-class way to wire matching auth on the A2A leg through the framework.Concrete blast: when
BearerTokenAuthMiddlewareis passed viaasgi_middleware=[...]onserve(transport="both"), it wraps the whole binary including A2A's public/.well-known/agent-card.jsondiscovery endpoint, so unauthenticated discovery requests get rejected with 401, breaking A2A buyer auto-discovery entirely.Repro (production-shape, not toy)
salesagent's
core/main.py: one binary serving MCP + A2A from the same handler/router, one Principal table, one token validates both transports. Buyer just picks a protocol. We hit the same gap.Workaround we shipped (with a real security cost)
We wrote a 30-line
ScopedAuthMiddlewarethat runsBearerTokenAuthMiddlewareonly on/mcp/*paths. A2A is currently unauthenticated in our build. That's a regression we accepted because the framework didn't give us a clean way to wire auth on both legs simultaneously, and we needed/.well-known/agent-card.jsonreachable for discovery. Filing this so we can drop the workaround.What we'd want
Option (b) from a chat with @bokelley: add an
A2ABearerAuthMiddlewaresibling (validates the same token shape but populatesServerCallContext.userrather than reading an HTTP header inline) plus aserve(auth=...)shortcut that wires both legs with one config:Internally
servemountsBearerTokenAuthMiddlewareon the MCP leg andA2ABearerAuthMiddlewareon the A2A leg from the singleauth=config. Adopters who need transport-specific auth keep the lower-level path (separate middlewares per leg).Considered alternatives:
BearerTokenAuthMiddlewaretransport-aware — awkward, A2A's auth model is different in shape (a2a-sdk readsServerCallContext.userpopulated upstream, not an HTTP header re-validated on every request). A transport-aware mux either reimplements both auth styles or shells out to two underlying middlewares — at which point you've reinvented (b) inside a wrapper.transport="both"— punts; doesn't actually solve the problem.Filed by
salesagent kill-nginx/M2 spike — surfaced in
core/SDK_FEEDBACK.mdround 3.