Skip to content

Add structured content to the TypeScript weather server and client and update to v2 - #165

Draft
olaservo wants to merge 5 commits into
modelcontextprotocol:mainfrom
olaservo:structured-output-2026-07-28/typescript
Draft

Add structured content to the TypeScript weather server and client and update to v2#165
olaservo wants to merge 5 commits into
modelcontextprotocol:mainfrom
olaservo:structured-output-2026-07-28/typescript

Conversation

@olaservo

@olaservo olaservo commented Jul 26, 2026

Copy link
Copy Markdown
Member

Brings the TypeScript examples onto MCP SDK 2.0.0 and protocol revision 2026-07-28, and gives both tools a declared outputSchema with matching structuredContent.

get-alerts answers with a top-level JSON array:

[
  { "event": "Flood Warning", "area": "La Salle County", ... },
  { "event": "Heat Advisory",  "area": "Wheeler County",  ... }
]

Through 2025-11-25 an outputSchema had to be object-rooted, so a tool returning a list had to invent a key to hang it off. get-forecast returns an object, for contrast.

This costs older clients nothing

The server declares the array schema once and never branches on protocol version. serveStdio serves both eras from one factory, and the SDK projects the schema for whichever era connects.

A 2026-07-28 client sees the schema as written. A 2025-11-25 client sees {"type":"object","properties":{"result":{...}}} and gets structuredContent as {"result":[...]}. Verified against both.

This projection is TypeScript-only. Go, Python and Rust all send the array root as written, so adopting one there is a breaking change for older clients — see the caveats on #164, #166 and #167. It is the strongest argument for the array root that this set makes.

Two fixes that predate this work

Both were pre-existing on main and both are what stopped the five examples being comparable. Declaring an outputSchema promotes them from differences in prose formatting to differences in the published contract, which is why they are fixed here rather than left.

Wrong endpoint. get-alerts queried /alerts?area=XX, which returns every alert NWS holds for the state including expired ones, where the other four query /alerts/active/area/XX. Measured against live data for TX: 372 alerts before, 8 after, and the tools/call response drops from roughly 270KB to 5KB.

Different published fields. get-alerts published {event, area, severity, status, headline} where every other language publishes {event, area, severity, description, instructions}, and get-forecast periods used the NWS camelCase spelling with shortForecast rather than detailed_forecast. It also returned every forecast period where the other four cap at 5. All five now advertise the same shapes:

get_alerts   -> [{event, area, severity, description, instructions}]
get_forecast -> {latitude, longitude,
                 periods[{name, temperature, temperature_unit,
                          wind_speed, wind_direction, detailed_forecast}]}

Other changes

Error paths throw. A tool declaring an outputSchema MUST return conforming structured content, so a path with no data has to fail rather than answer with a bare text result.

The client negotiates. versionNegotiation: { mode: 'auto' } probes server/discover and falls back to the 2025-11-25 handshake; the SDK's default is 'legacy'.

Each channel goes to its stated reader. content is forwarded to the model; structuredContent is used as data, reporting how many items came back when a tool returns an array. The SDK validates it against the declared schema, so the client-side SHOULD needs no code.

Package split. @modelcontextprotocol/sdk becomes @modelcontextprotocol/server and @modelcontextprotocol/client, which is where the 2026-07-28 support lives. Those require Node 20 or newer, so the client's engines.node moves to match. Model identifier moves to claude-sonnet-5.

Null handling. NWS sends the alert fields as explicit nulls rather than omitting them, so the mapping uses ?? — a key-missing default would not fire.

Verification

Captured off the raw wire — a real get-alerts("TX") call against live NWS data, no SDK on the client side:

server/discover -> OK, versions=["2026-07-28"]
tools/list      -> get-alerts outputSchema.type = "array"

tools/call:
  resultType: "complete"
  structuredContent: ARRAY (8 items)
  content: 1 block(s), human-readable prose

Called against all five servers in the same minute, every one returned the same alerts and the same forecast periods with identical values.

Related

One of a set bringing the examples onto 2026-07-28, one PR per language so the four can be compared: #164 (Python), #165 (TypeScript), #166 (Go), #167 (Rust). #163 is the shared prerequisite.

CI here is green, which is itself worth noting: main's smoke test uses an MCP SDK v1 helper that negotiates 2025-11-25, and it accepts this server because the SDK projects the array-rooted schema down to the object form that revision expects — the behaviour described above, confirmed by a real v1 client. Extending that test to assert on structured content comes with #163.

The Ruby examples are not in the set. The mcp gem 1.1.0 does now negotiate 2026-07-28, but its server never emits the resultType field that the revision makes mandatory, so a spec-strict client rejects every response including tools/list. That is an upstream fix, not something an example can work around.


🤖 Generated with Claude Code

Both tools declare an outputSchema and return structuredContent alongside the
text.

get-alerts declares z.array(...), so it answers with a top-level JSON array
rather than an array nested in an object, which protocol revision 2026-07-28
is the first to allow. "No alerts" is simply []. get-forecast returns an
object, for contrast.

This costs older clients nothing. The server declares the array schema once
and never branches on protocol version: serveStdio serves both eras from one
factory, and the SDK projects the schema down to
{"type":"object","properties":{"result":...}} for a 2025-11-25 client, wrapping
the structured content to match. Verified against both eras.

Error paths throw: a tool declaring an outputSchema MUST return conforming
structured content, so a path with no data has to fail.

The client passes versionNegotiation {mode:"auto"}; the SDK default is
"legacy". The SDK validates every result against the declared schema, so the
client-side SHOULD needs no code. Each channel goes to its stated reader:
content is forwarded to the model, structuredContent is used as data,
reporting how many items came back.

Moves to the 2.0 beta packages, where the 2026-07-28 support lives:
@modelcontextprotocol/sdk is replaced by @modelcontextprotocol/server and
@modelcontextprotocol/client. Model identifier moves to claude-sonnet-5.

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Updates the TypeScript weather server/client examples to MCP SDK v2 (split client/server packages) and adds declared tool outputSchema with matching structuredContent, including an array-rooted schema for get-alerts to align with protocol revision 2026-07-28.

Changes:

  • Migrate server to @modelcontextprotocol/server and use serveStdio(buildServer) with per-tool outputSchema + structuredContent for get-alerts (array) and get-forecast (object).
  • Migrate client to @modelcontextprotocol/client, enable automatic protocol version negotiation, and surface basic structured-output handling.
  • Update READMEs and dependency manifests/lockfiles for the new package split and Zod v4 usage.

Reviewed changes

Copilot reviewed 6 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
weather-server-typescript/src/index.ts Switches to v2 server APIs, adds output schemas + structured results, and refactors server startup via serveStdio.
weather-server-typescript/README.md Documents structured output behavior and backward-compat projection.
weather-server-typescript/package.json Replaces @modelcontextprotocol/sdk with @modelcontextprotocol/server and adds zod.
weather-server-typescript/package-lock.json Locks updated dependency graph for server v2 + Zod v4.
mcp-client-typescript/index.ts Switches to v2 client APIs, enables versionNegotiation: auto, and distinguishes content vs structuredContent.
mcp-client-typescript/README.md Documents structured output handling and version negotiation.
mcp-client-typescript/package.json Replaces @modelcontextprotocol/sdk with @modelcontextprotocol/client.
mcp-client-typescript/package-lock.json Locks updated dependency graph for client v2.
Files not reviewed (2)
  • mcp-client-typescript/package-lock.json: Generated file
  • weather-server-typescript/package-lock.json: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1 to +3
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Checked this rather than taking it as read, and the import is correct as written — no change made.

Zod v4 exports its schema builders as named exports from the zod/v4 subpath, so import * as z binds a module namespace object on which z.array and z.object resolve normally. tsc type-checks the file clean, and the built server answers a real get-alerts call over stdio with an array-rooted outputSchema and a top-level JSON array, which it could not do if z.array(...) were failing to resolve at runtime.

The pattern would break for a default-only export, which is presumably what the suggestion is pattern-matching on.

Comment thread mcp-client-typescript/README.md Outdated

The SDK validates every result against the tool's declared `outputSchema`, so the spec's client-side SHOULD needs no code here.

The two channels go to different readers: `content` is forwarded to the model, while `structuredContent` is used as data — the client counts the items it returns. See [Structured Content](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content).
olaservo and others added 4 commits August 1, 2026 11:07
The 2.0 packages left beta on 2026-07-27, so the dependency ranges move off
2.0.0-beta.5.

Also from review: engines.node goes to 20, which is what the 2.0 packages
require, and the client README no longer implies every structured result is
counted — only array-rooted ones are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get-alerts asked for /alerts?area=XX, which returns every alert NWS has on
file for the state including expired ones. The Python, Go and Rust servers
all ask for /alerts/active/area/XX.

Measured against the live API for TX: 372 alerts before, 8 after, and the
tools/call response drops from roughly 270KB to 5KB. The other four servers
return the same 8.

Pre-existing on main rather than introduced by the structured-output work,
but it is the one thing that stopped the examples being comparable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get-alerts published status and headline where Python, Go, Rust and Ruby all
publish description and instructions, and get-forecast periods used the NWS
camelCase spelling with shortForecast instead of detailed_forecast. The
divergence predates this round, but declaring an outputSchema turns it from a
difference in prose formatting into a difference in the published contract,
which defeats the point of a five-language example.

All five now advertise:
  get_alerts   -> [{event, area, severity, description, instructions}]
  get_forecast -> {latitude, longitude, periods[{name, temperature,
                   temperature_unit, wind_speed, wind_direction,
                   detailed_forecast}]}

Also limits the forecast to the next 5 periods, which the other four already
did, and uses `??` on the alert fields because NWS sends explicit nulls
rather than omitting them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec never uses the phrase "structured output". It defines two separate
things under Tool Result: "Structured Content" (the `structuredContent` field)
and "Output Schema" (the `outputSchema` field). Collapsing them into
"structured output" conflates the two.

It is also actively confusing here. In LLM tooling "structured output" means
constrained decoding — making the *model* emit conforming JSON. These clients
call a model API, so a reader could reasonably take the phrase to mean the
tool constrains the model's response, which is the opposite of what is going
on: the tool describes the shape of its own result.

Headings and prose now say "structured content". References to real
identifiers are left alone: the Python SDK's own docs page is called
Structured Output and lives at docs/servers/structured-output.md, and its
decorator parameter is `structured_output`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@olaservo olaservo changed the title Add structured output to the TypeScript weather server and client and update to v2 Add structured content to the TypeScript weather server and client and update to v2 Aug 2, 2026
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.

2 participants