diff --git a/mcp-client-go/README.md b/mcp-client-go/README.md index 19bc40dc..a3128329 100644 --- a/mcp-client-go/README.md +++ b/mcp-client-go/README.md @@ -1,3 +1,9 @@ # An LLM-Powered Chatbot MCP Client written in Go See the [Build an MCP client](https://modelcontextprotocol.io/docs/develop/build-client) tutorial for more information. + +## Structured output + +The SDK does not validate tool output, so this client compiles each declared `outputSchema` at connect time and checks results against it — the spec's client-side SHOULD. It uses [`jsonschema-go`](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema), already an SDK dependency. + +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). diff --git a/mcp-client-go/go.mod b/mcp-client-go/go.mod index 464047a8..5389f245 100644 --- a/mcp-client-go/go.mod +++ b/mcp-client-go/go.mod @@ -4,12 +4,12 @@ go 1.25.1 require ( github.com/anthropics/anthropic-sdk-go v1.14.0 + github.com/google/jsonschema-go v0.4.3 github.com/joho/godotenv v1.5.1 - github.com/modelcontextprotocol/go-sdk v1.4.1 + 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/tidwall/gjson v1.18.0 // indirect @@ -17,6 +17,8 @@ require ( github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // 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 ) diff --git a/mcp-client-go/go.sum b/mcp-client-go/go.sum index 90794f39..4309c98a 100644 --- a/mcp-client-go/go.sum +++ b/mcp-client-go/go.sum @@ -2,16 +2,16 @@ github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbw github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -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/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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= @@ -32,11 +32,15 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp-client-go/main.go b/mcp-client-go/main.go index 62f775fc..88dd5c86 100644 --- a/mcp-client-go/main.go +++ b/mcp-client-go/main.go @@ -12,30 +12,28 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/google/jsonschema-go/jsonschema" "github.com/joho/godotenv" "github.com/modelcontextprotocol/go-sdk/mcp" ) -var model anthropic.Model = anthropic.ModelClaudeSonnet4_5_20250929 +// A string literal rather than an anthropic.Model constant: the pinned SDK +// version predates this model and has no constant for it. +var model anthropic.Model = "claude-sonnet-5" type MCPClient struct { anthropic *anthropic.Client session *mcp.ClientSession tools []anthropic.ToolUnionParam + // Compiled outputSchema per tool name, for validating results. + outputSchemas map[string]*jsonschema.Resolved } func NewMCPClient() (*MCPClient, error) { - // Load .env file - if err := godotenv.Load(); err != nil { - return nil, fmt.Errorf("failed to load .env file: %w", err) - } - - apiKey := os.Getenv("ANTHROPIC_API_KEY") - if apiKey == "" { - return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable not set") - } + // .env is optional; the key may come from the environment instead. + _ = godotenv.Load() - client := anthropic.NewClient(option.WithAPIKey(apiKey)) + client := anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY"))) return &MCPClient{ anthropic: &client, @@ -80,6 +78,7 @@ func (c *MCPClient) ConnectToServer(ctx context.Context, serverArgs []string) er var toolNames []string // Convert MCP tools to Anthropic tool format + c.outputSchemas = make(map[string]*jsonschema.Resolved) for _, tool := range toolsResult.Tools { toolNames = append(toolNames, tool.Name) anthropicTool, err := mcpToolToAnthropicTool(tool) @@ -87,6 +86,14 @@ func (c *MCPClient) ConnectToServer(ctx context.Context, serverArgs []string) er return fmt.Errorf("failed to convert mcp tool to anthropic tool: %w", err) } c.tools = append(c.tools, anthropicTool) + + resolved, err := resolveOutputSchema(tool) + if err != nil { + return fmt.Errorf("failed to compile output schema of tool %s: %w", tool.Name, err) + } + if resolved != nil { + c.outputSchemas[tool.Name] = resolved + } } fmt.Printf("Connected to server with tools: %v\n", toolNames) @@ -116,6 +123,40 @@ func mcpToolToAnthropicTool(tool *mcp.Tool) (anthropic.ToolUnionParam, error) { }, nil } +// resolveOutputSchema compiles a tool's declared outputSchema, or returns +// (nil, nil) when it declares none. OutputSchema is `any` because the root may +// be any JSON Schema, so round-trip through JSON to accept every form. +func resolveOutputSchema(tool *mcp.Tool) (*jsonschema.Resolved, error) { + if tool.OutputSchema == nil { + return nil, nil + } + raw, err := json.Marshal(tool.OutputSchema) + if err != nil { + return nil, fmt.Errorf("failed to marshal output schema: %w", err) + } + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + return nil, fmt.Errorf("failed to unmarshal output schema: %w", err) + } + return schema.Resolve(nil) +} + +// validateToolOutput checks a result against its tool's declared outputSchema. +// Error results are exempt: they carry a message, not data. +func (c *MCPClient) validateToolOutput(name string, result *mcp.CallToolResult) error { + resolved, ok := c.outputSchemas[name] + if !ok || result.IsError { + return nil + } + if result.StructuredContent == nil { + return fmt.Errorf("tool %s declares an output schema but returned no structured content", name) + } + if err := resolved.Validate(result.StructuredContent); err != nil { + return fmt.Errorf("structured content from tool %s does not match its output schema: %w", name, err) + } + return nil +} + func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, error) { if c.session == nil { return "", fmt.Errorf("client is not connected to any server") @@ -169,16 +210,27 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err return "", fmt.Errorf("tool call %s failed: %w", toolUseBlock.Name, err) } - // Serialize tool result - resultJSON, err := json.Marshal(mcpToolResult) - if err != nil { - return "", fmt.Errorf("failed to serialize tool result: %w", err) + if err := c.validateToolOutput(toolUseBlock.Name, mcpToolResult); err != nil { + return "", err + } + + // StructuredContent is data the application can use directly. + if items, ok := mcpToolResult.StructuredContent.([]any); ok { + finalText = append(finalText, fmt.Sprintf("[%s returned %d items]", toolUseBlock.Name, len(items))) + } + + // Content is a list of block types; forward only the text ones. + var texts []string + for _, block := range mcpToolResult.Content { + if text, ok := block.(*mcp.TextContent); ok { + texts = append(texts, text.Text) + } } anthropicToolResults = append(anthropicToolResults, anthropic.NewToolResultBlock( toolUseBlock.ID, - string(resultJSON), - false, + strings.Join(texts, "\n"), + mcpToolResult.IsError, )) } @@ -187,7 +239,7 @@ func (c *MCPClient) ProcessQuery(ctx context.Context, query string) (string, err // Make another API call with tool results response, err = c.anthropic.Messages.New(ctx, anthropic.MessageNewParams{ - Model: anthropic.ModelClaude3_7SonnetLatest, + Model: model, MaxTokens: 1024, Messages: messages, }) @@ -267,6 +319,18 @@ func main() { log.Fatalf("Failed to connect to MCP server: %v", err) } + // Connecting and listing tools needs no credentials; querying them does. + // Matching the Python and TypeScript clients, report and exit rather than + // failing, so the connection itself can be exercised without a key. + if os.Getenv("ANTHROPIC_API_KEY") == "" { + fmt.Println("\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:") + fmt.Println(" export ANTHROPIC_API_KEY=your-api-key-here") + if err := client.Cleanup(); err != nil { + log.Printf("Cleanup error: %v", err) + } + return + } + if err := client.ChatLoop(ctx); err != nil { log.Printf("ChatLoop error: %v", err) } diff --git a/mcp-client-go/mcp-client-go.exe b/mcp-client-go/mcp-client-go.exe new file mode 100644 index 00000000..b063e0c3 Binary files /dev/null and b/mcp-client-go/mcp-client-go.exe differ diff --git a/weather-server-go/README.md b/weather-server-go/README.md index 36f695c9..563e3d83 100644 --- a/weather-server-go/README.md +++ b/weather-server-go/README.md @@ -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. diff --git a/weather-server-go/go.mod b/weather-server-go/go.mod index f1890034..4daccb5c 100644 --- a/weather-server-go/go.mod +++ b/weather-server-go/go.mod @@ -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 ) diff --git a/weather-server-go/go.sum b/weather-server-go/go.sum index ef8dd393..2df2cbe5 100644 --- a/weather-server-go/go.sum +++ b/weather-server-go/go.sum @@ -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= diff --git a/weather-server-go/main.go b/weather-server-go/main.go index 4ec28f31..f18c2b5e 100644 --- a/weather-server-go/main.go +++ b/weather-server-go/main.go @@ -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))] - result := strings.Join(forecasts, "\n---\n") + forecast := Forecast{ + Latitude: input.Latitude, + Longitude: input.Longitude, + Periods: periods, + } - 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 + // 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"), + }) } - // Format alerts - var alerts []string - for _, feature := range alertsData.Features { - alerts = append(alerts, formatAlert(feature)) + 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 := strings.Join(alerts, "\n---\n") + result := &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: result}, - }, - }, nil, nil + 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 diff --git a/weather-server-go/weather.exe b/weather-server-go/weather.exe new file mode 100644 index 00000000..24841621 Binary files /dev/null and b/weather-server-go/weather.exe differ