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

View file

@ -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)
}