diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index c77c9e4d..1c7ec8d7 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -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 diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index e20dd0f0..4f3a4a53 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -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) diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index 48e8ccb8..4b27c66c 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -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", } ) @@ -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 diff --git a/tests/test_a2a_server.py b/tests/test_a2a_server.py index 7d19bce8..bc3205a0 100644 --- a/tests/test_a2a_server.py +++ b/tests/test_a2a_server.py @@ -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" + ) # --------------------------------------------------------------------------- diff --git a/tests/test_serve_auth_both.py b/tests/test_serve_auth_both.py index e99c0f0d..82415459 100644 --- a/tests/test_serve_auth_both.py +++ b/tests/test_serve_auth_both.py @@ -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, diff --git a/tests/test_unified_mcp_a2a.py b/tests/test_unified_mcp_a2a.py index 97683fbd..ed58a952 100644 --- a/tests/test_unified_mcp_a2a.py +++ b/tests/test_unified_mcp_a2a.py @@ -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: