Skip to content

Commit 52e1abc

Browse files
authored
[agentserver-invocations] Serve AsyncAPI docs at /invocations/docs/asyncapi.{json,yaml} (#47829)
* [agentserver-invocations] Serve AsyncAPI docs at /invocations/docs/asyncapi.{json,yaml} InvocationAgentServerHost now accepts optional `asyncapi_spec_json` (dict) and/or `asyncapi_spec_yaml` (raw YAML str) constructor args. When set, they are served at GET /invocations/docs/asyncapi.json and GET /invocations/docs/asyncapi.yaml respectively — the AsyncAPI companion to the existing openapi.json endpoint for streaming/bidirectional surfaces (e.g. the invocations_ws WebSocket protocol) that OpenAPI cannot express. Path extension is authoritative for the returned content type — no Accept negotiation, no format conversion between the two representations. Either returns 404 if not registered; users may register only one, only the other, or both. Constructor rejects wrong-typed args (dict/str) with TypeError to prevent misuse from silently leaking a stringified dict to clients as YAML. Startup log extended to record which specs are configured. Bumps version to 1.0.0b7 and syncs api.md with the new public surface. Companion to TypeSpec PR Azure/azure-rest-api-specs#44416. Fixes: AB#5407709 * Address review: yaml charset, drop redundant asyncio marks, clarify validation - _invocation.py: set media_type to "application/yaml; charset=utf-8". RFC 9512 mandates UTF-8 for application/yaml, but clients don't universally assume it; declaring the charset avoids downstream mis-decoding of non-ASCII content in the raw YAML string. - _invocation.py: add a comment on the asyncapi_spec_* isinstance guards explaining why openapi_spec is intentionally left unchecked (backward-compat with dict-like Mapping subclasses), so future readers don't file the inconsistency as a bug. - test_server_routes.py: drop @pytest.mark.asyncio + async on the two TypeError-raising tests. Their bodies are purely synchronous (constructor call inside pytest.raises); the async wrapping was spurious.
1 parent eb67b14 commit 52e1abc

7 files changed

Lines changed: 235 additions & 5 deletions

File tree

sdk/agentserver/azure-ai-agentserver-invocations/CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Release History
22

3+
## 1.0.0b7 (Unreleased)
4+
5+
### Features Added
6+
7+
- AsyncAPI docs endpoints — `InvocationAgentServerHost` now accepts optional
8+
`asyncapi_spec_json` (dict) and/or `asyncapi_spec_yaml` (raw YAML string)
9+
constructor args, served at `GET /invocations/docs/asyncapi.json` and
10+
`GET /invocations/docs/asyncapi.yaml` respectively. Either representation
11+
returns `404` if not registered. See README for details.
12+
313
## 1.0.0b6 (2026-06-28)
414

515
### Features Added

sdk/agentserver/azure-ai-agentserver-invocations/README.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
The `azure-ai-agentserver-invocations` package provides the invocation protocol endpoints for Azure AI Hosted Agent containers. It plugs into the [`azure-ai-agentserver-core`](https://pypi.org/project/azure-ai-agentserver-core/) host framework and supports two transports on the same host:
44

5-
- **HTTP** (`invocations` protocol) — `POST /invocations`, `GET /invocations/{id}`, `POST /invocations/{id}/cancel`, `GET /invocations/docs/openapi.json`.
5+
- **HTTP** (`invocations` protocol) — `POST /invocations`, `GET /invocations/{id}`, `POST /invocations/{id}/cancel`, `GET /invocations/docs/openapi.json`, `GET /invocations/docs/asyncapi.{json,yaml}`.
66
- **WebSocket** (`invocations_ws` protocol) — full-duplex streaming at `/invocations_ws`, registered with `@app.ws_handler`.
77

88
## Getting started
@@ -38,6 +38,8 @@ This automatically installs `azure-ai-agentserver-core` as a dependency.
3838
| `GET` | `/invocations/{invocation_id}` | No | Retrieve invocation status or result |
3939
| `POST` | `/invocations/{invocation_id}/cancel` | No | Cancel a running invocation |
4040
| `GET` | `/invocations/docs/openapi.json` | No | Serve the agent's OpenAPI 3.x spec |
41+
| `GET` | `/invocations/docs/asyncapi.json` | No | Serve the agent's AsyncAPI 3.x spec (JSON) |
42+
| `GET` | `/invocations/docs/asyncapi.yaml` | No | Serve the agent's AsyncAPI 3.x spec (YAML) |
4143
| `WS` | `/invocations_ws` | No | Full-duplex WebSocket transport (`invocations_ws` protocol) |
4244

4345
### Request and response headers
@@ -225,6 +227,39 @@ app = InvocationAgentServerHost(openapi_spec={
225227
})
226228
```
227229

230+
### Serving an AsyncAPI spec
231+
232+
AsyncAPI is the companion to OpenAPI for streaming/bidirectional surfaces (e.g. the
233+
`invocations_ws` WebSocket protocol) that OpenAPI cannot express. Pass either or both
234+
representations to enable the discovery endpoints:
235+
236+
```python
237+
app = InvocationAgentServerHost(
238+
asyncapi_spec_json={
239+
"asyncapi": "3.0.0",
240+
"info": {"title": "My Agent", "version": "1.0.0"},
241+
"channels": { ... },
242+
"operations": { ... },
243+
},
244+
asyncapi_spec_yaml="""asyncapi: 3.0.0
245+
info:
246+
title: My Agent
247+
version: 1.0.0
248+
channels:
249+
...
250+
""",
251+
)
252+
```
253+
254+
Each representation is served at its own path:
255+
256+
- `GET /invocations/docs/asyncapi.json``application/json`
257+
- `GET /invocations/docs/asyncapi.yaml``application/yaml`
258+
259+
The path extension is authoritative for the returned content type (no `Accept`
260+
negotiation, no format conversion). If you only pass one, the other returns `404`.
261+
Serving both is recommended for tooling compatibility.
262+
228263
## WebSocket protocol (`invocations_ws`)
229264

230265
The same `InvocationAgentServerHost` object also exposes a WebSocket transport at `/invocations_ws`. Container authors do not install or import a second package — registering an `@app.ws_handler` is the only step. A multi-protocol agent shares one host, one session, and one process.

sdk/agentserver/azure-ai-agentserver-invocations/api.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ namespace azure.ai.agentserver.invocations
1515
def __init__(
1616
self,
1717
*,
18+
asyncapi_spec_json: Optional[dict[str, Any]] = ...,
19+
asyncapi_spec_yaml: Optional[str] = ...,
1820
openapi_spec: Optional[dict[str, Any]] = ...,
1921
**kwargs: Any
2022
) -> None: ...
@@ -45,6 +47,10 @@ namespace azure.ai.agentserver.invocations
4547

4648
def cancel_invocation_handler(self, fn: Callable[[Request], Awaitable[Response]]) -> Callable[[Request], Awaitable[Response]]: ...
4749

50+
def get_asyncapi_spec_json(self) -> Optional[dict[str, Any]]: ...
51+
52+
def get_asyncapi_spec_yaml(self) -> Optional[str]: ...
53+
4854
def get_invocation_handler(self, fn: Callable[[Request], Awaitable[Response]]) -> Callable[[Request], Awaitable[Response]]: ...
4955

5056
def get_openapi_spec(self) -> Optional[dict[str, Any]]: ...
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
apiMdSha256: 20e0c715368a99cb4302338429be892247a5f77a7500a96cf5164fbce402050d
1+
apiMdSha256: dbfee3ed6344b859cdcfa55da4772aa2cdb8e4316bbc99f9bc8c7c2abf43e00e
22
parserVersion: 0.3.28
3-
pythonVersion: 3.11.9
3+
pythonVersion: 3.14.2

sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_invocation.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,15 @@ async def ws(websocket: WebSocket) -> None:
184184
:param openapi_spec: Optional OpenAPI spec dict. When provided, the spec
185185
is served at ``GET /invocations/docs/openapi.json``.
186186
:type openapi_spec: Optional[dict[str, Any]]
187+
:param asyncapi_spec_json: Optional AsyncAPI spec dict. When provided, the spec
188+
is served at ``GET /invocations/docs/asyncapi.json``. AsyncAPI is the companion
189+
to OpenAPI for streaming/bidirectional surfaces (e.g. ``invocations_ws``).
190+
:type asyncapi_spec_json: Optional[dict[str, Any]]
191+
:param asyncapi_spec_yaml: Optional AsyncAPI spec as raw YAML text. When provided,
192+
the spec is served verbatim at ``GET /invocations/docs/asyncapi.yaml``. The
193+
path extension is authoritative for the returned content type (no ``Accept``
194+
negotiation); the platform never converts between JSON and YAML.
195+
:type asyncapi_spec_yaml: Optional[str]
187196
"""
188197

189198
_INSTRUMENTATION_SCOPE = "Azure.AI.AgentServer.Invocations"
@@ -192,12 +201,29 @@ def __init__(
192201
self,
193202
*,
194203
openapi_spec: Optional[dict[str, Any]] = None,
204+
asyncapi_spec_json: Optional[dict[str, Any]] = None,
205+
asyncapi_spec_yaml: Optional[str] = None,
195206
**kwargs: Any,
196207
) -> None:
197208
self._invoke_fn: Optional[Callable] = None
198209
self._get_invocation_fn: Optional[Callable] = None
199210
self._cancel_invocation_fn: Optional[Callable] = None
211+
# asyncapi_spec_* are validated eagerly: a stringified dict passed as
212+
# asyncapi_spec_yaml is technically valid YAML and would silently reach
213+
# clients as a single-line JSON blob. openapi_spec is left unchecked to
214+
# preserve backward-compat with existing callers passing dict-like
215+
# Mapping subclasses; consider unifying in a future release.
216+
if asyncapi_spec_json is not None and not isinstance(asyncapi_spec_json, dict):
217+
raise TypeError(
218+
f"asyncapi_spec_json must be dict, got {type(asyncapi_spec_json).__name__}"
219+
)
220+
if asyncapi_spec_yaml is not None and not isinstance(asyncapi_spec_yaml, str):
221+
raise TypeError(
222+
f"asyncapi_spec_yaml must be str, got {type(asyncapi_spec_yaml).__name__}"
223+
)
200224
self._openapi_spec = openapi_spec
225+
self._asyncapi_spec_json = asyncapi_spec_json
226+
self._asyncapi_spec_yaml = asyncapi_spec_yaml
201227

202228
# Initialise WS handler slots (no parameters — the keep-alive
203229
# interval lives on ``AgentConfig`` and is wired into Hypercorn
@@ -212,6 +238,18 @@ def __init__(
212238
methods=["GET"],
213239
name="get_openapi_spec",
214240
),
241+
Route(
242+
"/invocations/docs/asyncapi.json",
243+
self._get_asyncapi_spec_json_endpoint,
244+
methods=["GET"],
245+
name="get_asyncapi_spec_json",
246+
),
247+
Route(
248+
"/invocations/docs/asyncapi.yaml",
249+
self._get_asyncapi_spec_yaml_endpoint,
250+
methods=["GET"],
251+
name="get_asyncapi_spec_yaml",
252+
),
215253
Route(
216254
"/invocations",
217255
self._create_invocation_endpoint,
@@ -238,8 +276,11 @@ def __init__(
238276

239277
# --- Invocations startup configuration logging ---
240278
logger.info(
241-
"Invocations protocol: openapi_spec_configured=%s",
279+
"Invocations protocol: openapi_spec_configured=%s, "
280+
"asyncapi_spec_json_configured=%s, asyncapi_spec_yaml_configured=%s",
242281
self._openapi_spec is not None,
282+
self._asyncapi_spec_json is not None,
283+
self._asyncapi_spec_yaml is not None,
243284
)
244285

245286
# ------------------------------------------------------------------
@@ -342,6 +383,14 @@ def get_openapi_spec(self) -> Optional[dict[str, Any]]:
342383
"""Return the stored OpenAPI spec, or None."""
343384
return self._openapi_spec
344385

386+
def get_asyncapi_spec_json(self) -> Optional[dict[str, Any]]:
387+
"""Return the stored AsyncAPI spec (JSON representation), or None."""
388+
return self._asyncapi_spec_json
389+
390+
def get_asyncapi_spec_yaml(self) -> Optional[str]:
391+
"""Return the stored AsyncAPI spec as raw YAML text, or None."""
392+
return self._asyncapi_spec_yaml
393+
345394
# ------------------------------------------------------------------
346395
# Span attribute helper
347396
# ------------------------------------------------------------------
@@ -360,6 +409,26 @@ async def _get_openapi_spec_endpoint(self, request: Request) -> Response: # pyl
360409
)
361410
return JSONResponse(spec)
362411

412+
async def _get_asyncapi_spec_json_endpoint(self, request: Request) -> Response: # pylint: disable=unused-argument
413+
spec = self.get_asyncapi_spec_json()
414+
if spec is None:
415+
return create_error_response(
416+
"not_found", "No AsyncAPI (JSON) spec registered",
417+
status_code=404,
418+
headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM),
419+
)
420+
return JSONResponse(spec)
421+
422+
async def _get_asyncapi_spec_yaml_endpoint(self, request: Request) -> Response: # pylint: disable=unused-argument
423+
spec = self.get_asyncapi_spec_yaml()
424+
if spec is None:
425+
return create_error_response(
426+
"not_found", "No AsyncAPI (YAML) spec registered",
427+
status_code=404,
428+
headers=_apply_error_source_headers({}, _ERROR_SOURCE_UPSTREAM),
429+
)
430+
return Response(spec, media_type="application/yaml; charset=utf-8")
431+
363432
def _wrap_streaming_response(
364433
self,
365434
response: StreamingResponse,

sdk/agentserver/azure-ai-agentserver-invocations/azure/ai/agentserver/invocations/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
# Copyright (c) Microsoft Corporation. All rights reserved.
33
# ---------------------------------------------------------
44

5-
VERSION = "1.0.0b6"
5+
VERSION = "1.0.0b7"

sdk/agentserver/azure-ai-agentserver-invocations/tests/test_server_routes.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,116 @@ async def handle(request: Request) -> Response:
6969
assert resp.json() == SAMPLE_OPENAPI_SPEC
7070

7171

72+
# ---------------------------------------------------------------------------
73+
# GET asyncapi specs return 404 when not set
74+
# ---------------------------------------------------------------------------
75+
76+
@pytest.mark.asyncio
77+
async def test_get_asyncapi_json_returns_404_when_not_set(no_spec_client):
78+
"""GET /invocations/docs/asyncapi.json returns 404 when no spec registered."""
79+
resp = await no_spec_client.get("/invocations/docs/asyncapi.json")
80+
assert resp.status_code == 404
81+
82+
83+
@pytest.mark.asyncio
84+
async def test_get_asyncapi_yaml_returns_404_when_not_set(no_spec_client):
85+
"""GET /invocations/docs/asyncapi.yaml returns 404 when no spec registered."""
86+
resp = await no_spec_client.get("/invocations/docs/asyncapi.yaml")
87+
assert resp.status_code == 404
88+
89+
90+
# ---------------------------------------------------------------------------
91+
# GET asyncapi specs return spec when registered
92+
# ---------------------------------------------------------------------------
93+
94+
SAMPLE_ASYNCAPI_SPEC_JSON = {
95+
"asyncapi": "3.0.0",
96+
"info": {"title": "Test Agent", "version": "1.0.0"},
97+
"channels": {},
98+
"operations": {},
99+
}
100+
101+
SAMPLE_ASYNCAPI_SPEC_YAML = (
102+
"asyncapi: 3.0.0\n"
103+
"info:\n"
104+
" title: Test Agent\n"
105+
" version: 1.0.0\n"
106+
"channels: {}\n"
107+
"operations: {}\n"
108+
)
109+
110+
111+
@pytest.mark.asyncio
112+
async def test_get_asyncapi_json_returns_spec_when_registered():
113+
"""GET /invocations/docs/asyncapi.json returns the spec when registered."""
114+
app = InvocationAgentServerHost(asyncapi_spec_json=SAMPLE_ASYNCAPI_SPEC_JSON)
115+
116+
@app.invoke_handler
117+
async def handle(request: Request) -> Response:
118+
return Response(content=b"ok")
119+
120+
transport = ASGITransport(app=app)
121+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
122+
resp = await client.get("/invocations/docs/asyncapi.json")
123+
assert resp.status_code == 200
124+
assert resp.json() == SAMPLE_ASYNCAPI_SPEC_JSON
125+
126+
127+
@pytest.mark.asyncio
128+
async def test_get_asyncapi_yaml_returns_spec_when_registered():
129+
"""GET /invocations/docs/asyncapi.yaml returns the spec verbatim with YAML media type."""
130+
app = InvocationAgentServerHost(asyncapi_spec_yaml=SAMPLE_ASYNCAPI_SPEC_YAML)
131+
132+
@app.invoke_handler
133+
async def handle(request: Request) -> Response:
134+
return Response(content=b"ok")
135+
136+
transport = ASGITransport(app=app)
137+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
138+
resp = await client.get("/invocations/docs/asyncapi.yaml")
139+
assert resp.status_code == 200
140+
assert resp.text == SAMPLE_ASYNCAPI_SPEC_YAML
141+
assert resp.headers["content-type"].startswith("application/yaml")
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_asyncapi_representations_are_independent():
146+
"""Registering only JSON leaves YAML as 404 and vice versa (no format conversion)."""
147+
json_only = InvocationAgentServerHost(asyncapi_spec_json=SAMPLE_ASYNCAPI_SPEC_JSON)
148+
149+
@json_only.invoke_handler
150+
async def handle_json_only(request: Request) -> Response:
151+
return Response(content=b"ok")
152+
153+
transport = ASGITransport(app=json_only)
154+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
155+
assert (await client.get("/invocations/docs/asyncapi.json")).status_code == 200
156+
assert (await client.get("/invocations/docs/asyncapi.yaml")).status_code == 404
157+
158+
yaml_only = InvocationAgentServerHost(asyncapi_spec_yaml=SAMPLE_ASYNCAPI_SPEC_YAML)
159+
160+
@yaml_only.invoke_handler
161+
async def handle_yaml_only(request: Request) -> Response:
162+
return Response(content=b"ok")
163+
164+
transport = ASGITransport(app=yaml_only)
165+
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
166+
assert (await client.get("/invocations/docs/asyncapi.yaml")).status_code == 200
167+
assert (await client.get("/invocations/docs/asyncapi.json")).status_code == 404
168+
169+
170+
def test_asyncapi_json_rejects_non_dict():
171+
"""Constructor rejects non-dict asyncapi_spec_json to avoid silent misuse."""
172+
with pytest.raises(TypeError, match="asyncapi_spec_json must be dict"):
173+
InvocationAgentServerHost(asyncapi_spec_json="not a dict") # type: ignore[arg-type]
174+
175+
176+
def test_asyncapi_yaml_rejects_non_str():
177+
"""Constructor rejects non-str asyncapi_spec_yaml to avoid stringified dict leaking to client."""
178+
with pytest.raises(TypeError, match="asyncapi_spec_yaml must be str"):
179+
InvocationAgentServerHost(asyncapi_spec_yaml={"not": "a string"}) # type: ignore[arg-type]
180+
181+
72182
# ---------------------------------------------------------------------------
73183
# GET /invocations/{id} returns 404 default
74184
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)