Add structured output to the Go weather server and client

Both tools declare an OutputSchema and return StructuredContent alongside the
text.

get_alerts returns []Alert, 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. The declared schema narrows the inferred ["null","array"] root to
"array": jsonschema-go infers the nullable form because a nil slice is valid
Go, but the handler always builds a slice.

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

The client compiles every declared OutputSchema at connect time and validates
results against it, using jsonschema-go - already an SDK dependency, so no new
one. Each channel goes to its stated reader: Content is forwarded to the
model, StructuredContent is used as data, reporting how many items came back.

The client no longer treats a missing .env as fatal and checks for
ANTHROPIC_API_KEY after connecting rather than before, matching the Python and
TypeScript clients, so the connection can be exercised without credentials.

Requires go-sdk v1.7.0-pre.3: non-object output schema support (SEP-2106)
landed after v1.6.1 and is in no stable release. Model identifier moves to
claude-sonnet-5; the second call in the tool loop used a hardcoded
ModelClaude3_7SonnetLatest while the first used the package variable, so both
now use the same one.

Note that the SDK sends an array-rooted schema as written on every connection
rather than projecting it down, so an older client rejects the tool list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
olaservo 2026-07-25 17:55:08 -07:00
commit 84ffd2d7d2
5 changed files with 222 additions and 114 deletions

View file

@ -17,3 +17,9 @@ go build -o weather
The server will communicate via stdio and expose two MCP tools:
- `get_forecast` - Get weather forecast for a location (requires latitude and longitude)
- `get_alerts` - Get weather alerts for a US state (requires two-letter state code)
## Structured output
Both tools declare an `OutputSchema` and return `StructuredContent`. `get_forecast` returns an object; `get_alerts` returns a top-level JSON array, which protocol revision `2026-07-28` is the first to allow — see [Structured Content](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content) in the spec.
Note that an array-rooted schema requires a `2026-07-28` client: the SDK sends it as written on every connection rather than projecting it down, so an older client rejects the tool list.

View file

@ -2,13 +2,17 @@ module github.com/modelcontextprotocol/quickstart-resources/weather-server-go
go 1.25.1
require github.com/modelcontextprotocol/go-sdk v1.4.1
require (
github.com/google/jsonschema-go v0.4.3
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3
)
require (
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
)

View file

@ -1,20 +1,24 @@
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3 h1:SEAY9IduDif4iApnZgpFkjFIdo3askSGZVbZIYyTy6I=
github.com/modelcontextprotocol/go-sdk v1.7.0-pre.3/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=

View file

@ -10,6 +10,7 @@ import (
"net/http"
"strings"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -64,6 +65,24 @@ type AlertProperties struct {
Instruction string `json:"instruction"`
}
// Alert is one element of get_alerts' structured output. Returning []Alert
// makes structuredContent a top-level JSON array rather than an object
// wrapping one, which protocol revision 2026-07-28 is the first to allow.
type Alert struct {
Event string `json:"event" jsonschema:"The kind of weather event"`
Area string `json:"area" jsonschema:"The area the alert covers"`
Severity string `json:"severity" jsonschema:"How severe the event is"`
Description string `json:"description" jsonschema:"What is happening"`
Instructions string `json:"instructions" jsonschema:"What people in the area should do"`
}
// Forecast is get_forecast's structured output: the object case.
type Forecast struct {
Latitude float64 `json:"latitude" jsonschema:"Latitude the forecast is for"`
Longitude float64 `json:"longitude" jsonschema:"Longitude the forecast is for"`
Periods []ForecastPeriod `json:"periods" jsonschema:"The forecast periods, soonest first"`
}
func makeNWSRequest[T any](ctx context.Context, url string) (*T, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
@ -93,21 +112,14 @@ func makeNWSRequest[T any](ctx context.Context, url string) (*T, error) {
return &result, nil
}
func formatAlert(alert AlertFeature) string {
props := alert.Properties
event := cmp.Or(props.Event, "Unknown")
areaDesc := cmp.Or(props.AreaDesc, "Unknown")
severity := cmp.Or(props.Severity, "Unknown")
description := cmp.Or(props.Description, "No description available")
instruction := cmp.Or(props.Instruction, "No specific instructions provided")
func formatAlert(alert Alert) string {
return fmt.Sprintf(`
Event: %s
Area: %s
Severity: %s
Description: %s
Instructions: %s
`, event, areaDesc, severity, description, instruction)
`, alert.Event, alert.Area, alert.Severity, alert.Description, alert.Instructions)
}
func formatPeriod(period ForecastPeriod) string {
@ -121,65 +133,57 @@ Forecast: %s
}
func getForecast(ctx context.Context, req *mcp.CallToolRequest, input ForecastInput) (
*mcp.CallToolResult, any, error,
*mcp.CallToolResult, Forecast, error,
) {
// Get points data
pointsURL := fmt.Sprintf("%s/points/%f,%f", NWSAPIBase, input.Latitude, input.Longitude)
pointsData, err := makeNWSRequest[PointsResponse](ctx, pointsURL)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Unable to fetch forecast data for this location."},
},
}, nil, nil
return nil, Forecast{}, fmt.Errorf("unable to fetch forecast data for this location: %w", err)
}
// Get forecast data
forecastURL := pointsData.Properties.Forecast
if forecastURL == "" {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Unable to fetch forecast URL."},
},
}, nil, nil
return nil, Forecast{}, fmt.Errorf("unable to fetch forecast URL")
}
forecastData, err := makeNWSRequest[ForecastResponse](ctx, forecastURL)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Unable to fetch detailed forecast."},
},
}, nil, nil
return nil, Forecast{}, fmt.Errorf("unable to fetch detailed forecast: %w", err)
}
// Format the periods
periods := forecastData.Properties.Periods
if len(periods) == 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "No forecast periods available."},
},
}, nil, nil
return nil, Forecast{}, fmt.Errorf("no forecast periods available")
}
// Show next 5 periods
var forecasts []string
for i := range min(5, len(periods)) {
forecasts = append(forecasts, formatPeriod(periods[i]))
periods = periods[:min(5, len(periods))]
forecast := Forecast{
Latitude: input.Latitude,
Longitude: input.Longitude,
Periods: periods,
}
result := strings.Join(forecasts, "\n---\n")
return &mcp.CallToolResult{
var formatted []string
for _, period := range periods {
formatted = append(formatted, formatPeriod(period))
}
result := &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: result},
&mcp.TextContent{Text: strings.Join(formatted, "\n---\n")},
},
}, nil, nil
}
return result, forecast, nil
}
// getAlerts returns []Alert, so the wire carries [{...}, {...}] and "no alerts"
// is simply [].
func getAlerts(ctx context.Context, req *mcp.CallToolRequest, input AlertsInput) (
*mcp.CallToolResult, any, error,
*mcp.CallToolResult, []Alert, error,
) {
// Build alerts URL
stateCode := strings.ToUpper(input.State)
@ -187,35 +191,48 @@ func getAlerts(ctx context.Context, req *mcp.CallToolRequest, input AlertsInput)
alertsData, err := makeNWSRequest[AlertsResponse](ctx, alertsURL)
if err != nil {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "Unable to fetch alerts or no alerts found."},
},
}, nil, nil
return nil, nil, fmt.Errorf("unable to fetch alerts for state %s: %w", stateCode, err)
}
// Check if there are any alerts
if len(alertsData.Features) == 0 {
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: "No active alerts for this state."},
},
}, nil, nil
}
// Format alerts
var alerts []string
// An empty result is an empty array, not an error.
alerts := make([]Alert, 0, len(alertsData.Features))
for _, feature := range alertsData.Features {
alerts = append(alerts, formatAlert(feature))
props := feature.Properties
alerts = append(alerts, Alert{
Event: cmp.Or(props.Event, "Unknown"),
Area: cmp.Or(props.AreaDesc, "Unknown"),
Severity: cmp.Or(props.Severity, "Unknown"),
Description: cmp.Or(props.Description, "No description available"),
Instructions: cmp.Or(props.Instruction, "No specific instructions provided"),
})
}
result := strings.Join(alerts, "\n---\n")
text := "No active alerts for this state."
if len(alerts) > 0 {
var formatted []string
for _, alert := range alerts {
formatted = append(formatted, formatAlert(alert))
}
text = strings.Join(formatted, "\n---\n")
}
return &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: result},
},
}, nil, nil
result := &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: text}},
}
return result, alerts, nil
}
// alertsOutputSchema narrows the inferred root from ["null", "array"] to
// "array": a nil slice would marshal to null, but getAlerts always builds one.
func alertsOutputSchema() *jsonschema.Schema {
schema, err := jsonschema.For[[]Alert](nil)
if err != nil {
log.Fatalf("inferring get_alerts output schema: %v", err)
}
schema.Types = nil
schema.Type = "array"
return schema
}
func main() {
@ -233,8 +250,9 @@ func main() {
// Add get_alerts tool
mcp.AddTool(server, &mcp.Tool{
Name: "get_alerts",
Description: "Get weather alerts for a US state",
Name: "get_alerts",
Description: "Get weather alerts for a US state",
OutputSchema: alertsOutputSchema(),
}, getAlerts)
// Run server on stdio transport

Binary file not shown.