From 5ce9da89b0d78ad4d8ddba069481733a14317633 Mon Sep 17 00:00:00 2001 From: Navendu Pottekkat Date: Sat, 28 Jun 2025 22:34:52 +0530 Subject: [PATCH 1/3] feat: support structured content and output schema --- examples/structured_output/README.md | 46 +++++++++++ examples/structured_output/main.go | 112 +++++++++++++++++++++++++++ go.mod | 5 ++ go.sum | 11 +++ mcp/tools.go | 50 +++++++++++- mcp/tools_test.go | 51 ++++++++++++ mcp/typed_tools.go | 22 ++++++ mcp/utils.go | 89 +++++++++++++++++++++ 8 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 examples/structured_output/README.md create mode 100644 examples/structured_output/main.go diff --git a/examples/structured_output/README.md b/examples/structured_output/README.md new file mode 100644 index 000000000..e2de01fcf --- /dev/null +++ b/examples/structured_output/README.md @@ -0,0 +1,46 @@ +# Structured Content Example + +This example shows how to return `structuredContent` in tool result with corresponding `OutputSchema`. + +Defined in the MCP spec here: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content + +## Usage + +Define a struct for your output: + +```go +type WeatherResponse struct { + Location string `json:"location" jsonschema_description:"The location"` + Temperature float64 `json:"temperature" jsonschema_description:"Current temperature"` + Conditions string `json:"conditions" jsonschema_description:"Weather conditions"` +} +``` + +Add it to your tool: + +```go +tool := mcp.NewTool("get_weather", + mcp.WithDescription("Get weather information"), + mcp.WithOutputSchema[WeatherResponse](), + mcp.WithString("location", mcp.Required()), +) +``` + +Return structured data in tool result: + +```go +func weatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (*mcp.CallToolResult, error) { + response := WeatherResponse{ + Location: args.Location, + Temperature: 25.0, + Conditions: "Cloudy", + } + + fallbackText := fmt.Sprintf("Weather in %s: %.1f°C, %s", + response.Location, response.Temperature, response.Conditions) + + return mcp.NewToolResultStructured(response, fallbackText), nil +} +``` + +See [main.go](./main.go) for more examples. \ No newline at end of file diff --git a/examples/structured_output/main.go b/examples/structured_output/main.go new file mode 100644 index 000000000..7fcbc1941 --- /dev/null +++ b/examples/structured_output/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" +) + +// Note: The jsonschema_description tag is added to the JSON schema as description +// Ideally use better descriptions, this is just an example +type WeatherRequest struct { + Location string `json:"location" jsonschema_description:"City or location"` + Units string `json:"units,omitempty" jsonschema_description:"celsius or fahrenheit"` +} + +type WeatherResponse struct { + Location string `json:"location" jsonschema_description:"Location"` + Temperature float64 `json:"temperature" jsonschema_description:"Temperature"` + Units string `json:"units" jsonschema_description:"Units"` + Conditions string `json:"conditions" jsonschema_description:"Weather conditions"` + Timestamp time.Time `json:"timestamp" jsonschema_description:"When retrieved"` +} + +type UserProfile struct { + ID string `json:"id" jsonschema_description:"User ID"` + Name string `json:"name" jsonschema_description:"Full name"` + Email string `json:"email" jsonschema_description:"Email"` + Tags []string `json:"tags" jsonschema_description:"User tags"` +} + +type UserRequest struct { + UserID string `json:"userId" jsonschema_description:"User ID"` +} + +func main() { + s := server.NewMCPServer( + "Structured Output Example", + "1.0.0", + server.WithToolCapabilities(false), + ) + + // Example 1: Auto-generated schema from struct + weatherTool := mcp.NewTool("get_weather", + mcp.WithDescription("Get weather with structured output"), + mcp.WithOutputSchema[WeatherResponse](), + mcp.WithString("location", mcp.Required()), + mcp.WithString("units", mcp.Enum("celsius", "fahrenheit"), mcp.DefaultString("celsius")), + ) + s.AddTool(weatherTool, mcp.NewStructuredToolHandler(getWeatherHandler)) + + // Example 2: Nested struct schema + userTool := mcp.NewTool("get_user_profile", + mcp.WithDescription("Get user profile"), + mcp.WithOutputSchema[UserProfile](), + mcp.WithString("userId", mcp.Required()), + ) + s.AddTool(userTool, mcp.NewStructuredToolHandler(getUserProfileHandler)) + + // Example 3: Manual result creation + manualTool := mcp.NewTool("manual_structured", + mcp.WithDescription("Manual structured result"), + mcp.WithOutputSchema[WeatherResponse](), + mcp.WithString("location", mcp.Required()), + ) + s.AddTool(manualTool, mcp.NewTypedToolHandler(manualWeatherHandler)) + + if err := server.ServeStdio(s); err != nil { + fmt.Printf("Server error: %v\n", err) + } +} + +func getWeatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (WeatherResponse, error) { + temp := 22.5 + if args.Units == "fahrenheit" { + temp = temp*9/5 + 32 + } + + return WeatherResponse{ + Location: args.Location, + Temperature: temp, + Units: args.Units, + Conditions: "Cloudy with a chance of meatballs", + Timestamp: time.Now(), + }, nil +} + +func getUserProfileHandler(ctx context.Context, request mcp.CallToolRequest, args UserRequest) (UserProfile, error) { + return UserProfile{ + ID: args.UserID, + Name: "John Doe", + Email: "john.doe@example.com", + Tags: []string{"developer", "golang"}, + }, nil +} + +func manualWeatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (*mcp.CallToolResult, error) { + response := WeatherResponse{ + Location: args.Location, + Temperature: 25.0, + Units: "celsius", + Conditions: "Sunny, yesterday my life was filled with rain", + Timestamp: time.Now(), + } + + fallbackText := fmt.Sprintf("Weather in %s: %.1f°C, %s", + response.Location, response.Temperature, response.Conditions) + + return mcp.NewToolResultStructured(response, fallbackText), nil +} diff --git a/go.mod b/go.mod index 9b9fe2d48..5c8974549 100644 --- a/go.mod +++ b/go.mod @@ -4,13 +4,18 @@ go 1.23 require ( github.com/google/uuid v1.6.0 + github.com/invopop/jsonschema v0.13.0 github.com/spf13/cast v1.7.1 github.com/stretchr/testify v1.9.0 github.com/yosida95/uritemplate/v3 v3.0.2 ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 31ed86d18..70e9c33da 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -6,10 +10,15 @@ github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -18,6 +27,8 @@ github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/mcp/tools.go b/mcp/tools.go index 3e3931b09..cf9fabebe 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -6,6 +6,8 @@ import ( "fmt" "reflect" "strconv" + + "github.com/invopop/jsonschema" ) var errToolSchemaConflict = errors.New("provide either InputSchema or RawInputSchema, not both") @@ -36,6 +38,10 @@ type ListToolsResult struct { type CallToolResult struct { Result Content []Content `json:"content"` // Can be TextContent, ImageContent, AudioContent, or EmbeddedResource + // Structured content returned as a JSON object in the structuredContent field of a result. + // For backwards compatibility, a tool that returns structured content SHOULD also return + // functionally equivalent unstructured content. + StructuredContent any `json:"structuredContent,omitempty"` // Whether the tool call ended in an error. // // If not set, this is assumed to be false (the call was successful). @@ -478,6 +484,8 @@ type Tool struct { InputSchema ToolInputSchema `json:"inputSchema"` // Alternative to InputSchema - allows arbitrary JSON Schema to be provided RawInputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling + // Optional JSON Schema defining expected output structure + RawOutputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling // Optional properties describing tool behavior Annotations ToolAnnotation `json:"annotations"` } @@ -491,7 +499,7 @@ func (t Tool) GetName() string { // It handles marshaling either InputSchema or RawInputSchema based on which is set. func (t Tool) MarshalJSON() ([]byte, error) { // Create a map to build the JSON structure - m := make(map[string]any, 3) + m := make(map[string]any, 5) // Add the name and description m["name"] = t.Name @@ -499,7 +507,7 @@ func (t Tool) MarshalJSON() ([]byte, error) { m["description"] = t.Description } - // Determine which schema to use + // Determine which input schema to use if t.RawInputSchema != nil { if t.InputSchema.Type != "" { return nil, fmt.Errorf("tool %s has both InputSchema and RawInputSchema set: %w", t.Name, errToolSchemaConflict) @@ -510,6 +518,11 @@ func (t Tool) MarshalJSON() ([]byte, error) { m["inputSchema"] = t.InputSchema } + // Add output schema if present + if t.RawOutputSchema != nil { + m["outputSchema"] = t.RawOutputSchema + } + m["annotations"] = t.Annotations return json.Marshal(m) @@ -615,6 +628,39 @@ func WithDescription(description string) ToolOption { } } +// WithOutputSchema creates a ToolOption that sets the output schema for a tool. +// It accepts any Go type, usually a struct, and automatically generates a JSON schema from it. +func WithOutputSchema[T any]() ToolOption { + return func(t *Tool) { + var zero T + + // Generate schema using invopop/jsonschema library + reflector := jsonschema.Reflector{} + schema := reflector.Reflect(zero) + + // Extract the MCP-compatible schema (inline object schema) + // See how jsonschema library generates the schema: https://github.com/invopop/jsonschema#example + mcpSchema, err := ExtractMCPSchema(schema) + if err != nil { + // Skip and maintain backward compatibility + return + } + + t.RawOutputSchema = mcpSchema + } +} + +// WithRawOutputSchema sets a raw JSON schema for the tool's output. +// Use this when you need full control over the schema or when working with +// complex schemas that can't be generated from Go types. The jsonschema library +// can handle complex schemas and provides nice extension points, so be sure to +// check that out before using this. +func WithRawOutputSchema(schema json.RawMessage) ToolOption { + return func(t *Tool) { + t.RawOutputSchema = schema + } +} + // WithToolAnnotation adds optional hints about the Tool. func WithToolAnnotation(annotation ToolAnnotation) ToolOption { return func(t *Tool) { diff --git a/mcp/tools_test.go b/mcp/tools_test.go index 0cd71230e..7beec31dd 100644 --- a/mcp/tools_test.go +++ b/mcp/tools_test.go @@ -529,6 +529,57 @@ func TestFlexibleArgumentsJSONMarshalUnmarshal(t *testing.T) { assert.Equal(t, float64(123), args["key2"]) // JSON numbers are unmarshaled as float64 } +// TestToolWithOutputSchema tests that the WithOutputSchema function +// generates an MCP-compatible JSON output schema for a tool +func TestToolWithOutputSchema(t *testing.T) { + type TestOutput struct { + Name string `json:"name" jsonschema_description:"Person's name"` + Age int `json:"age" jsonschema_description:"Person's age"` + Email string `json:"email,omitempty" jsonschema_description:"Email address"` + } + + tool := NewTool("test_tool", + WithDescription("Test tool with output schema"), + WithOutputSchema[TestOutput](), + WithString("input", Required()), + ) + + // Check that RawOutputSchema was set + assert.NotNil(t, tool.RawOutputSchema) + + // Marshal and verify structure + data, err := json.Marshal(tool) + assert.NoError(t, err) + + var toolData map[string]any + err = json.Unmarshal(data, &toolData) + assert.NoError(t, err) + + // Verify outputSchema exists + outputSchema, exists := toolData["outputSchema"] + assert.True(t, exists) + assert.NotNil(t, outputSchema) +} + +// TestNewToolResultStructured tests that the NewToolResultStructured function +// creates a CallToolResult with both structured and text content +func TestNewToolResultStructured(t *testing.T) { + testData := map[string]any{ + "message": "Success", + "count": 42, + "active": true, + } + + result := NewToolResultStructured(testData, "Fallback text") + + assert.Len(t, result.Content, 1) + + textContent, ok := result.Content[0].(TextContent) + assert.True(t, ok) + assert.Equal(t, "Fallback text", textContent.Text) + assert.NotNil(t, result.StructuredContent) +} + // TestNewItemsAPICompatibility tests that the new Items API functions // generate the same schema as the original Items() function with manual schema objects func TestNewItemsAPICompatibility(t *testing.T) { diff --git a/mcp/typed_tools.go b/mcp/typed_tools.go index 68d8cdd1f..a03a19dd7 100644 --- a/mcp/typed_tools.go +++ b/mcp/typed_tools.go @@ -8,6 +8,9 @@ import ( // TypedToolHandlerFunc is a function that handles a tool call with typed arguments type TypedToolHandlerFunc[T any] func(ctx context.Context, request CallToolRequest, args T) (*CallToolResult, error) +// StructuredToolHandlerFunc is a function that handles a tool call with typed arguments and returns structured output +type StructuredToolHandlerFunc[TArgs any, TResult any] func(ctx context.Context, request CallToolRequest, args TArgs) (TResult, error) + // NewTypedToolHandler creates a ToolHandlerFunc that automatically binds arguments to a typed struct func NewTypedToolHandler[T any](handler TypedToolHandlerFunc[T]) func(ctx context.Context, request CallToolRequest) (*CallToolResult, error) { return func(ctx context.Context, request CallToolRequest) (*CallToolResult, error) { @@ -18,3 +21,22 @@ func NewTypedToolHandler[T any](handler TypedToolHandlerFunc[T]) func(ctx contex return handler(ctx, request, args) } } + +// NewStructuredToolHandler creates a ToolHandlerFunc that automatically binds arguments to a typed struct +// and returns structured output. It automatically creates both structured and +// text content (from the structured output) for backwards compatibility. +func NewStructuredToolHandler[TArgs any, TResult any](handler StructuredToolHandlerFunc[TArgs, TResult]) func(ctx context.Context, request CallToolRequest) (*CallToolResult, error) { + return func(ctx context.Context, request CallToolRequest) (*CallToolResult, error) { + var args TArgs + if err := request.BindArguments(&args); err != nil { + return NewToolResultError(fmt.Sprintf("failed to bind arguments: %v", err)), nil + } + + result, err := handler(ctx, request, args) + if err != nil { + return NewToolResultError(fmt.Sprintf("tool execution failed: %v", err)), nil + } + + return NewToolResultStructuredOnly(result), nil + } +} diff --git a/mcp/utils.go b/mcp/utils.go index 3e652efd7..633b8294f 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -3,7 +3,9 @@ package mcp import ( "encoding/json" "fmt" + "strings" + "github.com/invopop/jsonschema" "github.com/spf13/cast" ) @@ -253,6 +255,38 @@ func NewToolResultText(text string) *CallToolResult { } } +// NewToolResultStructured creates a new CallToolResult with structured content. +// It includes both the structured content and a text representation for backward compatibility. +func NewToolResultStructured(structured any, fallbackText string) *CallToolResult { + return &CallToolResult{ + Content: []Content{ + TextContent{ + Type: "text", + Text: fallbackText, + }, + }, + StructuredContent: structured, + } +} + +// NewToolResultStructuredOnly creates a new CallToolResult with only structured content. +// This is useful when you want to provide structured data without a text fallback. +func NewToolResultStructuredOnly(structured any) *CallToolResult { + // Convert to JSON string for backward compatibility + jsonBytes, _ := json.Marshal(structured) + fallbackText := string(jsonBytes) + + return &CallToolResult{ + Content: []Content{ + TextContent{ + Type: "text", + Text: fallbackText, + }, + }, + StructuredContent: structured, + } +} + // NewToolResultImage creates a new CallToolResult with both text and image content func NewToolResultImage(text, imageData, mimeType string) *CallToolResult { return &CallToolResult{ @@ -445,6 +479,61 @@ func FormatNumberResult(value float64) *CallToolResult { return NewToolResultText(fmt.Sprintf("%.2f", value)) } +// ExtractMCPSchema converts a full JSON Schema document to the inline format expected by MCP +func ExtractMCPSchema(schema *jsonschema.Schema) (json.RawMessage, error) { + schemaBytes, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("failed to marshal schema: %w", err) + } + + var schemaMap map[string]any + if err := json.Unmarshal(schemaBytes, &schemaMap); err != nil { + return nil, fmt.Errorf("failed to unmarshal schema: %w", err) + } + + // Handle $ref case - extract the referenced definition + if ref, hasRef := schemaMap["$ref"].(string); hasRef { + if defs, hasDefs := schemaMap["$defs"].(map[string]any); hasDefs { + // Extract the reference name + refParts := strings.Split(ref, "/") + if len(refParts) > 0 { + defName := refParts[len(refParts)-1] + if defSchema, found := defs[defName].(map[string]any); found { + // Clean up the definition - remove $schema, $id, etc. + cleanSchema := make(map[string]any) + + // Copy only type, properties, and required fields + if schemaType, ok := defSchema["type"]; ok { + cleanSchema["type"] = schemaType + } + if properties, ok := defSchema["properties"]; ok { + cleanSchema["properties"] = properties + } + if required, ok := defSchema["required"]; ok { + cleanSchema["required"] = required + } + + return json.Marshal(cleanSchema) + } + } + } + } + + // If no $ref, clean up the schema directly + cleanSchema := make(map[string]any) + if schemaType, ok := schemaMap["type"]; ok { + cleanSchema["type"] = schemaType + } + if properties, ok := schemaMap["properties"]; ok { + cleanSchema["properties"] = properties + } + if required, ok := schemaMap["required"]; ok { + cleanSchema["required"] = required + } + + return json.Marshal(cleanSchema) +} + func ExtractString(data map[string]any, key string) string { if value, ok := data[key]; ok { if str, ok := value.(string); ok { From 085c67d9a12cede55a35874fdb976ddade7f3b4c Mon Sep 17 00:00:00 2001 From: Navendu Pottekkat Date: Sun, 29 Jun 2025 15:08:52 +0530 Subject: [PATCH 2/3] fix: support nested arrays wrapped in objects, more elegant implementation --- examples/structured_output/main.go | 42 +++++++++++++++++++++- mcp/tools.go | 17 ++++++--- mcp/utils.go | 56 ------------------------------ 3 files changed, 53 insertions(+), 62 deletions(-) diff --git a/examples/structured_output/main.go b/examples/structured_output/main.go index 7fcbc1941..e7df04021 100644 --- a/examples/structured_output/main.go +++ b/examples/structured_output/main.go @@ -35,6 +35,17 @@ type UserRequest struct { UserID string `json:"userId" jsonschema_description:"User ID"` } +type Asset struct { + ID string `json:"id" jsonschema_description:"Asset identifier"` + Name string `json:"name" jsonschema_description:"Asset name"` + Value float64 `json:"value" jsonschema_description:"Current value"` + Currency string `json:"currency" jsonschema_description:"Currency code"` +} + +type AssetListRequest struct { + Limit int `json:"limit,omitempty" jsonschema_description:"Number of assets to return"` +} + func main() { s := server.NewMCPServer( "Structured Output Example", @@ -59,7 +70,15 @@ func main() { ) s.AddTool(userTool, mcp.NewStructuredToolHandler(getUserProfileHandler)) - // Example 3: Manual result creation + // Example 3: Array output - direct array of objects + assetsTool := mcp.NewTool("get_assets", + mcp.WithDescription("Get list of assets as array"), + mcp.WithOutputSchema[[]Asset](), + mcp.WithNumber("limit", mcp.Min(1), mcp.Max(100), mcp.DefaultNumber(10)), + ) + s.AddTool(assetsTool, mcp.NewStructuredToolHandler(getAssetsHandler)) + + // Example 4: Manual result creation manualTool := mcp.NewTool("manual_structured", mcp.WithDescription("Manual structured result"), mcp.WithOutputSchema[WeatherResponse](), @@ -96,6 +115,27 @@ func getUserProfileHandler(ctx context.Context, request mcp.CallToolRequest, arg }, nil } +func getAssetsHandler(ctx context.Context, request mcp.CallToolRequest, args AssetListRequest) ([]Asset, error) { + limit := args.Limit + if limit <= 0 { + limit = 10 + } + + assets := []Asset{ + {ID: "btc", Name: "Bitcoin", Value: 45000.50, Currency: "USD"}, + {ID: "eth", Name: "Ethereum", Value: 3200.75, Currency: "USD"}, + {ID: "ada", Name: "Cardano", Value: 0.85, Currency: "USD"}, + {ID: "sol", Name: "Solana", Value: 125.30, Currency: "USD"}, + {ID: "dot", Name: "Pottedot", Value: 18.45, Currency: "USD"}, + } + + if limit > len(assets) { + limit = len(assets) + } + + return assets[:limit], nil +} + func manualWeatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (*mcp.CallToolResult, error) { response := WeatherResponse{ Location: args.Location, diff --git a/mcp/tools.go b/mcp/tools.go index cf9fabebe..44909fbb8 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -635,18 +635,25 @@ func WithOutputSchema[T any]() ToolOption { var zero T // Generate schema using invopop/jsonschema library - reflector := jsonschema.Reflector{} + // Configure reflector to generate clean, MCP-compatible schemas + reflector := jsonschema.Reflector{ + DoNotReference: true, // Removes $defs map, outputs entire structure inline + Anonymous: true, // Hides auto-generated Schema IDs + AllowAdditionalProperties: true, // Removes additionalProperties: false + } schema := reflector.Reflect(zero) - // Extract the MCP-compatible schema (inline object schema) - // See how jsonschema library generates the schema: https://github.com/invopop/jsonschema#example - mcpSchema, err := ExtractMCPSchema(schema) + // Clean up schema for MCP compliance + schema.Version = "" // Remove $schema field + + // Convert to raw JSON for MCP + mcpSchema, err := json.Marshal(schema) if err != nil { // Skip and maintain backward compatibility return } - t.RawOutputSchema = mcpSchema + t.RawOutputSchema = json.RawMessage(mcpSchema) } } diff --git a/mcp/utils.go b/mcp/utils.go index 633b8294f..47711d5a4 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -3,9 +3,7 @@ package mcp import ( "encoding/json" "fmt" - "strings" - "github.com/invopop/jsonschema" "github.com/spf13/cast" ) @@ -479,60 +477,6 @@ func FormatNumberResult(value float64) *CallToolResult { return NewToolResultText(fmt.Sprintf("%.2f", value)) } -// ExtractMCPSchema converts a full JSON Schema document to the inline format expected by MCP -func ExtractMCPSchema(schema *jsonschema.Schema) (json.RawMessage, error) { - schemaBytes, err := json.Marshal(schema) - if err != nil { - return nil, fmt.Errorf("failed to marshal schema: %w", err) - } - - var schemaMap map[string]any - if err := json.Unmarshal(schemaBytes, &schemaMap); err != nil { - return nil, fmt.Errorf("failed to unmarshal schema: %w", err) - } - - // Handle $ref case - extract the referenced definition - if ref, hasRef := schemaMap["$ref"].(string); hasRef { - if defs, hasDefs := schemaMap["$defs"].(map[string]any); hasDefs { - // Extract the reference name - refParts := strings.Split(ref, "/") - if len(refParts) > 0 { - defName := refParts[len(refParts)-1] - if defSchema, found := defs[defName].(map[string]any); found { - // Clean up the definition - remove $schema, $id, etc. - cleanSchema := make(map[string]any) - - // Copy only type, properties, and required fields - if schemaType, ok := defSchema["type"]; ok { - cleanSchema["type"] = schemaType - } - if properties, ok := defSchema["properties"]; ok { - cleanSchema["properties"] = properties - } - if required, ok := defSchema["required"]; ok { - cleanSchema["required"] = required - } - - return json.Marshal(cleanSchema) - } - } - } - } - - // If no $ref, clean up the schema directly - cleanSchema := make(map[string]any) - if schemaType, ok := schemaMap["type"]; ok { - cleanSchema["type"] = schemaType - } - if properties, ok := schemaMap["properties"]; ok { - cleanSchema["properties"] = properties - } - if required, ok := schemaMap["required"]; ok { - cleanSchema["required"] = required - } - - return json.Marshal(cleanSchema) -} func ExtractString(data map[string]any, key string) string { if value, ok := data[key]; ok { From 0334dc03500b2ae3e23eab313b38d4fc5cc5b09f Mon Sep 17 00:00:00 2001 From: Navendu Pottekkat Date: Mon, 28 Jul 2025 09:49:34 +0530 Subject: [PATCH 3/3] fix: improve error handling --- mcp/utils.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/mcp/utils.go b/mcp/utils.go index 47711d5a4..e5a01caa1 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -267,12 +267,18 @@ func NewToolResultStructured(structured any, fallbackText string) *CallToolResul } } -// NewToolResultStructuredOnly creates a new CallToolResult with only structured content. -// This is useful when you want to provide structured data without a text fallback. +// NewToolResultStructuredOnly creates a new CallToolResult with structured +// content and creates a JSON string fallback for backwards compatibility. +// This is useful when you want to provide structured data without any specific text fallback. func NewToolResultStructuredOnly(structured any) *CallToolResult { + var fallbackText string // Convert to JSON string for backward compatibility - jsonBytes, _ := json.Marshal(structured) - fallbackText := string(jsonBytes) + jsonBytes, err := json.Marshal(structured) + if err != nil { + fallbackText = fmt.Sprintf("Error serializing structured content: %v", err) + } else { + fallbackText = string(jsonBytes) + } return &CallToolResult{ Content: []Content{ @@ -477,7 +483,6 @@ func FormatNumberResult(value float64) *CallToolResult { return NewToolResultText(fmt.Sprintf("%.2f", value)) } - func ExtractString(data map[string]any, key string) string { if value, ok := data[key]; ok { if str, ok := value.(string); ok {