Skip to content

feat: return structuredContent and advertise outputSchema for typed tools - #1282

Draft
sd2k wants to merge 5 commits into
mainfrom
feat/structured-tool-output
Draft

sd2k wants to merge 5 commits into
mainfrom
feat/structured-tool-output

Conversation

@sd2k

@sd2k sd2k commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Every tool whose handler returns a concrete Go type now advertises an outputSchema reflected from that type and returns its result as structuredContent, alongside the existing text content (same JSON). Code-mode and other programmatic clients can then write code against a result's shape without calling the tool first.

Closes #1278. Part of #1281.

Changes

  • tools_output.go: builds an output schema from the handler's return type in ConvertTool.
    • Results that aren't JSON objects (slices, maps) are wrapped as {"result": ...}, because MCP requires structuredContent to be an object.
    • string, any and *mcp.CallToolResult results stay text-only.
  • Schemas describe the marshalled JSON, not just the Go struct, because strict clients (TS/Python SDKs) reject non-matching results:
    • nilable fields without omitempty accept null;
    • fields promoted through nil embedded pointers aren't required;
    • TextMarshaler types are strings; other json.Marshaler types are unconstrained unless they implement JSONSchema().
  • Checking:
    • At construction, each schema is checked against values of its type populated to every depth. Tools are package-level MustTool vars, so a mismatch fails at startup and in every test run.
    • At call time, results are validated too, but a mismatch is logged rather than failing the call.
  • Typed results for tools that returned any or model.Value, so they get schemas too:
    • query_prometheus and query_prometheus_histogram
    • the alerting operation tools
    • create_annotation

Output shape changes (for release notes)

These change what tools return compared with v2.0.0. They ship in a minor release, not as breaking changes, because tool output is not a stable API:

  • query_prometheus and query_prometheus_histogram return the Prometheus API data object: {"resultType": ..., "result": ...}. The histogram tool's result field is renamed to data.
  • alerting_rules_*, alerting_silences_* and alerting_manage_routing return a result struct with one field per operation. List operations report [] instead of omitting the field. Silence deletion reports silenceID instead of silence_id.
  • create_annotation returns {id, message} instead of {"Payload": {...}}.

Testing

  • go test ./ ./tools/... passes (unit tests).
  • New tests in tools_output_test.go cover:
    • inlining, and text output matching structured output
    • nullability
    • embedded pointers
    • recursive types
    • wrapping non-object results
    • text-only result types
    • a mismatch failing at construction
  • golangci-lint run: 0 issues.
  • Integration tests: left to CI.

Note: this branch was originally built on v2; it's now rebased onto main after the v2.0.0 squash-merge, and still checks all 127 tools on main at construction.

sd2k and others added 4 commits October 2, 2026 13:25
…ools

Every tool whose handler returns a concrete Go type now advertises an
outputSchema reflected from that type and returns the result as
structuredContent, with the same JSON as its text content. Harnesses that
compose tool calls in code (pi's Codemode, tool-search clients) can then
program against a result's shape without calling the tool first.

Results that are not JSON objects (slices, maps) are wrapped as
{"result": ...}, as MCP requires structuredContent to be an object; nil
slices and maps there become [] and {}. A nil struct pointer is returned as
the zero value, as go-sdk's typed handlers do. string, any and
*mcp.CallToolResult results stay text-only.

Strict clients (the TypeScript and Python SDKs) reject a result that does
not match its schema, so the schema has to describe the marshalled JSON,
not just the struct:

- fields encoding/json can emit as null (nil pointers, slices, maps without
  omitempty, and nilable slice/map elements) accept null
- fields promoted through a nil embedded pointer are not required
- TextMarshaler types are strings; other json.Marshaler types are
  unconstrained unless they describe themselves with JSONSchema()

Each schema is checked at construction against values of its type populated
to every depth, so a mismatch fails at startup and in every test run.
Results are also validated at call time; a mismatch is logged rather than
failing the call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
query_prometheus and query_prometheus_histogram returned a bare
model.Value, so a caller could not tell an instant query's vector, scalar
or string from a range query's matrix without inspecting the shape, and the
interface had no output schema. Both now return the Prometheus HTTP API's
data object, {"resultType": ..., "result": ...}, described by a
hand-written schema matching how prometheus/common marshals each value
type. The histogram tool's result field is renamed to data to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alerting_rules_read/write, alerting_silences_read/write and
alerting_manage_routing returned any, since each operation returns a
different type, so they could not advertise an output schema. Each now
returns a result struct with one field per operation (rules, rule,
versions, savedRule, message; silences, silence, silenceID, status;
notificationPolicies, contactPoints, contactPointIntegrations,
timeIntervals, timeInterval). List operations report [] rather than
omitting their field when nothing matches.

Silence deletion now reports silenceID rather than silence_id, matching
create and update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
create_annotation returned the go-openapi response wrapper as any, which
marshalled as {"Payload": {...}} and had no output schema. It now returns
the {id, message} body for both standard and Graphite annotations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sd2k

sd2k commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

cursor review

@github-actions github-actions Bot added the cla: yes Contributor License Agreement is signed label Oct 2, 2026

@cursor cursor Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread tools/prometheus.go
Prior to this commit, a typed-nil model.Matrix or model.Vector, which
backends return for queries with no results, marshalled as
{"resultType": ..., "result": null}. The advertised output schema requires
result to be an array, so strict MCP clients rejected empty
query_prometheus and query_prometheus_histogram results.

This commit makes PrometheusValue marshal typed-nil matrices and vectors
as [], matching the Prometheus HTTP API, and adds typed-nil cases to the
schema conformance test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sd2k

sd2k commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit f9dfb29. Configure here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: yes Contributor License Agreement is signed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Structured tool output: advertise outputSchema and populate structuredContent

1 participant