From f77050b14e779965a0986419d7871c782a4df66f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jul 2026 17:26:24 -0700 Subject: [PATCH 1/3] fix(keycardai-a2a): enable A2A 0.3 compat in server compositions for cross-SDK interop a2a-sdk 1.x's JSONRPC dispatcher ships enable_v0_3_compat=False, so a server composed per our README/example rejected 0.3 message/send requests from the Go/TS Keycard SDKs with -32601 MethodNotFound. The package deliberately does not wrap create_jsonrpc_routes (it is glue, not a server abstraction), so the flag is now passed at every composition point we ship: the README quickstart, the keycard_protected_server example, and the test fixtures that mirror them. The a2a_jsonrpc_usage example's manual envelope was itself a 0.3 shape (message/send, plain role, no messageId, no A2A-Version header) aimed at the 1.0 server the sibling example builds; it now shows the current 1.0 envelope, with a comment pointing 0.3 callers at the compat flag. Adds a dispatch test proving a 0.3 message/send request round-trips through the built app when compat is on. Interim measure per ECO-161: the flag goes away once all Keycard SDKs speak A2A 1.0. Co-Authored-By: Claude Fable 5 --- packages/a2a/README.md | 4 +++ .../a2a/examples/a2a_jsonrpc_usage/main.py | 12 +++++-- .../examples/keycard_protected_server/main.py | 5 +++ packages/a2a/tests/test_agent_card_server.py | 3 ++ packages/a2a/tests/test_jsonrpc_dispatch.py | 36 +++++++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/a2a/README.md b/packages/a2a/README.md index b25f8030..6f627861 100644 --- a/packages/a2a/README.md +++ b/packages/a2a/README.md @@ -96,6 +96,10 @@ your_app.routes.append(Mount( request_handler=request_handler, rpc_url="/jsonrpc", context_builder=KeycardServerCallContextBuilder(), + # Keycard SDKs in other languages still speak A2A 0.3 (`message/send` + # with no A2A-Version header); without this the 1.x dispatcher rejects + # them with -32601 MethodNotFound. Interim until all SDKs speak 1.0. + enable_v0_3_compat=True, ), middleware=[ Middleware( diff --git a/packages/a2a/examples/a2a_jsonrpc_usage/main.py b/packages/a2a/examples/a2a_jsonrpc_usage/main.py index e3d3efdd..b0ec8150 100644 --- a/packages/a2a/examples/a2a_jsonrpc_usage/main.py +++ b/packages/a2a/examples/a2a_jsonrpc_usage/main.py @@ -13,6 +13,7 @@ import asyncio import os +import uuid import httpx @@ -28,13 +29,19 @@ async def example_manual_jsonrpc() -> None: print("=" * 60) async with httpx.AsyncClient() as client: + # A2A 1.0 envelope: CamelCase method name, a required messageId, an + # enum-string role, and the A2A-Version header. (A2A 0.3 clients send + # "message/send" instead; servers accept those only when built with + # enable_v0_3_compat=True, as the sibling keycard_protected_server + # example does.) jsonrpc_request = { "jsonrpc": "2.0", "id": "1", - "method": "message/send", + "method": "SendMessage", "params": { "message": { - "role": "user", + "messageId": str(uuid.uuid4()), + "role": "ROLE_USER", "parts": [{"text": "What is the status of deployment?"}], } }, @@ -47,6 +54,7 @@ async def example_manual_jsonrpc() -> None: headers={ "Content-Type": "application/json", "Authorization": f"Bearer {os.getenv('AUTH_TOKEN', '')}", + "A2A-Version": "1.0", }, ) diff --git a/packages/a2a/examples/keycard_protected_server/main.py b/packages/a2a/examples/keycard_protected_server/main.py index 1c0b9694..d8eb460b 100644 --- a/packages/a2a/examples/keycard_protected_server/main.py +++ b/packages/a2a/examples/keycard_protected_server/main.py @@ -115,6 +115,11 @@ def build_app(config: AgentServiceConfig, executor: AgentExecutor) -> Starlette: request_handler=request_handler, rpc_url="/jsonrpc", context_builder=KeycardServerCallContextBuilder(), + # Keycard SDKs in other languages still speak A2A 0.3 + # (`message/send` with no A2A-Version header). Without + # this flag the 1.x dispatcher rejects them with -32601 + # MethodNotFound. Interim until all SDKs speak 1.0. + enable_v0_3_compat=True, ), middleware=[ Middleware( diff --git a/packages/a2a/tests/test_agent_card_server.py b/packages/a2a/tests/test_agent_card_server.py index 2fa2e60f..98fabdd6 100644 --- a/packages/a2a/tests/test_agent_card_server.py +++ b/packages/a2a/tests/test_agent_card_server.py @@ -92,6 +92,9 @@ def app(service_config): request_handler=request_handler, rpc_url="/jsonrpc", context_builder=KeycardServerCallContextBuilder(), + # Mirrors the example: Keycard SDKs in other languages + # still speak A2A 0.3. + enable_v0_3_compat=True, ), middleware=[ Middleware( diff --git a/packages/a2a/tests/test_jsonrpc_dispatch.py b/packages/a2a/tests/test_jsonrpc_dispatch.py index 3e9b7717..d3e95c8f 100644 --- a/packages/a2a/tests/test_jsonrpc_dispatch.py +++ b/packages/a2a/tests/test_jsonrpc_dispatch.py @@ -106,6 +106,9 @@ def client(service_config): request_handler=request_handler, rpc_url="/jsonrpc", context_builder=KeycardServerCallContextBuilder(), + # Mirrors the README / example composition: Keycard SDKs + # in other languages still speak A2A 0.3. + enable_v0_3_compat=True, ), middleware=[ Middleware( @@ -155,3 +158,36 @@ def test_send_message_drives_executor_and_returns_response(self, client): # The KeycardServerCallContextBuilder propagated the access_token # from the auth backend's KeycardUser into ServerCallContext.state. assert "token: bearer-test-token" in body + + def test_v0_3_message_send_drives_executor(self, client): + """A 0.3 ``message/send`` request succeeds via the compat adapter. + + Keycard SDKs in other languages still send the 0.3 wire shape: + method ``message/send``, snake-less camelCase message fields with a + plain ``user`` role and ``kind``-tagged parts, and no ``A2A-Version`` + header (the dispatcher treats a missing header as 0.3). With + ``enable_v0_3_compat=False`` this request fails with -32601 + MethodNotFound, breaking cross-SDK interop. + """ + response = client.post( + "/a2a/jsonrpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "message/send", + "params": { + "message": { + "messageId": "req-03", + "role": "user", + "parts": [{"kind": "text", "text": "ping-03"}], + } + }, + }, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert "error" not in payload, payload + body = response.text + assert "echoed: ping-03" in body + assert "token: bearer-test-token" in body From 13d3f76d231a997ff35b0e2d48f8cea460a2144b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jul 2026 17:26:53 -0700 Subject: [PATCH 2/3] docs(keycardai-a2a): bind token audience in example AuthProvider construction The example and README quickstart constructed AuthProvider without an audience, which disables the verifier's audience check: any token minted in the zone for any service would be accepted. Delegation tokens are minted with audience = the target service's public URL, so the correct binding is the service's own identity_url. The test_agent_card_server fixture that mirrors the example gets the same binding. Co-Authored-By: Claude Fable 5 --- packages/a2a/README.md | 3 +++ packages/a2a/examples/keycard_protected_server/main.py | 3 +++ packages/a2a/tests/test_agent_card_server.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/a2a/README.md b/packages/a2a/README.md index 6f627861..4abafa3e 100644 --- a/packages/a2a/README.md +++ b/packages/a2a/README.md @@ -69,6 +69,9 @@ auth_provider = AuthProvider( zone_url=config.auth_server_url, server_name=config.service_name, server_url=config.identity_url, + # Bind accepted tokens to this service; leaving audience unset + # disables the audience check entirely. + audience=config.identity_url, application_credential=ClientSecret((config.client_id, config.client_secret)), ) verifier = auth_provider.get_token_verifier() diff --git a/packages/a2a/examples/keycard_protected_server/main.py b/packages/a2a/examples/keycard_protected_server/main.py index d8eb460b..eabbacc6 100644 --- a/packages/a2a/examples/keycard_protected_server/main.py +++ b/packages/a2a/examples/keycard_protected_server/main.py @@ -87,6 +87,9 @@ def build_app(config: AgentServiceConfig, executor: AgentExecutor) -> Starlette: zone_url=config.auth_server_url, server_name=config.service_name, server_url=config.identity_url, + # Bind accepted tokens to this service; leaving audience unset + # disables the audience check entirely. + audience=config.identity_url, application_credential=ClientSecret((config.client_id, config.client_secret)), ) verifier = auth_provider.get_token_verifier() diff --git a/packages/a2a/tests/test_agent_card_server.py b/packages/a2a/tests/test_agent_card_server.py index 98fabdd6..da725d4d 100644 --- a/packages/a2a/tests/test_agent_card_server.py +++ b/packages/a2a/tests/test_agent_card_server.py @@ -62,6 +62,9 @@ def app(service_config): zone_url=service_config.auth_server_url, server_name=service_config.service_name, server_url=service_config.identity_url, + # Mirrors the example: bind accepted tokens to this service; + # leaving audience unset disables the audience check entirely. + audience=service_config.identity_url, application_credential=ClientSecret( (service_config.client_id, service_config.client_secret) ), From f01549cd99f3a8d7f2bfa62b832f680f34106811 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jul 2026 17:28:43 -0700 Subject: [PATCH 3/3] fix(keycardai-a2a): drop dead delegation-chain remnants from the keycardai-agents extraction Two leftovers from the pre-split keycardai-agents package: - DelegationClient.invoke_service returned a hardcoded delegation_chain: [] field that nothing populates; the return shape is now just {result}. No consumer reads it (the only test assertion checked it was always empty). - ServiceDiscovery.list_delegatable_services was a warn-and-return-[] stub whose docstring and log message pointed at get_a2a_tools(), which does not exist in this package. Removed along with its placeholder test and the orphaned mock_delegatable_services fixture. Co-Authored-By: Claude Fable 5 --- .../a2a/src/keycardai/a2a/client/discovery.py | 35 ------------------- .../src/keycardai/a2a/server/delegation.py | 32 +++++++---------- packages/a2a/tests/conftest.py | 23 ------------ packages/a2a/tests/test_a2a_client.py | 3 +- packages/a2a/tests/test_discovery.py | 11 ------ 5 files changed, 14 insertions(+), 90 deletions(-) diff --git a/packages/a2a/src/keycardai/a2a/client/discovery.py b/packages/a2a/src/keycardai/a2a/client/discovery.py index 7882026a..dbd82aa7 100644 --- a/packages/a2a/src/keycardai/a2a/client/discovery.py +++ b/packages/a2a/src/keycardai/a2a/client/discovery.py @@ -57,11 +57,6 @@ class ServiceDiscovery: >>> # Discover service (cached) >>> card = await discovery.get_service_card("https://slack-poster.example.com") >>> print(card["capabilities"]) - >>> - >>> # List all discoverable services from Keycard dependencies - >>> services = await discovery.list_delegatable_services() - >>> for service in services: - ... print(f"{service['name']}: {service['url']}") """ def __init__( @@ -186,36 +181,6 @@ async def get_service_card( return card - async def list_delegatable_services(self) -> list[dict[str, Any]]: - """List all services this service can delegate to. - - Queries Keycard to find all services that this service has - dependencies configured for. Returns service information with - their agent cards. - - Returns: - List of service dictionaries with 'name', 'url', 'description', 'capabilities' - - Note: - This requires a Keycard API endpoint that lists application dependencies. - Currently returns empty list. Once Keycard API is available, it will query: - GET https://{zone_id}.keycard.cloud/api/v1/applications/{client_id}/dependencies - - For now, use the `delegatable_services` parameter in `get_a2a_tools()` - to manually specify services. - - Example: - >>> services = await discovery.list_delegatable_services() - >>> for service in services: - ... print(f"{service['name']}: {service['capabilities']}") - """ - logger.warning( - "list_delegatable_services() not yet implemented - " - "requires Keycard API for dependency listing. " - "Use delegatable_services parameter in get_a2a_tools() instead." - ) - return [] - async def clear_cache(self) -> None: """Clear all cached agent cards.""" logger.info("Clearing agent card cache") diff --git a/packages/a2a/src/keycardai/a2a/server/delegation.py b/packages/a2a/src/keycardai/a2a/server/delegation.py index 67b200f2..66eae8b7 100644 --- a/packages/a2a/src/keycardai/a2a/server/delegation.py +++ b/packages/a2a/src/keycardai/a2a/server/delegation.py @@ -60,7 +60,7 @@ def _build_jsonrpc_send_message(task: dict[str, Any] | str) -> dict[str, Any]: def _unwrap_jsonrpc_response(response_body: dict[str, Any]) -> dict[str, Any]: """Unwrap an A2A 1.x JSONRPC ``SendMessageResponse`` into a flat - ``{result, delegation_chain}`` dict. + ``{result}`` dict. ``SendMessageResponse`` is a oneof of ``message`` or ``task``. When the remote executor enqueues a ``Message``, the text is read from @@ -68,9 +68,6 @@ def _unwrap_jsonrpc_response(response_body: dict[str, Any]) -> dict[str, Any]: the task is JSON-stringified into ``result``; callers needing the full Task lifecycle should use ``a2a.client.create_client`` directly. - ``delegation_chain`` is always empty here. Multi-hop chain tracking - requires parsing JWT claims directly. - Raises: ValueError: if the response carries a JSONRPC ``error`` member. """ @@ -82,7 +79,7 @@ def _unwrap_jsonrpc_response(response_body: dict[str, Any]) -> dict[str, Any]: ) result = response_body.get("result") if result is None: - return {"result": "", "delegation_chain": []} + return {"result": ""} if isinstance(result, dict): message = result.get("message") if isinstance(message, dict): @@ -94,13 +91,10 @@ def _unwrap_jsonrpc_response(response_body: dict[str, Any]) -> dict[str, Any]: if isinstance(p, dict) and "text" in p ] if text_parts: - return { - "result": "\n".join(text_parts), - "delegation_chain": [], - } + return {"result": "\n".join(text_parts)} if isinstance(result, str): - return {"result": result, "delegation_chain": []} - return {"result": json.dumps(result), "delegation_chain": []} + return {"result": result} + return {"result": json.dumps(result)} class DelegationClient: @@ -287,9 +281,9 @@ async def invoke_service( """Call another agent service over A2A JSONRPC with bearer auth. Sends a ``SendMessage`` JSONRPC request to ``${service_url}/a2a/jsonrpc`` - and returns ``{"result": , "delegation_chain": []}``. For the - full A2A protocol surface (Task lifecycle, streaming, status updates), - use ``a2a.client.create_client`` directly. + and returns ``{"result": }``. For the full A2A protocol surface + (Task lifecycle, streaming, status updates), use + ``a2a.client.create_client`` directly. Args: service_url: Base URL of the target service @@ -298,7 +292,7 @@ async def invoke_service( subject_token: Optional token for exchange if token not provided Returns: - Dict with ``result`` (str) and ``delegation_chain`` (list). + Dict with ``result`` (str). Raises: httpx.HTTPStatusError: If the JSONRPC request fails @@ -535,9 +529,9 @@ def invoke_service( """Call another agent service over A2A JSONRPC with bearer auth. Sends a ``SendMessage`` JSONRPC request to ``${service_url}/a2a/jsonrpc`` - and returns ``{"result": , "delegation_chain": []}``. For the - full A2A protocol surface (Task lifecycle, streaming, status updates), - use ``a2a.client.create_client`` directly. + and returns ``{"result": }``. For the full A2A protocol surface + (Task lifecycle, streaming, status updates), use + ``a2a.client.create_client`` directly. Args: service_url: Base URL of the target service @@ -546,7 +540,7 @@ def invoke_service( subject_token: Optional token for exchange if token not provided Returns: - Dict with ``result`` (str) and ``delegation_chain`` (list). + Dict with ``result`` (str). Raises: httpx.HTTPStatusError: If the JSONRPC request fails diff --git a/packages/a2a/tests/conftest.py b/packages/a2a/tests/conftest.py index 7345091e..d0e75569 100644 --- a/packages/a2a/tests/conftest.py +++ b/packages/a2a/tests/conftest.py @@ -162,29 +162,6 @@ def mock_agent_card_minimal(): return {"name": "Minimal Service"} -# ============================================ -# Service Discovery Fixtures -# ============================================ - -@pytest.fixture -def mock_delegatable_services(): - """Mock list of delegatable services.""" - return [ - { - "name": "Service One", - "url": "https://service1.example.com", - "description": "First test service", - "capabilities": ["capability1", "capability2"], - }, - { - "name": "Service Two", - "url": "https://service2.example.com", - "description": "Second test service", - "capabilities": ["capability3"], - }, - ] - - # ============================================ # JWT Token Fixtures # ============================================ diff --git a/packages/a2a/tests/test_a2a_client.py b/packages/a2a/tests/test_a2a_client.py index f2e6819e..14660707 100644 --- a/packages/a2a/tests/test_a2a_client.py +++ b/packages/a2a/tests/test_a2a_client.py @@ -142,8 +142,7 @@ async def test_invoke_service_posts_jsonrpc_envelope(a2a_client): ) # The wrapper unwraps the SendMessageResponse.message.parts text. - assert result["result"] == "Task completed successfully" - assert result["delegation_chain"] == [] + assert result == {"result": "Task completed successfully"} # Confirm the request matches the 1.x dispatcher contract. posted_url = mock_post.call_args[0][0] diff --git a/packages/a2a/tests/test_discovery.py b/packages/a2a/tests/test_discovery.py index 90bdf743..92046cf4 100644 --- a/packages/a2a/tests/test_discovery.py +++ b/packages/a2a/tests/test_discovery.py @@ -316,17 +316,6 @@ async def test_get_cache_stats_identifies_expired( assert stats["expired"] == 1 -class TestListDelegatableServices: - """Test service listing (placeholder implementation).""" - - @pytest.mark.asyncio - async def test_list_delegatable_services_returns_empty(self, discovery): - """Test list_delegatable_services returns empty list (not yet implemented).""" - services = await discovery.list_delegatable_services() - assert services == [] - assert isinstance(services, list) - - class TestContextManager: """Test discovery as async context manager."""