Skip to content

Derived output schemas and structured content (ADR 0005) - #27

Merged
drshade merged 2 commits into
mainfrom
derived-output-schemas
Aug 1, 2026
Merged

Derived output schemas and structured content (ADR 0005)#27
drshade merged 2 commits into
mainfrom
derived-output-schemas

Conversation

@drshade

@drshade drshade commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Implements ADR 0005: the highest-leverage Batch 1 roadmap item, completing the library's thesis — typed in, typed out.

What it adds

data WeatherReport = WeatherReport
    { temperature :: Int, sky :: Sky, alerts :: [Text], humidity :: Maybe Int }

handleTool :: ClientContext -> MyTool -> IO (ToolOutput WeatherReport)
handleTool _ (GetWeather city) = pure $ ToolOutput (lookupWeather city)

tools = Just $(deriveToolHandlerWithOutput ''MyTool 'handleTool ''WeatherReport)
  • The result record derives the tools' outputSchema via the existing Schema machinery (primitives, Maybe→optional, lists, all-nullary enums, nested records) — shared with input derivation per the ADR, so future schema-keyword work benefits both directions.
  • The serializer is generated by the same TH walk as the schema, so the structured value provably matches what the schema promises (snake_cased enums, Nothing fields omitted). No reliance on user ToJSON instances that could drift.
  • ToolOutput carries the spec-recommended behavior: a plain ToolOutput also returns the serialized JSON as a text content block for pre-structured-output clients; ToolOutputWith overrides the content; ToolOutputError maps to isError; ToolOutputRaw is the escape hatch.
  • Output types must resolve to a record (outputSchema is an object schema per spec) — anything else is rejected at TH time with a clear message.
  • Plain ToToolResult handlers are untouched; deriveToolHandler(WithDescription) now share a generalized implementation with the output-typed variants rather than duplicating the dispatch generation.

Verification

  • 161 test examples (was 151): a DerivedOutput spec covers the schema shape (enum values, nested object, array, optional-Maybe required-list, description wiring), structured/text-block equality, Nothing omission, all four ToolOutput forms.
  • Conformance corpus: the extended reference server gains echo_structured — derived through this machinery so the corpus pins the exact TH wire shape — with tools-call-structured cases in both eras and tools-list-extended showing the serialized outputSchema. Corpus README updated; v0.2.0-anchored fixtures untouched.
  • Built and tested on GHC 9.10.3 and 9.14.1.

Versioning

Purely additive (new exports, no changed signatures) → joins the pending 0.2.1.0 line per the release policy. ADR_0005 marked Landed (0.2.1.0).

Tools can now be typed on the way out: deriveToolHandlerWithOutput
(and ...WithOutputDescription) take a result record type, derive the
tools' outputSchema from it using the same field rules as input
derivation (primitives, Maybe, lists, all-nullary enums, nested
records), and serialize the handler's typed values into
structuredContent. The serializer is generated by the same TH walk as
the schema, so what the schema promises is what the value contains —
snake_cased enums, Maybe fields omitted when Nothing — and the two
cannot drift.

Handlers return the new ToolOutput type:

- ToolOutput o: structured value; per the spec's recommendation the
  serialized JSON is also returned as a text content block for clients
  that predate structured output
- ToolOutputWith [Content] o: structured value with caller-supplied
  content blocks
- ToolOutputError Text: execution failure via isError
- ToolOutputRaw ToolResult: full-control escape hatch

The output type must resolve to a record (outputSchema is an object
schema per spec) — rejected at TH time otherwise. Existing ToToolResult
handlers are untouched; the plain derivations now share a generalized
implementation with the output-typed ones.

The conformance corpus's extended reference server gains an
echo_structured tool derived through this machinery, so the corpus pins
the exact wire shape: legacy/modern tools-call-structured cases and a
legacy tools-list-extended case showing the serialized outputSchema.
The v0.2.0-anchored fixtures are untouched.

161 test examples (was 151); verified on GHC 9.10.3 and 9.14.1.
ADR_0005 marked Landed (0.2.1.0); joins the pending 0.2.1.0 line
(additive, PVP minor).

@drshade drshade left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review verdict: one fix requested (canonical text-block encoding), one nit. The design is exactly ADR 0005 — and generating the serializer from the same TH walk as the schema is the right way to make the schema/value agreement structural rather than hoped-for. The ToolOutput four-form API is clean (plain/with-content/error/raw covers the space without a typeclass), the spec-recommended text block appears exactly when the handler didn't supply content, TH-time rejection of non-record output types matches the ADR, and unifying deriveToolHandler* through one generic implementation pays down duplication rather than adding to it. Test coverage hits every form plus the schema shape details, and the corpus deliberately pins the TH wire shape with a derived tool. 161/161 locally; CI pending as I write this — merge will wait for green as usual.

Comment thread src/MCP/Server/Derive/Internal.hs Outdated
ToolOutputError msg -> toolError msg
ToolOutputRaw result -> result
where
jsonText = TE.decodeUtf8 . BSL.toStrict . encode

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The requested fix: make the text block's JSON canonical (sorted keys). encode's object key order depends on the aeson/hashable pair in the build plan — which is exactly why GoldenWire compares parsed Values. But this string is JSON inside a string: the corpus fixture tools-call-structured.response.json embeds "{\"echoedLength\":6,\"echoedText\":\"golden\"}" literally, and parsed-Value comparison can't see through it. If a future build plan resolves a different hashable, the text block's key order changes and the fixture breaks — worse, for external corpus consumers the expected bytes would be aeson-implementation-specific, undermining the corpus's neutrality.

Fix is small: encode the text block from a canonically-ordered structure, e.g. a recursive Value -> Value that rebuilds every object via sorted KeyMap.toList before encode (aeson encodes KeyMap.fromList of sorted pairs in that order... safer still, convert objects to Data.Map Text Value recursively and encode that — Map's ToJSON is ordered by key). Deterministic output has a side benefit the spec explicitly cares about: stable tool results improve client prompt-cache hit rates.

Apply it only to the text block (structuredContent itself is compared structurally everywhere and can stay as-is), regenerate the two tools-call-structured fixtures, and ideally note in the corpus README that embedded-JSON strings are canonical (sorted keys).

Comment thread src/MCP/Server/Derive.hs Outdated
body <- [| object (concat $(return $ ListE fieldExps)) |]
return $ LamE [VarP rVar] body
where
fieldPairs rVar (fieldName, _, fieldType) = do

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, fix or defer: the record serializer applies field accessors ($(varE fieldName) $(varE rVar)), which breaks for users with DuplicateRecordFields enabled (bare accessor application is ambiguous there, and duplicate field names across records are increasingly common in the codebases that would want typed outputs). The rest of the TH machinery never relies on accessors — input decoding constructs values, and your own enum/wrapper cases here pattern-match. Building this the same way — one conP binding every field to fresh names — makes the serializer immune:

-- \(Con f0 f1 ...) -> object (concat [...pairs from f0, f1...])

Same generated semantics, no accessor lookup.

…ializer

- The structured-output text block is now encoded canonically (compact,
  object keys sorted, via an encoding-only wrapper that emits objects
  through a sorted Map): encode's key order depends on the
  aeson/hashable pair in the build plan, and this JSON lives inside a
  string, where the corpus's parsed-Value comparison cannot see through
  — so the bytes must be deterministic for the fixtures and for
  external corpus consumers. Deterministic results also keep repeated
  tool outputs stable for client prompt caching. The two structured
  corpus fixtures are regenerated and the corpus README documents the
  embedded-JSON canonical form. A unit test pins the exact bytes.
- The generated record serializer binds fields by pattern-matching the
  constructor instead of applying accessors, so it works for users with
  DuplicateRecordFields enabled — consistent with the rest of the TH
  machinery, which never relies on accessors.

162 test examples.
@drshade

drshade commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Both addressed in 304f069 (162 examples):

  • Requested fix: the text block is now canonical — compact JSON with sorted object keys, produced by an encoding-only newtype whose toEncoding emits objects through a sorted Map (so aeson's escaping is reused and no KeyMap is rebuilt). Applied only to the embedded-JSON text block as suggested; structuredContent stays as-is since it's compared structurally everywhere. The two tools-call-structured fixtures are regenerated, the corpus README documents that embedded-JSON strings are canonical, and a new unit test pins the exact text-block bytes.
  • Nit: the record serializer now binds fields via a single conP pattern with fresh names — no accessor application, so DuplicateRecordFields users are safe, consistent with how the rest of the TH machinery avoids accessors.

@drshade drshade left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. Both points verified on 304f069: the Canonical encoding-only newtype makes the embedded text-block JSON byte-deterministic across build plans (sorted keys via Data.Map's ordered toEncoding, aeson escaping reused), with the guarantee documented in the corpus README and pinned by an exact-bytes unit test; the record serializer now pattern-binds fields so DuplicateRecordFields users are safe. 162/162 tests locally, CI green. The typed-in/typed-out story is complete — merging. ADR_0005 status flip to Landed confirmed in the diff.

@drshade
drshade merged commit 72fee99 into main Aug 1, 2026
5 checks passed
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