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:
parent
8e373f0f2e
commit
84ffd2d7d2
1 changed files with 222 additions and 114 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue