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..e7df04021 --- /dev/null +++ b/examples/structured_output/main.go @@ -0,0 +1,152 @@ +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"` +} + +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", + "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: 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](), + 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 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, + 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 6976ed3d5..997bdc912 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -7,6 +7,8 @@ import ( "net/http" "reflect" "strconv" + + "github.com/invopop/jsonschema" ) var errToolSchemaConflict = errors.New("provide either InputSchema or RawInputSchema, not both") @@ -38,6 +40,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). @@ -547,6 +553,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"` } @@ -560,7 +568,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 @@ -568,7 +576,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) @@ -579,6 +587,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) @@ -689,6 +702,46 @@ 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 + // 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) + + // 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 = json.RawMessage(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..e5a01caa1 100644 --- a/mcp/utils.go +++ b/mcp/utils.go @@ -253,6 +253,44 @@ 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 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, err := json.Marshal(structured) + if err != nil { + fallbackText = fmt.Sprintf("Error serializing structured content: %v", err) + } else { + 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{