Skip to content

feat(tools): Type handler results from outputSchema - #2035

Merged
dcramer merged 6 commits into
mainfrom
feat/typed-output-schema
Nov 5, 2026
Merged

dcramer merged 6 commits into
mainfrom
feat/typed-output-schema

Conversation

@sentry-junior

@sentry-junior sentry-junior Bot commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Make outputSchema a compile-time contract instead of a runtime-only check. Before this, ToolConfig.outputSchema was a plain z.ZodType, structuredResult<T> inferred T from whatever payload it got, and nothing tied a handler's return type to its schema. Tools that wanted a check added satisfies z.infer<...> by hand, and some didn't.

What changes

  • ToolConfig<TSchema, TOutputSchema> and defineTool are generic over outputSchema. When a tool sets one, its handler must return StructuredToolOutput<z.output<TOutputSchema>>. Strings, markdown and raw MCP content only type-check on tools without a schema. Tool definition sites need no annotations, because the schema type is inferred from the outputSchema property.
  • StructuredToolOutput has content?: never, so a schema tool can't ship its own content next to the payload. The server passes a CallToolResult through unchanged, so that content would otherwise reach clients as is.
  • Tools without a schema keep their concrete handler return type, and can still pass through another tool's structured result (execute_sentry_tool, get_sentry_resource).
  • ToolOutputFor distributes over a union, so the ToolConfig<any> catalog and registry types compile unchanged.
  • structuredResult(structuredContent: NoInfer<T>) now takes T from the return context, not from the payload. Missing and mistyped fields fail tsc. Extra fields are only caught in fresh object literals; spread or variable payloads (structuredResult({ ...upstream }), structuredResult(payload)) are not checked for them, so map fields explicitly. Shared helpers that build results now declare their return type.

Why z.output and not z.input
In @modelcontextprotocol/server 2.4.0, validateToolOutput validates structuredContent against the schema and then sends the raw payload, not the parse result. The schema is advertised to clients with standardSchemaToJsonSchema(schema, "output"). So the payload has to already be in the schema's output form. z.input would accept payloads that rely on defaults, and clients would receive something that doesn't match the advertised schema. Output schemas must not use transforms, because the SDK can't advertise them as JSON Schema.

Shared helpers typed from the schema

  • search_sentry_tools: createSearchToolsResult now declares StructuredToolOutput<SearchToolsOutput>.
  • Search events (support/search-events/structured.ts): the hand-written SearchEventsStructuredContent interface is replaced by a type derived from the same schema factory that builds each dataset's outputSchema, so the builders can't drift from it.
    • DatasetSearchStructuredContent<TDataset> is the per-dataset payload.
    • The generic structured runSearchEvents<TDataset> overload narrows to it. The overload is documented as an unchecked narrowing: the builders return loose row links, and it holds because structured runs lock the dataset and each row's links come from that dataset's defineDatasetConfig.
    • Builders stay non-generic, so adding datasets to STRUCTURED_DATASETS needs no casts.

Type test
src/internal/tool-helpers/define.test.ts uses expectTypeOf for the happy path and six @ts-expect-error cases:

  • a wrong field type
  • a missing field
  • a payload that relies on a schema default
  • an extra field
  • markdown returned from a schema tool
  • a schema tool that sets its own content

I checked that each one fails tsc for the intended reason when its directive is removed.

Docs
docs/contributing/tool-responses.md describes the guarantee, the fresh-literal caveat, and the rule that shared builders declare their return type. It also says not to add per-tool satisfies checks.

Checks (all with SENTRY_AUTH_TOKEN unset, package-scoped, after merging main 964342c):

  • mocks build
  • generate-definitions (no diff)
  • mcp-core tsc, mcp-cloudflare tsc, cli typecheck
  • full mcp-core vitest: 2133 passed, 6 skipped
  • root lint: 0 warnings, 0 errors

Review
An independent reviewer passed this branch after its one must-fix (the content?: never above) was applied.

Open PR compatibility
I merged an earlier head of this branch locally into each open outputSchema / structured-output PR and ran mcp-core tsc. All of them passed except one conflict:

Follow-ups (not in this PR)

  • Per-tool satisfies checks are now redundant and can be removed later: get-replay-details.ts (satisfies GetReplayDetailsPayload) and update-issue.ts (two satisfies UpdateIssueOutput).
  • Candidates for their own shared-helper PRs: empty string → null mapping (value || null), and nullable user/geo mapping (null when every field is empty). Both repeat across the replay, issue-event and profile tools.

via @dcramer.

--

View Session [Sentry]

sentry-junior Bot and others added 6 commits November 5, 2026 22:15
Make the output schema a compile-time contract. `ToolConfig` and
`defineTool` are now generic over `outputSchema`, and a handler on a tool
that declares one must return `structuredResult(...)` typed as the
schema's `z.output`. Markdown and raw content only type-check on tools
without a schema.

`structuredResult` now takes its type from where the result is returned
instead of inferring it from the payload, so object literals get
excess-property checks against the schema.

The change exposed two drifts on main, both fixed here:
- search_errors: the shared search builders typed row links as a loose
  record, not the dataset's links schema. The builders and
  `runSearchEvents` are now generic over the dataset, and the content type
  is derived from `buildSearchEventsOutputSchema`.
- search_sentry_tools: its result helper had no return type.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
Name the schema-derived per-dataset payload DatasetSearchStructuredContent
and keep SearchEventsStructuredContent as the loose shared shape. Dataset
configs that read rows through it would otherwise make the derived type
circular.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
Build both the shared and the per-dataset search output schemas from one
factory. The builders return the shared payload, which is now
schema-derived, and runSearchEvents narrows it to the locked dataset's
schema. This keeps the builders non-generic, so adding datasets to
STRUCTURED_DATASETS needs no casts.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
A schema tool could return its own `content` next to a valid payload, and
the server passes a CallToolResult through unchanged. Mark `content` as
never on StructuredToolOutput and cover it in the type test. Also state
that excess-property checks only cover fresh literals, that output schemas
must not use transforms, and that the dataset search overload is an
unchecked narrowing.

Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com>
@vercel

vercel Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
sentry-local Ready Ready Preview Nov 5, 2026 11:03pm UTC

Request Review

@github-actions github-actions Bot added the risk: medium PR risk score: medium label Nov 5, 2026
@dcramer
dcramer merged commit d5ce7c2 into main Nov 5, 2026
34 checks passed
@dcramer
dcramer deleted the feat/typed-output-schema branch November 5, 2026 23:20
sentry-junior Bot added a commit that referenced this pull request Nov 5, 2026
Resolve the runSearchEvents overload conflict with #2035: keep the
replays overload and use the generic runSearchEvents<TDataset> one in
place of the PublicEventsDataset overload.
sentry-junior Bot added a commit that referenced this pull request Nov 5, 2026
Resolve the ToolConfig conflict with #2035: keep the _meta field and the
outputSchema-typed handler.

This branch was successfully deployed

1 active and 1 inactive deployments
Preview — 994037e7 Deployed Nov 5, 2026 by vercel[bot]
Actions — 994037e7 Deployed Nov 5, 2026 by sentry-junior[bot] via eval #1671
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk: medium PR risk score: medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant