Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/handler-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -929,8 +929,9 @@ mock-B).
## A2A transport

`serve(MyAgent(), transport="a2a")` wires the same handler through the
A2A protocol with auto-generated agent card (`/.well-known/agent.json`)
derived from the `ADCPHandler` methods your class overrides.
A2A protocol with auto-generated agent card (`/.well-known/agent-card.json`,
with the 0.3 alias `/.well-known/agent.json` also served for backwards
compatibility) derived from the `ADCPHandler` methods your class overrides.

### Durable task storage

Expand Down
12 changes: 10 additions & 2 deletions src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -917,8 +917,16 @@ def create_a2a_server(
}
if context_builder is not None:
jsonrpc_kwargs["context_builder"] = context_builder
routes = list(create_agent_card_routes(agent_card=agent_card)) + list(
create_jsonrpc_routes(**jsonrpc_kwargs)
routes = (
list(create_agent_card_routes(agent_card=agent_card))
# 0.3 alias: A2A 0.3 buyer SDKs probe /.well-known/agent.json
# as a positive A2A signal. Same handler, no redirect round-trip.
+ list(
create_agent_card_routes(
agent_card=agent_card, card_url="/.well-known/agent.json"
)
)
+ list(create_jsonrpc_routes(**jsonrpc_kwargs))
)
app = Starlette(routes=routes)

Expand Down
6 changes: 4 additions & 2 deletions src/adcp/server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -750,7 +750,8 @@ def resolved_a2a_bearer_prefix_required(self) -> bool:
_A2A_DISCOVERY_PATHS: frozenset[str] = frozenset(
{
_A2A_AGENT_CARD_PATH, # 1.0 canonical: ``/.well-known/agent-card.json``.
"/.well-known/agent.json", # Legacy 0.3 alias retained by enable_v0_3_compat=True.
# Legacy 0.3 alias — route registered explicitly in create_a2a_server.
"/.well-known/agent.json",
}
)

Expand All @@ -762,7 +763,8 @@ class A2ABearerAuthMiddleware:
:func:`adcp.server.a2a_server.create_a2a_server` with this
middleware to require a valid bearer header on every JSON-RPC
request, while leaving the spec-mandated public discovery
surface (``/.well-known/agent-card.json``) accessible.
surface (``/.well-known/agent-card.json`` and the 0.3 alias
``/.well-known/agent.json``) accessible.

Designed to compose with a2a-sdk's
:class:`DefaultServerCallContextBuilder`: on auth success the
Expand Down
11 changes: 7 additions & 4 deletions tests/test_a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,10 +345,13 @@ def test_create_a2a_server_creates_starlette_app():
# Starlette app has .routes
assert hasattr(app, "routes")
route_paths = [r.path for r in app.routes]
# A2A well-known agent card endpoint
# 1.0 serves ``/.well-known/agent-card.json`` in addition to the
# legacy ``/.well-known/agent.json`` aliased path (compat shim).
assert any(p.startswith("/.well-known/agent-card") for p in route_paths)
# Both the 1.0 canonical path and the 0.3 alias must be registered.
assert any(p.startswith("/.well-known/agent-card") for p in route_paths), (
"canonical /.well-known/agent-card.json route missing"
)
assert "/.well-known/agent.json" in route_paths, (
"0.3 alias /.well-known/agent.json route missing from create_a2a_server"
)


# ---------------------------------------------------------------------------
Expand Down
23 changes: 23 additions & 0 deletions tests/test_serve_auth_both.py
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,29 @@ def test_discovery_paths_match_a2a_sdk_routes() -> None:
)


def test_all_discovery_paths_are_registered_routes() -> None:
"""Inverse of test_discovery_paths_match_a2a_sdk_routes: every path in
_A2A_DISCOVERY_PATHS must actually be registered in the Starlette app
produced by create_a2a_server. A path in the frozenset but missing from
the routing table is auth-exempted but unserviceable — a 404 dressed up
as a bypass. This test would have caught the missing /.well-known/agent.json
route (issue #612)."""
from adcp.server.a2a_server import create_a2a_server
from adcp.server.auth import _A2A_DISCOVERY_PATHS

app = create_a2a_server(_OkHandler(), name="inverse-drift-guard", validation=None)
# Structural check only (r.path membership). Live dispatch is validated by
# test_a2a_agent_card_served_on_root_path in test_unified_mcp_a2a.py.
app_paths = {r.path for r in app.routes}

not_routed = [p for p in _A2A_DISCOVERY_PATHS if p not in app_paths]
assert not not_routed, (
f"_A2A_DISCOVERY_PATHS contains path(s) {not_routed!r} that are NOT "
f"registered in the Starlette app. Auth middleware exempts them but "
f"they 404 — add matching routes in create_a2a_server."
)


def test_a2a_agent_card_constant_referenced_directly() -> None:
"""The 1.0 path uses ``a2a.utils.constants.AGENT_CARD_WELL_KNOWN_PATH``
rather than a string literal. If a2a-sdk changes the constant,
Expand Down
17 changes: 7 additions & 10 deletions tests/test_unified_mcp_a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,14 @@ def unified_client():


def test_a2a_agent_card_served_on_root_path(unified_client) -> None:
"""``/.well-known/agent.json`` resolves through the dispatcher
to the A2A app. Even if a2a-sdk's wrapper returns 404 (variation
in card-path between versions), the request must NOT 405 / 502
/ 500 — those would indicate the dispatcher routed wrong."""
"""``/.well-known/agent.json`` (0.3 alias) resolves to a 200 agent-card
response. The route must be registered — a 404 means the alias was
stripped from create_a2a_server's route list."""
resp = unified_client.get("/.well-known/agent.json")
assert resp.status_code in (200, 404)
assert resp.status_code not in (
405,
502,
500,
), f"A2A agent-card path resolved to wrong inner app: status={resp.status_code}"
assert resp.status_code == 200, (
f"/.well-known/agent.json returned {resp.status_code}; "
"expected 200 — the 0.3 alias route is missing from create_a2a_server"
)


def test_a2a_root_path_routed_to_a2a_app(unified_client) -> None:
Expand Down
Loading