Skip to content

feat(api): type the HTTP wire from AG-UI and the read routes from their shapes - #81

Merged
ciaransweet merged 2 commits into
mainfrom
feat/api-response-models
Aug 14, 2026
Merged

feat(api): type the HTTP wire from AG-UI and the read routes from their shapes#81
ciaransweet merged 2 commits into
mainfrom
feat/api-response-models

Conversation

@ciaransweet

@ciaransweet ciaransweet commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

The generated OpenAPI described four of the five routes as a bare
object, additionalProperties: true, messages on POST /runs as a list of
untyped dicts, and the SSE stream as application/json — which is the one thing
a turn never is. A consumer could not write a client from it.

What the document says now

route before after
POST /runs request list[dict] RunRequestag_ui.core.Message, all seven roles
POST /runs response application/json {} text/event-stream, oneOf of all 33 AG-UI events, discriminated on type
GET /threads/{id} object ThreadResponse (messages typed as Message too)
GET /threads/{id}/turns object TurnsResponseTurnInfo
GET /threads/{id}/state/{key} object StateValueResponse
GET /views/{toolset}/{view} text/html string unchanged; already honest

8 component schemas → 65.

The AG-UI types come from AG-UI

ag-ui-protocol is already the [api] extra's dependency and exports both
unions, so nothing is re-described by hand and none of it can drift from the
protocol. Message includes the activity and reasoning roles, which is what
makes typing the request safe: a client echoing back a history containing the
activity messages this server emitted still validates.

Our own four models — ThreadResponse, TurnsResponse, TurnInfo,
StateValueResponse and the StateEntryInfo three of them share — are exported
from mcp_agent_api, so a Python client validates against them instead of
re-declaring the shapes.

Three mechanics worth reviewing, all established by testing

Passing the Event union as the response model is what registers the event
schemas into components.
Without it the oneOf's $refs dangle and Swagger
renders nothing. Inlining the schema instead would have looked correct in the
source and been broken in the browser, because #/$defs/... resolves against the
document root rather than the enclosing schema. There is a test asserting every
referenced branch is defined.

A StreamingResponse subclass naming media_type is what keeps the default
application/json out.
Passing the model without it documented both media
types. The handler builds its own response, so this only affects the document.

The read models are attached through responses=, not response_model=, so
nothing is re-serialised. Two nullable fields need opposite treatment:
state_metadata omits seq until the write is merged, so a client ordering by
it is never sorting nulls, while kind: null is meaningful — the value is
untyped — and must stay. A plain response_model turns the first into
"seq": null; response_model_exclude_none drops the second; a
@model_serializer fixes the wire and then erases the schema, which is the whole
point of the change. A test pins both halves after a real turn.

One behaviour change

A posted message without an id now gets a 422. The protocol requires one,
so using its models enforces it. Any string does — a fresh uuid per message is
the obvious choice — and nothing here reads it: history is the server's, only the
trailing user message's text is taken, and the id a client posts is discarded
rather than stored. Documented in CONSUMING.md and on RunRequest, including
the awkward part: we require a field we then throw away, because the protocol
says so and claiming conformance while accepting what it forbids helps nobody.

338 tests pass, lint clean.

Raised from

Reviewing the Swagger page of the dss deployment now serving this API. Its
/health is dss's own route and gets the same treatment separately, in the PR
that bumps its lock.

🤖 Generated with Claude Code

ciaransweet and others added 2 commits August 14, 2026 10:28
The four read routes documented as a bare object with additionalProperties,
which tells a client writing against them nothing. They now carry
ThreadResponse, TurnsResponse, StateValueResponse and the StateEntryInfo all
three share, exported from mcp_agent_api so a Python client can validate
against them rather than re-declare them.

Attached through responses= rather than response_model=, so nothing is
re-serialised: a state entry omits seq until it is known, and a response model
would send it as null. POST /runs now advertises text/event-stream instead of
the default application/json, which is the one thing a turn never is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
messages on POST /runs, and the transcript GET /threads/{id} returns, are
ag_ui.core.Message — the protocol's discriminated union, activity role
included, so a client echoing its own history back still validates. The stream
is documented as ag_ui.core.Event: 33 event types discriminated on `type`,
under text/event-stream.

Passing the union as the response model is also what registers every event
schema into components, so the oneOf's refs resolve rather than rendering as
nothing. A StreamingResponse subclass naming its media_type is what keeps the
default application/json out.

Requiring an id on posted messages is the one behaviour change: the protocol
requires it, nothing here reads it, and any string will do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ciaransweet ciaransweet changed the title feat(api): response models for the read routes, exported feat(api): type the HTTP wire from AG-UI and the read routes from their shapes Aug 14, 2026
@ciaransweet
ciaransweet merged commit 097e162 into main Aug 14, 2026
4 checks passed
@ciaransweet
ciaransweet deleted the feat/api-response-models branch August 14, 2026 12:36
ciaransweet pushed a commit that referenced this pull request Aug 14, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.5.7](mcp-toolsets-runtime-v0.5.6...mcp-toolsets-runtime-v0.5.7)
(2026-08-14)


### Features

* **api:** type the HTTP wire from AG-UI and the read routes from their
shapes
([#81](#81))
([097e162](097e162))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: ds-release-bot[bot] <116609932+ds-release-bot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant