feat(api): type the HTTP wire from AG-UI and the read routes from their shapes - #81
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The generated OpenAPI described four of the five routes as a bare
object, additionalProperties: true,messagesonPOST /runsas a list ofuntyped dicts, and the SSE stream as
application/json— which is the one thinga turn never is. A consumer could not write a client from it.
What the document says now
POST /runsrequestlist[dict]RunRequest→ag_ui.core.Message, all seven rolesPOST /runsresponseapplication/json{}text/event-stream,oneOfof all 33 AG-UI events, discriminated ontypeGET /threads/{id}objectThreadResponse(messages typed asMessagetoo)GET /threads/{id}/turnsobjectTurnsResponse→TurnInfoGET /threads/{id}/state/{key}objectStateValueResponseGET /views/{toolset}/{view}text/htmlstring8 component schemas → 65.
The AG-UI types come from AG-UI
ag-ui-protocolis already the[api]extra's dependency and exports bothunions, so nothing is re-described by hand and none of it can drift from the
protocol.
Messageincludes theactivityandreasoningroles, which is whatmakes 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,StateValueResponseand theStateEntryInfothree of them share — are exportedfrom
mcp_agent_api, so a Python client validates against them instead ofre-declaring the shapes.
Three mechanics worth reviewing, all established by testing
Passing the
Eventunion as the responsemodelis what registers the eventschemas into
components. Without it theoneOf's$refs dangle and Swaggerrenders nothing. Inlining the schema instead would have looked correct in the
source and been broken in the browser, because
#/$defs/...resolves against thedocument root rather than the enclosing schema. There is a test asserting every
referenced branch is defined.
A
StreamingResponsesubclass namingmedia_typeis what keeps the defaultapplication/jsonout. Passing the model without it documented both mediatypes. The handler builds its own response, so this only affects the document.
The read models are attached through
responses=, notresponse_model=, sonothing is re-serialised. Two nullable fields need opposite treatment:
state_metadataomitssequntil the write is merged, so a client ordering byit is never sorting nulls, while
kind: nullis meaningful — the value isuntyped — and must stay. A plain
response_modelturns the first into"seq": null;response_model_exclude_nonedrops the second; a@model_serializerfixes the wire and then erases the schema, which is the wholepoint of the change. A test pins both halves after a real turn.
One behaviour change
A posted message without an
idnow 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.mdand onRunRequest, includingthe 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
/healthis dss's own route and gets the same treatment separately, in the PRthat bumps its lock.
🤖 Generated with Claude Code