Skip to content

Commit 1122d4e

Browse files
feat(server): restore FastAPI /docs visibility for A2A routes (a2aproject#1024)
## Summary - Introduces `src/a2a/server/routes/helpers/` — a new subpackage of transport-specific route helpers mirroring the layout of `client/transports/` - `add_a2a_routes_to_fastapi()` re-registers A2A Starlette routes as `APIRoute` instances so FastAPI's OpenAPI generator picks them up and they appear in `/docs` and `/openapi.json` - Each endpoint group is tagged (`A2A: Agent Card`, `A2A: JSON-RPC`, `A2A: REST`) and annotated with proto-derived request-body schemas so Swagger UI's **Try it out** panel renders a typed, editable payload - The required `A2A-Version: 1.0` header is declared on every dispatcher route so Swagger pre-fills it and callers aren't blocked by the `@validate_version` guard ## Why In v0.3, `A2AFastAPIApplication` was a first-class FastAPI subclass, so all endpoints were automatically visible in `/docs` (see [a2aproject#280](a2aproject#280)). The v1.0 rewrite dropped that wrapper in favour of bare Starlette route factories (`create_agent_card_routes`, `create_jsonrpc_routes`, `create_rest_routes`). These return `starlette.routing.Route` objects; FastAPI's OpenAPI generator only enumerates `fastapi.routing.APIRoute` instances, so every A2A endpoint silently vanished from `/docs` and `/openapi.json`. This PR restores that capability without requiring callers to switch frameworks or restructure their apps — a single helper function wraps and enriches the routes in place. Additional improvements over the v0.3 approach: - **Rich request-body schemas** generated from proto descriptors (`google.protobuf.descriptor`) rather than Pydantic models, matching the proto-first v1.0 types - **Proto `oneof` → JSON Schema `oneOf`** so `Part.content` variants (`text`, `raw`, `url`, `data`) are correctly mutually exclusive in Swagger — prevents `-32602 Invalid params` errors from the auto-generated sample payload - **JSON-RPC `params` as `oneOf`** over all 11 method types — every method is individually inspectable from a single endpoint row - **`A2A-Version` header** pre-filled to `1.0` on all dispatcher routes — removes the `-32009` version error that would otherwise block every "Try it out" call - **Scalable layout** — `helpers/` mirrors `client/transports/` with one file per integration target; adding a new framework is a single new file that imports from `_proto_schema.py` and `jsonrpc.py` ## Changes ``` src/a2a/server/routes/helpers/ __init__.py re-exports add_a2a_routes_to_fastapi _proto_schema.py proto → JSON Schema utilities (field_schema, message_schema, REST_BODY_TYPES) jsonrpc.py JSON-RPC specifics (METHOD_TYPES, DESCRIPTION, envelope_schema) fastapi.py add_a2a_routes_to_fastapi + _A2ARoute src/a2a/server/routes/__init__.py exports add_a2a_routes_to_fastapi src/a2a/server/routes/agent_card_routes.py docstring on _get_agent_card endpoint tests/server/routes/helpers/ test_proto_schema.py field_schema, message_schema, oneof handling, REST_BODY_TYPES test_jsonrpc.py envelope_schema, METHOD_TYPES, DESCRIPTION test_fastapi.py OpenAPI tags, dispatch, tenant mount, schema injection, version header ``` ## Usage ```python from fastapi import FastAPI from a2a.server.routes import ( add_a2a_routes_to_fastapi, create_agent_card_routes, create_jsonrpc_routes, create_rest_routes, ) app = FastAPI() add_a2a_routes_to_fastapi( app, agent_card_routes=create_agent_card_routes(agent_card), jsonrpc_routes=create_jsonrpc_routes(request_handler, rpc_url='/'), rest_routes=create_rest_routes(request_handler), ) ``` ## Validation - `uv run pytest tests/server/routes/helpers/ -v` — 25 tests, all passing ## Screenshots **Before** <img width="1669" height="979" alt="image" src="https://github.com/user-attachments/assets/3cac7844-3dd6-44a7-bc33-5d1ab0e59b19" /> **After** <img width="1707" height="920" alt="image" src="https://github.com/user-attachments/assets/d423b497-bb1e-4d53-83b6-da4f667cb72f" /> <img width="1699" height="787" alt="image" src="https://github.com/user-attachments/assets/698f34ac-8270-46bc-a010-ae797d66b557" /> Continues a2aproject#280 🦕 --------- Co-authored-by: Iva Sokolaj <[email protected]>
1 parent 2e09ffe commit 1122d4e

10 files changed

Lines changed: 811 additions & 0 deletions

File tree

.github/actions/spelling/allow.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ amannn
2020
aproject
2121
ARequest
2222
ARun
23+
ARoute
2324
AServer
2425
AServers
2526
AService
@@ -94,6 +95,8 @@ notif
9495
npx
9596
oauthoidc
9697
oidc
98+
oneof
99+
oneofs
97100
Oneof
98101
OpenAPI
99102
openapiv
@@ -112,6 +115,7 @@ proto
112115
protobuf
113116
Protobuf
114117
protoc
118+
protojson
115119
pydantic
116120
pyi
117121
pypistats
@@ -125,6 +129,7 @@ rmi
125129
RS256
126130
RUF
127131
SECP256R1
132+
SFIXED
128133
SLF
129134
socio
130135
sse

docs/migrations/v1_0/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,30 @@ app = FastAPI(routes=routes)
465465
uvicorn.run(app, host=host, port=port)
466466
```
467467

468+
`FastAPI(routes=routes)` mounts the A2A endpoints correctly, but FastAPI's OpenAPI generator only enumerates routes that are `fastapi.routing.APIRoute` instances, so the A2A endpoints will not appear in `/docs` or `/openapi.json`. To make them visible in the auto-generated OpenAPI schema — grouped into Agent Card, JSON-RPC, and REST sections — use the `add_a2a_routes_to_fastapi` helper:
469+
470+
```python
471+
from fastapi import FastAPI
472+
import uvicorn
473+
474+
from a2a.server.routes import (
475+
add_a2a_routes_to_fastapi,
476+
create_agent_card_routes,
477+
create_jsonrpc_routes,
478+
create_rest_routes,
479+
)
480+
481+
app = FastAPI()
482+
add_a2a_routes_to_fastapi(
483+
app,
484+
agent_card_routes=create_agent_card_routes(agent_card),
485+
jsonrpc_routes=create_jsonrpc_routes(request_handler, rpc_url='/'),
486+
rest_routes=create_rest_routes(request_handler),
487+
)
488+
489+
uvicorn.run(app, host=host, port=port)
490+
```
491+
468492
> **Example**: [`a2a-mcp-without-framework/server/__main__.py` in PR #509](https://github.com/a2aproject/a2a-samples/pull/509/files#diff-d15d39ae64c3d4e3a36cc6fb442302caf4e32a6dbd858792e7a4bed180a625ac)
469493
470494
---

src/a2a/server/routes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@
55
DefaultServerCallContextBuilder,
66
ServerCallContextBuilder,
77
)
8+
from a2a.server.routes.fastapi_routes import add_a2a_routes_to_fastapi
89
from a2a.server.routes.jsonrpc_routes import create_jsonrpc_routes
910
from a2a.server.routes.rest_routes import create_rest_routes
1011

1112

1213
__all__ = [
1314
'DefaultServerCallContextBuilder',
1415
'ServerCallContextBuilder',
16+
'add_a2a_routes_to_fastapi',
1517
'create_agent_card_routes',
1618
'create_jsonrpc_routes',
1719
'create_rest_routes',
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""JSON-RPC envelope schema utilities for A2A server routes."""
2+
3+
from typing import Any
4+
5+
from google.protobuf.message import Message
6+
7+
from a2a.server.routes._proto_schema import message_schema
8+
from a2a.types.a2a_pb2 import (
9+
CancelTaskRequest,
10+
DeleteTaskPushNotificationConfigRequest,
11+
GetExtendedAgentCardRequest,
12+
GetTaskPushNotificationConfigRequest,
13+
GetTaskRequest,
14+
ListTaskPushNotificationConfigsRequest,
15+
ListTasksRequest,
16+
SendMessageRequest,
17+
SubscribeToTaskRequest,
18+
TaskPushNotificationConfig,
19+
)
20+
21+
22+
METHOD_TYPES: dict[str, type[Message]] = {
23+
'SendMessage': SendMessageRequest,
24+
'SendStreamingMessage': SendMessageRequest,
25+
'GetTask': GetTaskRequest,
26+
'ListTasks': ListTasksRequest,
27+
'CancelTask': CancelTaskRequest,
28+
'CreateTaskPushNotificationConfig': TaskPushNotificationConfig,
29+
'GetTaskPushNotificationConfig': GetTaskPushNotificationConfigRequest,
30+
'ListTaskPushNotificationConfigs': ListTaskPushNotificationConfigsRequest,
31+
'DeleteTaskPushNotificationConfig': DeleteTaskPushNotificationConfigRequest,
32+
'SubscribeToTask': SubscribeToTaskRequest,
33+
'GetExtendedAgentCard': GetExtendedAgentCardRequest,
34+
}
35+
36+
DESCRIPTION = """\
37+
A2A JSON-RPC 2.0 endpoint. The `method` field selects the operation;
38+
`params` must match that method's schema (see the `oneOf` below).
39+
40+
**Supported methods:**
41+
42+
- `SendMessage` — Send a message to the agent (returns a Task or response Message).
43+
- `SendStreamingMessage` — Send a message and receive a Server-Sent Events stream.
44+
- `GetTask` — Fetch a task by ID.
45+
- `ListTasks` — List tasks with pagination and filtering.
46+
- `CancelTask` — Cancel an in-progress task.
47+
- `CreateTaskPushNotificationConfig` — Register a push-notification config on a task.
48+
- `GetTaskPushNotificationConfig` — Read a single push-notification config.
49+
- `ListTaskPushNotificationConfigs` — List all push-notification configs for a task.
50+
- `DeleteTaskPushNotificationConfig` — Delete a push-notification config.
51+
- `SubscribeToTask` — Subscribe to task events via Server-Sent Events.
52+
- `GetExtendedAgentCard` — Fetch the authenticated extended agent card.
53+
"""
54+
55+
56+
def envelope_schema(components: dict[str, Any]) -> dict[str, Any]:
57+
"""Builds the A2ARequest JSON-RPC envelope schema with a oneOf over all method params."""
58+
params_refs = [
59+
message_schema(cls.DESCRIPTOR, components)
60+
for cls in dict.fromkeys(METHOD_TYPES.values())
61+
]
62+
63+
components['A2ARequest'] = {
64+
'type': 'object',
65+
'required': ['jsonrpc', 'method'],
66+
'properties': {
67+
'jsonrpc': {'type': 'string', 'enum': ['2.0']},
68+
'id': {
69+
'oneOf': [
70+
{'type': 'string'},
71+
{'type': 'integer'},
72+
{'type': 'null'},
73+
],
74+
},
75+
'method': {
76+
'type': 'string',
77+
'enum': list(METHOD_TYPES),
78+
},
79+
'params': {'oneOf': params_refs},
80+
},
81+
}
82+
return {'$ref': '#/components/schemas/A2ARequest'}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Proto → JSON Schema utilities for A2A server routes."""
2+
3+
from typing import Any
4+
5+
from google.protobuf.descriptor import Descriptor, FieldDescriptor
6+
from google.protobuf.message import Message
7+
8+
from a2a.types.a2a_pb2 import SendMessageRequest, TaskPushNotificationConfig
9+
10+
11+
REST_BODY_TYPES: dict[tuple[str, str], type[Message]] = {
12+
('/message:send', 'POST'): SendMessageRequest,
13+
('/message:stream', 'POST'): SendMessageRequest,
14+
('/tasks/{id}/pushNotificationConfigs', 'POST'): TaskPushNotificationConfig,
15+
}
16+
17+
# 64-bit integer types serialize as strings in protojson.
18+
_PROTO_SCALAR_SCHEMAS: dict[int, dict[str, Any]] = {
19+
FieldDescriptor.TYPE_DOUBLE: {'type': 'number'},
20+
FieldDescriptor.TYPE_FLOAT: {'type': 'number'},
21+
FieldDescriptor.TYPE_INT64: {'type': 'string', 'format': 'int64'},
22+
FieldDescriptor.TYPE_UINT64: {'type': 'string', 'format': 'uint64'},
23+
FieldDescriptor.TYPE_INT32: {'type': 'integer', 'format': 'int32'},
24+
FieldDescriptor.TYPE_FIXED64: {'type': 'string', 'format': 'fixed64'},
25+
FieldDescriptor.TYPE_FIXED32: {'type': 'integer', 'format': 'fixed32'},
26+
FieldDescriptor.TYPE_BOOL: {'type': 'boolean'},
27+
FieldDescriptor.TYPE_STRING: {'type': 'string'},
28+
FieldDescriptor.TYPE_BYTES: {'type': 'string', 'format': 'byte'},
29+
FieldDescriptor.TYPE_UINT32: {'type': 'integer', 'format': 'uint32'},
30+
FieldDescriptor.TYPE_SFIXED32: {'type': 'integer'},
31+
FieldDescriptor.TYPE_SFIXED64: {'type': 'string'},
32+
FieldDescriptor.TYPE_SINT32: {'type': 'integer'},
33+
FieldDescriptor.TYPE_SINT64: {'type': 'string'},
34+
}
35+
36+
_WELL_KNOWN_SCHEMAS: dict[str, dict[str, Any]] = {
37+
'google.protobuf.Timestamp': {'type': 'string', 'format': 'date-time'},
38+
'google.protobuf.Duration': {'type': 'string'},
39+
'google.protobuf.Struct': {'type': 'object'},
40+
'google.protobuf.Value': {},
41+
'google.protobuf.ListValue': {'type': 'array', 'items': {}},
42+
'google.protobuf.Empty': {'type': 'object'},
43+
'google.protobuf.Any': {'type': 'object'},
44+
'google.protobuf.FieldMask': {'type': 'string'},
45+
}
46+
47+
48+
def field_schema(
49+
field: FieldDescriptor, components: dict[str, Any]
50+
) -> dict[str, Any]:
51+
if field.message_type and field.message_type.GetOptions().map_entry:
52+
value_field = field.message_type.fields_by_name['value']
53+
return {
54+
'type': 'object',
55+
'additionalProperties': field_schema(value_field, components),
56+
}
57+
58+
if field.type == FieldDescriptor.TYPE_MESSAGE:
59+
item = message_schema(field.message_type, components)
60+
elif field.type == FieldDescriptor.TYPE_ENUM:
61+
item = {
62+
'type': 'string',
63+
'enum': [v.name for v in field.enum_type.values],
64+
}
65+
else:
66+
item = dict(_PROTO_SCALAR_SCHEMAS.get(field.type, {'type': 'string'}))
67+
68+
if field.is_repeated:
69+
return {'type': 'array', 'items': item}
70+
return item
71+
72+
73+
def message_schema(
74+
descriptor: Descriptor | Any, components: dict[str, Any]
75+
) -> dict[str, Any]:
76+
"""Returns a $ref to descriptor's schema, registering it in components if needed."""
77+
if descriptor.full_name in _WELL_KNOWN_SCHEMAS:
78+
return dict(_WELL_KNOWN_SCHEMAS[descriptor.full_name])
79+
80+
name = descriptor.name
81+
ref = {'$ref': f'#/components/schemas/{name}'}
82+
if name in components:
83+
return ref
84+
85+
# Reserve the slot before recursing so cyclic types terminate.
86+
components[name] = {}
87+
88+
real_oneofs = [o for o in descriptor.oneofs if len(o.fields) > 1]
89+
oneof_field_names = {f.name for o in real_oneofs for f in o.fields}
90+
base_properties = {
91+
f.name: field_schema(f, components)
92+
for f in descriptor.fields
93+
if f.name not in oneof_field_names
94+
}
95+
96+
if not real_oneofs:
97+
components[name] = {'type': 'object', 'properties': base_properties}
98+
return ref
99+
100+
oneof_constraints = [
101+
{
102+
'oneOf': [
103+
{
104+
'type': 'object',
105+
'properties': {f.name: field_schema(f, components)},
106+
'required': [f.name],
107+
}
108+
for f in oneof.fields
109+
]
110+
}
111+
for oneof in real_oneofs
112+
]
113+
parts: list[dict[str, Any]] = []
114+
if base_properties:
115+
parts.append({'type': 'object', 'properties': base_properties})
116+
parts.extend(oneof_constraints)
117+
components[name] = parts[0] if len(parts) == 1 else {'allOf': parts}
118+
return ref

src/a2a/server/routes/agent_card_routes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def create_agent_card_routes(
4141
)
4242

4343
async def _get_agent_card(request: Request) -> Response:
44+
"""Returns the public AgentCard describing this agent's capabilities, supported transports, and skills."""
4445
card_to_serve = agent_card
4546
if card_modifier:
4647
card_to_serve = await card_modifier(card_to_serve)

0 commit comments

Comments
 (0)