Merge pull request #166 from olaservo/structured-output-2026-07-28/go
Add structured content to the Go weather server and client and update to latest SDK and protocol version
This commit is contained in:
commit
6935039258
8 changed files with 256 additions and 115 deletions
|
|
@ -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 content
|
||||
|
||||
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/2026-07-28/server/tools#structured-content).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0/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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
// .env is optional; the key may come from the environment instead.
|
||||
_ = godotenv.Load()
|
||||
|
||||
apiKey := os.Getenv("ANTHROPIC_API_KEY")
|
||||
if apiKey == "" {
|
||||
return nil, fmt.Errorf("ANTHROPIC_API_KEY environment variable not set")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,3 +17,11 @@ 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 content
|
||||
|
||||
Both tools advertise 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/2026-07-28/server/tools#structured-content) in the spec.
|
||||
|
||||
`get_forecast` sets no `OutputSchema` on the `Tool`: the SDK infers one from the handler's return type, which is the idiomatic Go spelling. `get_alerts` overrides it, because inference widens a slice to `["null", "array"]` — a nil slice marshals to `null` — and the tool never returns nil.
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
|
||||
github.com/modelcontextprotocol/go-sdk v1.7.0/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=
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/jsonschema-go/jsonschema"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
|
|
@ -64,6 +65,36 @@ type AlertProperties struct {
|
|||
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 {
|
||||
|
|
@ -93,21 +124,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 +145,69 @@ 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))]
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
result := strings.Join(forecasts, "\n---\n")
|
||||
forecast := Forecast{
|
||||
Latitude: input.Latitude,
|
||||
Longitude: input.Longitude,
|
||||
Periods: published,
|
||||
}
|
||||
|
||||
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,43 +215,63 @@ 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() {
|
||||
// Create MCP server
|
||||
// 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",
|
||||
}, nil)
|
||||
}, &mcp.ServerOptions{
|
||||
Capabilities: &mcp.ServerCapabilities{},
|
||||
})
|
||||
|
||||
// Add get_forecast tool
|
||||
mcp.AddTool(server, &mcp.Tool{
|
||||
|
|
@ -233,8 +281,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