Skip to content

Commit 697db0d

Browse files
feat(server): restore FastAPI /docs visibility for A2A routes
1 parent 7af6050 commit 697db0d

11 files changed

Lines changed: 744 additions & 0 deletions

File tree

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.helpers 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',

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)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from a2a.server.routes.helpers.fastapi import add_a2a_routes_to_fastapi
2+
3+
__all__ = [
4+
'add_a2a_routes_to_fastapi',
5+
]
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Proto → JSON Schema helpers shared across transport helpers."""
2+
3+
from typing import Any
4+
5+
from google.protobuf.descriptor import Descriptor, FieldDescriptor
6+
7+
from a2a.types.a2a_pb2 import (
8+
SendMessageRequest,
9+
TaskPushNotificationConfig,
10+
)
11+
12+
13+
REST_BODY_TYPES: dict[tuple[str, str], type] = {
14+
('/message:send', 'POST'): SendMessageRequest,
15+
('/message:stream', 'POST'): SendMessageRequest,
16+
('/tasks/{id}/pushNotificationConfigs', 'POST'): TaskPushNotificationConfig,
17+
}
18+
19+
# 64-bit integer types serialize as strings in protojson.
20+
_PROTO_SCALAR_SCHEMAS: dict[int, dict[str, Any]] = {
21+
FieldDescriptor.TYPE_DOUBLE: {'type': 'number'},
22+
FieldDescriptor.TYPE_FLOAT: {'type': 'number'},
23+
FieldDescriptor.TYPE_INT64: {'type': 'string', 'format': 'int64'},
24+
FieldDescriptor.TYPE_UINT64: {'type': 'string', 'format': 'uint64'},
25+
FieldDescriptor.TYPE_INT32: {'type': 'integer', 'format': 'int32'},
26+
FieldDescriptor.TYPE_FIXED64: {'type': 'string', 'format': 'fixed64'},
27+
FieldDescriptor.TYPE_FIXED32: {'type': 'integer', 'format': 'fixed32'},
28+
FieldDescriptor.TYPE_BOOL: {'type': 'boolean'},
29+
FieldDescriptor.TYPE_STRING: {'type': 'string'},
30+
FieldDescriptor.TYPE_BYTES: {'type': 'string', 'format': 'byte'},
31+
FieldDescriptor.TYPE_UINT32: {'type': 'integer', 'format': 'uint32'},
32+
FieldDescriptor.TYPE_SFIXED32: {'type': 'integer'},
33+
FieldDescriptor.TYPE_SFIXED64: {'type': 'string'},
34+
FieldDescriptor.TYPE_SINT32: {'type': 'integer'},
35+
FieldDescriptor.TYPE_SINT64: {'type': 'string'},
36+
}
37+
38+
_WELL_KNOWN_SCHEMAS: dict[str, dict[str, Any]] = {
39+
'google.protobuf.Timestamp': {'type': 'string', 'format': 'date-time'},
40+
'google.protobuf.Duration': {'type': 'string'},
41+
'google.protobuf.Struct': {'type': 'object'},
42+
'google.protobuf.Value': {},
43+
'google.protobuf.ListValue': {'type': 'array', 'items': {}},
44+
'google.protobuf.Empty': {'type': 'object'},
45+
'google.protobuf.Any': {'type': 'object'},
46+
'google.protobuf.FieldMask': {'type': 'string'},
47+
}
48+
49+
50+
def field_schema(
51+
field: FieldDescriptor, components: dict[str, Any]
52+
) -> dict[str, Any]:
53+
if field.message_type and field.message_type.GetOptions().map_entry:
54+
value_field = field.message_type.fields_by_name['value']
55+
return {
56+
'type': 'object',
57+
'additionalProperties': field_schema(value_field, components),
58+
}
59+
60+
if field.type == FieldDescriptor.TYPE_MESSAGE:
61+
item = message_schema(field.message_type, components)
62+
elif field.type == FieldDescriptor.TYPE_ENUM:
63+
item = {
64+
'type': 'string',
65+
'enum': [v.name for v in field.enum_type.values],
66+
}
67+
else:
68+
item = dict(_PROTO_SCALAR_SCHEMAS.get(field.type, {'type': 'string'}))
69+
70+
if field.is_repeated:
71+
return {'type': 'array', 'items': item}
72+
return item
73+
74+
75+
def message_schema(
76+
descriptor: Descriptor, components: dict[str, Any]
77+
) -> dict[str, Any]:
78+
"""Returns a $ref to descriptor's schema, registering it in components if needed."""
79+
if descriptor.full_name in _WELL_KNOWN_SCHEMAS:
80+
return dict(_WELL_KNOWN_SCHEMAS[descriptor.full_name])
81+
82+
name = descriptor.name
83+
ref = {'$ref': f'#/components/schemas/{name}'}
84+
if name in components:
85+
return ref
86+
87+
# Reserve the slot before recursing so cyclic types terminate.
88+
components[name] = {}
89+
90+
real_oneofs = [o for o in descriptor.oneofs if len(o.fields) > 1]
91+
oneof_field_names = {f.name for o in real_oneofs for f in o.fields}
92+
base_properties = {
93+
f.name: field_schema(f, components)
94+
for f in descriptor.fields
95+
if f.name not in oneof_field_names
96+
}
97+
98+
if not real_oneofs:
99+
components[name] = {'type': 'object', 'properties': base_properties}
100+
return ref
101+
102+
variants: list[dict[str, Any]] = [{}]
103+
for oneof in real_oneofs:
104+
variants = [
105+
{**variant, f.name: field_schema(f, components)}
106+
for variant in variants
107+
for f in oneof.fields
108+
]
109+
components[name] = {
110+
'oneOf': [
111+
{
112+
'type': 'object',
113+
'properties': {**base_properties, **variant_props},
114+
'required': sorted(set(variant_props) & oneof_field_names),
115+
}
116+
for variant_props in variants
117+
],
118+
}
119+
return ref
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
from typing import TYPE_CHECKING, Any
2+
3+
from a2a.server.routes.helpers._proto_schema import (REST_BODY_TYPES,
4+
message_schema)
5+
from a2a.server.routes.helpers.jsonrpc import \
6+
DESCRIPTION as _JSONRPC_DESCRIPTION
7+
from a2a.server.routes.helpers.jsonrpc import \
8+
envelope_schema as _jsonrpc_envelope_schema
9+
from a2a.utils.constants import PROTOCOL_VERSION_1_0, VERSION_HEADER
10+
11+
if TYPE_CHECKING:
12+
from fastapi import FastAPI
13+
from starlette.routing import BaseRoute, Route
14+
15+
_package_fastapi_installed = True
16+
else:
17+
try:
18+
from fastapi.routing import APIRoute
19+
from starlette.routing import Route, request_response
20+
21+
class _A2ARoute(APIRoute):
22+
"""APIRoute that uses Starlette's request_response to bypass FastAPI middleware scope requirements."""
23+
24+
def __init__(self, *args: Any, **kwargs: Any) -> None:
25+
super().__init__(*args, **kwargs)
26+
self.app = request_response(self.endpoint)
27+
28+
_package_fastapi_installed = True
29+
except ImportError:
30+
Route = Any
31+
_A2ARoute = Any
32+
33+
_package_fastapi_installed = False
34+
35+
36+
_AGENT_CARD_TAG = 'A2A: Agent Card'
37+
_JSONRPC_TAG = 'A2A: JSON-RPC'
38+
_REST_TAG = 'A2A: REST'
39+
40+
_A2A_VERSION_HEADER = {
41+
'in': 'header',
42+
'name': VERSION_HEADER,
43+
'required': True,
44+
'schema': {'type': 'string', 'enum': [PROTOCOL_VERSION_1_0]},
45+
'example': PROTOCOL_VERSION_1_0,
46+
}
47+
48+
49+
def _request_body_extra(
50+
ref: dict[str, Any], description: str
51+
) -> dict[str, Any]:
52+
return {
53+
'requestBody': {
54+
'description': description,
55+
'required': True,
56+
'content': {'application/json': {'schema': ref}},
57+
},
58+
}
59+
60+
61+
def _rest_body_extra(
62+
route: 'Route', rest_bodies: dict[tuple[str, str], dict[str, Any]]
63+
) -> dict[str, Any] | None:
64+
methods = route.methods or set()
65+
for (suffix, method), extra in rest_bodies.items():
66+
if method in methods and route.path.endswith(suffix):
67+
return extra
68+
return None
69+
70+
71+
def _attach_route(
72+
app: 'FastAPI',
73+
route: 'BaseRoute',
74+
tag: str,
75+
openapi_extra: dict[str, Any] | None,
76+
require_version_header: bool = False,
77+
) -> None:
78+
if not (isinstance(route, Route) and route.methods):
79+
app.routes.append(route)
80+
return
81+
# Drop HEAD: Starlette adds it alongside GET, but FastAPI registers duplicate operation IDs.
82+
methods = sorted(m for m in route.methods if m != 'HEAD')
83+
if require_version_header:
84+
extra = dict(openapi_extra or {})
85+
extra.setdefault('parameters', [_A2A_VERSION_HEADER])
86+
openapi_extra = extra
87+
app.routes.append(
88+
_A2ARoute(
89+
path=route.path,
90+
endpoint=route.endpoint,
91+
methods=methods,
92+
tags=[tag],
93+
openapi_extra=openapi_extra,
94+
)
95+
)
96+
97+
98+
def _install_components(app: 'FastAPI', schemas: dict[str, Any]) -> None:
99+
original_openapi = app.openapi
100+
101+
def _openapi() -> dict[str, Any]:
102+
if app.openapi_schema:
103+
return app.openapi_schema
104+
schema = original_openapi()
105+
component_schemas = schema.setdefault('components', {}).setdefault(
106+
'schemas', {}
107+
)
108+
for name, sub_schema in schemas.items():
109+
component_schemas.setdefault(name, sub_schema)
110+
return schema
111+
112+
app.openapi = _openapi # type: ignore[method-assign]
113+
114+
115+
def add_a2a_routes_to_fastapi(
116+
app: 'FastAPI',
117+
*,
118+
agent_card_routes: 'list[BaseRoute] | None' = None,
119+
jsonrpc_routes: 'list[BaseRoute] | None' = None,
120+
rest_routes: 'list[BaseRoute] | None' = None,
121+
) -> None:
122+
"""Mounts A2A routes on a FastAPI app and enriches them for ``/docs``.
123+
124+
Re-registers Starlette routes as ``APIRoute`` instances so they appear in
125+
the auto-generated OpenAPI schema, tagged and annotated with proto-derived
126+
request-body schemas.
127+
128+
Usage::
129+
130+
app = FastAPI()
131+
add_a2a_routes_to_fastapi(
132+
app,
133+
agent_card_routes=create_agent_card_routes(agent_card),
134+
jsonrpc_routes=create_jsonrpc_routes(request_handler, rpc_url='/'),
135+
rest_routes=create_rest_routes(request_handler),
136+
)
137+
138+
Args:
139+
app: The FastAPI application to mount the routes on.
140+
agent_card_routes: Routes returned by ``create_agent_card_routes``.
141+
jsonrpc_routes: Routes returned by ``create_jsonrpc_routes``.
142+
rest_routes: Routes returned by ``create_rest_routes``.
143+
"""
144+
if not _package_fastapi_installed:
145+
raise ImportError(
146+
'The `fastapi` package is required to use '
147+
'`add_a2a_routes_to_fastapi`. Install it alongside '
148+
'`a2a-sdk[http-server]`.'
149+
)
150+
151+
components: dict[str, Any] = {}
152+
jsonrpc_extra = {
153+
'summary': 'A2A JSON-RPC endpoint',
154+
'description': _JSONRPC_DESCRIPTION,
155+
**_request_body_extra(
156+
_jsonrpc_envelope_schema(components), 'A2A JSON-RPC 2.0 request'
157+
),
158+
}
159+
rest_extras = {
160+
key: _request_body_extra(
161+
message_schema(cls.DESCRIPTOR, components),
162+
f'A2A {cls.__name__}',
163+
)
164+
for key, cls in REST_BODY_TYPES.items()
165+
}
166+
167+
for route in agent_card_routes or ():
168+
_attach_route(app, route, _AGENT_CARD_TAG, openapi_extra=None)
169+
170+
for route in jsonrpc_routes or ():
171+
extra = jsonrpc_extra if isinstance(route, Route) else None
172+
_attach_route(app, route, _JSONRPC_TAG, openapi_extra=extra, require_version_header=True)
173+
174+
for route in rest_routes or ():
175+
extra = (
176+
_rest_body_extra(route, rest_extras)
177+
if isinstance(route, Route)
178+
else None
179+
)
180+
_attach_route(app, route, _REST_TAG, openapi_extra=extra, require_version_header=True)
181+
182+
_install_components(app, components)

0 commit comments

Comments
 (0)