quickstart-resources/weather-server-go/main.go
olaservo a0ce1f89be Say "structured content", not "structured output"
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>
2026-08-09 15:36:33 -07:00

293 lines
8.6 KiB
Go

package main
import (
"cmp"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
NWSAPIBase = "https://api.weather.gov"
UserAgent = "weather-app/1.0"
)
type ForecastInput struct {
Latitude float64 `json:"latitude" jsonschema:"Latitude of the location"`
Longitude float64 `json:"longitude" jsonschema:"Longitude of the location"`
}
type AlertsInput struct {
State string `json:"state" jsonschema:"Two-letter US state code (e.g. CA, NY)"`
}
type PointsResponse struct {
Properties struct {
Forecast string `json:"forecast"`
} `json:"properties"`
}
type ForecastResponse struct {
Properties struct {
Periods []ForecastPeriod `json:"periods"`
} `json:"properties"`
}
type ForecastPeriod struct {
Name string `json:"name"`
Temperature int `json:"temperature"`
TemperatureUnit string `json:"temperatureUnit"`
WindSpeed string `json:"windSpeed"`
WindDirection string `json:"windDirection"`
DetailedForecast string `json:"detailedForecast"`
}
type AlertsResponse struct {
Features []AlertFeature `json:"features"`
}
type AlertFeature struct {
Properties AlertProperties `json:"properties"`
}
type AlertProperties struct {
Event string `json:"event"`
AreaDesc string `json:"areaDesc"`
Severity string `json:"severity"`
Description string `json:"description"`
Instruction string `json:"instruction"`
}
// Alert is one element of get_alerts' structured content. 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"`
}
// Period is one element of Forecast's periods. ForecastPeriod above is the
// shape NWS sends; this is the shape the tool publishes, kept separate for the
// same reason Alert is separate from AlertProperties.
type Period struct {
Name string `json:"name"`
Temperature int `json:"temperature"`
TemperatureUnit string `json:"temperature_unit"`
WindSpeed string `json:"wind_speed"`
WindDirection string `json:"wind_direction"`
DetailedForecast string `json:"detailed_forecast"`
}
// Forecast is get_forecast's structured content: 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 []Period `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 {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Accept", "application/geo+json")
client := http.DefaultClient
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request to %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("HTTP error %d: %s", resp.StatusCode, string(body))
}
var result T
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &result, nil
}
func formatAlert(alert Alert) string {
return fmt.Sprintf(`
Event: %s
Area: %s
Severity: %s
Description: %s
Instructions: %s
`, alert.Event, alert.Area, alert.Severity, alert.Description, alert.Instructions)
}
func formatPeriod(period ForecastPeriod) string {
return fmt.Sprintf(`
%s:
Temperature: %d°%s
Wind: %s %s
Forecast: %s
`, period.Name, period.Temperature, period.TemperatureUnit,
period.WindSpeed, period.WindDirection, period.DetailedForecast)
}
func getForecast(ctx context.Context, req *mcp.CallToolRequest, input ForecastInput) (
*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 nil, Forecast{}, fmt.Errorf("unable to fetch forecast data for this location: %w", err)
}
// Get forecast data
forecastURL := pointsData.Properties.Forecast
if forecastURL == "" {
return nil, Forecast{}, fmt.Errorf("unable to fetch forecast URL")
}
forecastData, err := makeNWSRequest[ForecastResponse](ctx, forecastURL)
if err != nil {
return nil, Forecast{}, fmt.Errorf("unable to fetch detailed forecast: %w", err)
}
periods := forecastData.Properties.Periods
if len(periods) == 0 {
return nil, Forecast{}, fmt.Errorf("no forecast periods available")
}
// Show next 5 periods
periods = periods[:min(5, len(periods))]
published := make([]Period, 0, len(periods))
for _, period := range periods {
published = append(published, Period{
Name: period.Name,
Temperature: period.Temperature,
TemperatureUnit: period.TemperatureUnit,
WindSpeed: period.WindSpeed,
WindDirection: period.WindDirection,
DetailedForecast: period.DetailedForecast,
})
}
forecast := Forecast{
Latitude: input.Latitude,
Longitude: input.Longitude,
Periods: published,
}
var formatted []string
for _, period := range periods {
formatted = append(formatted, formatPeriod(period))
}
result := &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{Text: strings.Join(formatted, "\n---\n")},
},
}
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, []Alert, error,
) {
// Build alerts URL
stateCode := strings.ToUpper(input.State)
alertsURL := fmt.Sprintf("%s/alerts/active/area/%s", NWSAPIBase, stateCode)
alertsData, err := makeNWSRequest[AlertsResponse](ctx, alertsURL)
if err != nil {
return nil, nil, fmt.Errorf("unable to fetch alerts for state %s: %w", stateCode, err)
}
// An empty result is an empty array, not an error.
alerts := make([]Alert, 0, len(alertsData.Features))
for _, feature := range alertsData.Features {
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"),
})
}
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")
}
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() {
// Create MCP server.
//
// Passing an empty Capabilities suppresses the SDK's historical default of
// advertising {"logging":{}} — this server does no logging, and the logging
// feature is deprecated as of 2026-07-28 (SEP-2577). The tools capability is
// still inferred from the tools registered below.
server := mcp.NewServer(&mcp.Implementation{
Name: "weather",
Version: "1.0.0",
}, &mcp.ServerOptions{
Capabilities: &mcp.ServerCapabilities{},
})
// Add get_forecast tool
mcp.AddTool(server, &mcp.Tool{
Name: "get_forecast",
Description: "Get weather forecast for a location",
}, getForecast)
// Add get_alerts tool
mcp.AddTool(server, &mcp.Tool{
Name: "get_alerts",
Description: "Get weather alerts for a US state",
OutputSchema: alertsOutputSchema(),
}, getAlerts)
// Run server on stdio transport
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
log.Fatal(err)
}
}