Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/a2a/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -96,6 +99,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(
Expand Down
12 changes: 10 additions & 2 deletions packages/a2a/examples/a2a_jsonrpc_usage/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import asyncio
import os
import uuid

import httpx

Expand All @@ -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?"}],
}
},
Expand All @@ -47,6 +54,7 @@ async def example_manual_jsonrpc() -> None:
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {os.getenv('AUTH_TOKEN', '<your-token>')}",
"A2A-Version": "1.0",
},
)

Expand Down
8 changes: 8 additions & 0 deletions packages/a2a/examples/keycard_protected_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -115,6 +118,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(
Expand Down
35 changes: 0 additions & 35 deletions packages/a2a/src/keycardai/a2a/client/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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")
Expand Down
32 changes: 13 additions & 19 deletions packages/a2a/src/keycardai/a2a/server/delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,14 @@ 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
``result.message.parts[].text`` and joined. When it produces a ``Task``,
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.
"""
Expand All @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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": <text>, "delegation_chain": []}``. For the
full A2A protocol surface (Task lifecycle, streaming, status updates),
use ``a2a.client.create_client`` directly.
and returns ``{"result": <text>}``. 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
Expand All @@ -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
Expand Down Expand Up @@ -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": <text>, "delegation_chain": []}``. For the
full A2A protocol surface (Task lifecycle, streaming, status updates),
use ``a2a.client.create_client`` directly.
and returns ``{"result": <text>}``. 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
Expand All @@ -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
Expand Down
23 changes: 0 additions & 23 deletions packages/a2a/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ============================================
Expand Down
3 changes: 1 addition & 2 deletions packages/a2a/tests/test_a2a_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions packages/a2a/tests/test_agent_card_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
),
Expand Down Expand Up @@ -92,6 +95,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(
Expand Down
11 changes: 0 additions & 11 deletions packages/a2a/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
36 changes: 36 additions & 0 deletions packages/a2a/tests/test_jsonrpc_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Loading