From b46b0d7ae7b58540bd4d6ec94c73f647f6c6d1b3 Mon Sep 17 00:00:00 2001 From: Grivn Date: Thu, 7 Aug 2025 14:53:24 +0800 Subject: [PATCH 1/2] feat: support creating tools using go-struct-style input schema --- .../README.md | 11 ++++- .../main.go | 19 ++++--- mcp/tools.go | 41 ++++++++++++++++ mcp/tools_test.go | 49 +++++++++++++++++++ 4 files changed, 109 insertions(+), 11 deletions(-) rename examples/{structured_output => structured_input_and_output}/README.md (79%) rename examples/{structured_output => structured_input_and_output}/main.go (90%) diff --git a/examples/structured_output/README.md b/examples/structured_input_and_output/README.md similarity index 79% rename from examples/structured_output/README.md rename to examples/structured_input_and_output/README.md index e2de01fcf..76de8e1df 100644 --- a/examples/structured_output/README.md +++ b/examples/structured_input_and_output/README.md @@ -6,6 +6,15 @@ Defined in the MCP spec here: https://modelcontextprotocol.io/specification/2025 ## Usage +Define a struct for your input: + +```go +type WeatherRequest struct { + Location string `json:"location,required" jsonschema_description:"City or location"` + Units string `json:"units,omitempty" jsonschema_description:"celsius or fahrenheit" jsonschema:"enum=celsius,enum=fahrenheit"` +} +``` + Define a struct for your output: ```go @@ -21,8 +30,8 @@ Add it to your tool: ```go tool := mcp.NewTool("get_weather", mcp.WithDescription("Get weather information"), + mcp.WithInputSchema[WeatherRequest](), mcp.WithOutputSchema[WeatherResponse](), - mcp.WithString("location", mcp.Required()), ) ``` diff --git a/examples/structured_output/main.go b/examples/structured_input_and_output/main.go similarity index 90% rename from examples/structured_output/main.go rename to examples/structured_input_and_output/main.go index e7df04021..f932def08 100644 --- a/examples/structured_output/main.go +++ b/examples/structured_input_and_output/main.go @@ -12,8 +12,8 @@ import ( // 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"` + Location string `json:"location" jsonschema_description:"City or location" jsonschema:"required"` + Units string `json:"units,omitempty" jsonschema_description:"celsius or fahrenheit" jsonschema:"enum=celsius,enum=fahrenheit"` } type WeatherResponse struct { @@ -32,7 +32,7 @@ type UserProfile struct { } type UserRequest struct { - UserID string `json:"userId" jsonschema_description:"User ID"` + UserID string `json:"userId" jsonschema_description:"User ID" jsonschema:"required"` } type Asset struct { @@ -43,12 +43,12 @@ type Asset struct { } type AssetListRequest struct { - Limit int `json:"limit,omitempty" jsonschema_description:"Number of assets to return"` + Limit int `json:"limit,omitempty" jsonschema_description:"Number of assets to return" jsonschema:"minimum=1,maximum=100,default=10"` } func main() { s := server.NewMCPServer( - "Structured Output Example", + "Structured Input/Output Example", "1.0.0", server.WithToolCapabilities(false), ) @@ -56,33 +56,32 @@ func main() { // Example 1: Auto-generated schema from struct weatherTool := mcp.NewTool("get_weather", mcp.WithDescription("Get weather with structured output"), + mcp.WithInputSchema[WeatherRequest](), 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.WithInputSchema[UserRequest](), 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.WithInputSchema[AssetListRequest](), 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.WithInputSchema[WeatherRequest](), mcp.WithOutputSchema[WeatherResponse](), - mcp.WithString("location", mcp.Required()), ) s.AddTool(manualTool, mcp.NewTypedToolHandler(manualWeatherHandler)) diff --git a/mcp/tools.go b/mcp/tools.go index 500503e2a..b9d6e1726 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -704,6 +704,47 @@ func WithDescription(description string) ToolOption { } } +// WithInputSchema creates a ToolOption that sets the input schema for a tool. +// It accepts any Go type, usually a struct, and automatically generates a JSON schema from it. +func WithInputSchema[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.InputSchema.Type = "" + t.RawInputSchema = json.RawMessage(mcpSchema) + } +} + +// WithRawInputSchema sets a raw JSON schema for the tool's input. +// 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 WithRawInputSchema(schema json.RawMessage) ToolOption { + return func(t *Tool) { + t.RawInputSchema = schema + } +} + // 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 { diff --git a/mcp/tools_test.go b/mcp/tools_test.go index 7beec31dd..5fb1ee955 100644 --- a/mcp/tools_test.go +++ b/mcp/tools_test.go @@ -529,6 +529,55 @@ func TestFlexibleArgumentsJSONMarshalUnmarshal(t *testing.T) { assert.Equal(t, float64(123), args["key2"]) // JSON numbers are unmarshaled as float64 } +// TestToolWithInputSchema tests that the WithInputSchema function +// generates an MCP-compatible JSON output schema for a tool +func TestToolWithInputSchema(t *testing.T) { + type TestInput struct { + Name string `json:"name" jsonschema_description:"Person's name" jsonschema:"required"` + Age int `json:"age" jsonschema_description:"Person's age"` + Email string `json:"email,omitempty" jsonschema_description:"Email address" jsonschema:"required"` + } + + tool := NewTool("test_tool", + WithDescription("Test tool with output schema"), + WithInputSchema[TestInput](), + ) + + // Check that RawOutputSchema was set + assert.NotNil(t, tool.RawInputSchema) + + // 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 inputSchema exists + inputSchema, exists := toolData["inputSchema"] + assert.True(t, exists) + assert.NotNil(t, inputSchema) + + // Verify required list exists + schemaMap, ok := inputSchema.(map[string]interface{}) + assert.True(t, ok) + requiredList, exists := schemaMap["required"] + assert.True(t, exists) + assert.NotNil(t, requiredList) + + // Verify properties exist + properties, exists := schemaMap["properties"] + assert.True(t, exists) + propertiesMap, ok := properties.(map[string]interface{}) + assert.True(t, ok) + + // Verify specific properties + assert.Contains(t, propertiesMap, "name") + assert.Contains(t, propertiesMap, "age") + assert.Contains(t, propertiesMap, "email") +} + // TestToolWithOutputSchema tests that the WithOutputSchema function // generates an MCP-compatible JSON output schema for a tool func TestToolWithOutputSchema(t *testing.T) { From e0adb403d8f029fc5f190fd39a5e1669b44e4185 Mon Sep 17 00:00:00 2001 From: Grivn Date: Tue, 12 Aug 2025 11:33:19 +0800 Subject: [PATCH 2/2] docs: add struct-based schema documentation for tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive documentation for the new struct-based schema features introduced in commit b46b0d7, including: - Input schema definition using Go structs with WithInputSchema - Output schema definition using WithOutputSchema - Structured tool handlers with NewStructuredToolHandler - Array output schemas - Schema tags reference - Complete file operations example with structured I/O 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- www/docs/pages/servers/tools.mdx | 304 +++++++++++++++++++++++++++++++ 1 file changed, 304 insertions(+) diff --git a/www/docs/pages/servers/tools.mdx b/www/docs/pages/servers/tools.mdx index e329bd1c1..7bd5bf75a 100644 --- a/www/docs/pages/servers/tools.mdx +++ b/www/docs/pages/servers/tools.mdx @@ -101,6 +101,310 @@ mcp.WithNumber("price", ) ``` +## Struct-Based Schema Definition + +MCP-Go supports defining input and output schemas using Go structs with automatic JSON schema generation. This provides a type-safe alternative to manual parameter definition, especially useful for complex tools with structured inputs and outputs. + +### Input Schema with Go Structs + +Define your input parameters as a Go struct and use `WithInputSchema`: + +```go +// Define input struct with JSON schema tags +type SearchRequest struct { + Query string `json:"query" jsonschema_description:"Search query" jsonschema:"required"` + Limit int `json:"limit,omitempty" jsonschema_description:"Maximum results" jsonschema:"minimum=1,maximum=100,default=10"` + Categories []string `json:"categories,omitempty" jsonschema_description:"Filter by categories"` + SortBy string `json:"sortBy,omitempty" jsonschema_description:"Sort field" jsonschema:"enum=relevance,enum=date,enum=popularity"` +} + +// Create tool with struct-based input schema +searchTool := mcp.NewTool("search_products", + mcp.WithDescription("Search product catalog"), + mcp.WithInputSchema[SearchRequest](), +) +``` + +### Output Schema with Go Structs + +Define structured output for predictable tool responses: + +```go +// Define output struct +type SearchResponse struct { + Query string `json:"query" jsonschema_description:"Original search query"` + TotalCount int `json:"totalCount" jsonschema_description:"Total matching products"` + Products []Product `json:"products" jsonschema_description:"Search results"` + ProcessedAt time.Time `json:"processedAt" jsonschema_description:"When search was performed"` +} + +type Product struct { + ID string `json:"id" jsonschema_description:"Product ID"` + Name string `json:"name" jsonschema_description:"Product name"` + Price float64 `json:"price" jsonschema_description:"Price in USD"` + InStock bool `json:"inStock" jsonschema_description:"Availability"` +} + +// Create tool with both input and output schemas +searchTool := mcp.NewTool("search_products", + mcp.WithDescription("Search product catalog with structured output"), + mcp.WithInputSchema[SearchRequest](), + mcp.WithOutputSchema[SearchResponse](), +) +``` + +### Structured Tool Handlers + +Use `NewStructuredToolHandler` for type-safe handler implementation: + +```go +func main() { + s := server.NewMCPServer("Product Search", "1.0.0", + server.WithToolCapabilities(false), + ) + + // Define tool with input and output schemas + searchTool := mcp.NewTool("search_products", + mcp.WithDescription("Search product catalog"), + mcp.WithInputSchema[SearchRequest](), + mcp.WithOutputSchema[SearchResponse](), + ) + + // Add tool with structured handler + s.AddTool(searchTool, mcp.NewStructuredToolHandler(searchProductsHandler)) + + server.ServeStdio(s) +} + +// Handler receives typed input and returns typed output +func searchProductsHandler(ctx context.Context, req mcp.CallToolRequest, args SearchRequest) (SearchResponse, error) { + // Input is already validated and bound to SearchRequest struct + limit := args.Limit + if limit <= 0 { + limit = 10 + } + + // Perform search logic + products := searchDatabase(args.Query, args.Categories, limit) + + // Return structured response + return SearchResponse{ + Query: args.Query, + TotalCount: len(products), + Products: products, + ProcessedAt: time.Now(), + }, nil +} +``` + +### Array Output Schema + +Tools can return arrays of structured data: + +```go +// Define asset struct +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"` +} + +// Tool that returns array of assets +assetsTool := mcp.NewTool("list_assets", + mcp.WithDescription("List portfolio assets"), + mcp.WithInputSchema[struct { + Portfolio string `json:"portfolio" jsonschema_description:"Portfolio ID" jsonschema:"required"` + }](), + mcp.WithOutputSchema[[]Asset](), // Array output schema +) + +func listAssetsHandler(ctx context.Context, req mcp.CallToolRequest, args struct{ Portfolio string }) ([]Asset, error) { + // Return array of assets + return []Asset{ + {ID: "btc", Name: "Bitcoin", Value: 45000.50, Currency: "USD"}, + {ID: "eth", Name: "Ethereum", Value: 3200.75, Currency: "USD"}, + }, nil +} +``` + +### Schema Tags Reference + +MCP-Go uses the `jsonschema` struct tags for schema generation: + +```go +type ExampleStruct struct { + // Required field + Name string `json:"name" jsonschema:"required"` + + // Field with description + Age int `json:"age" jsonschema_description:"User age in years"` + + // Field with constraints + Score float64 `json:"score" jsonschema:"minimum=0,maximum=100"` + + // Enum field + Status string `json:"status" jsonschema:"enum=active,enum=inactive,enum=pending"` + + // Optional field with default + PageSize int `json:"pageSize,omitempty" jsonschema:"default=20"` + + // Array with constraints + Tags []string `json:"tags" jsonschema:"minItems=1,maxItems=10"` +} +``` + +### Manual Structured Results + +For more control over the response, use `NewTypedToolHandler` with manual result creation: + +```go +manualTool := mcp.NewTool("process_data", + mcp.WithDescription("Process data with custom result"), + mcp.WithInputSchema[ProcessRequest](), + mcp.WithOutputSchema[ProcessResponse](), +) + +s.AddTool(manualTool, mcp.NewTypedToolHandler(manualProcessHandler)) + +func manualProcessHandler(ctx context.Context, req mcp.CallToolRequest, args ProcessRequest) (*mcp.CallToolResult, error) { + // Process the data + response := ProcessResponse{ + Status: "completed", + ProcessedAt: time.Now(), + ItemCount: 42, + } + + // Create custom fallback text for backward compatibility + fallbackText := fmt.Sprintf("Processed %d items successfully", response.ItemCount) + + // Return structured result with custom text + return mcp.NewToolResultStructured(response, fallbackText), nil +} +``` + +### Complete Example: File Operations with Structured I/O + +Here's a complete example using the file operations pattern from earlier, enhanced with structured schemas: + +```go +// Define structured input for file operations +type FileOperationRequest struct { + Path string `json:"path" jsonschema_description:"File path" jsonschema:"required"` + Content string `json:"content,omitempty" jsonschema_description:"File content (for write operations)"` + Encoding string `json:"encoding,omitempty" jsonschema_description:"File encoding" jsonschema:"enum=utf-8,enum=ascii,enum=base64,default=utf-8"` +} + +// Define structured output +type FileOperationResponse struct { + Success bool `json:"success" jsonschema_description:"Operation success status"` + Path string `json:"path" jsonschema_description:"File path"` + Message string `json:"message" jsonschema_description:"Result message"` + Content string `json:"content,omitempty" jsonschema_description:"File content (for read operations)"` + Size int64 `json:"size,omitempty" jsonschema_description:"File size in bytes"` + Modified time.Time `json:"modified,omitempty" jsonschema_description:"Last modified time"` +} + +func main() { + s := server.NewMCPServer("File Manager", "1.0.0", + server.WithToolCapabilities(true), + ) + + // Create file tool with structured I/O + createFileTool := mcp.NewTool("create_file", + mcp.WithDescription("Create a new file with content"), + mcp.WithInputSchema[FileOperationRequest](), + mcp.WithOutputSchema[FileOperationResponse](), + ) + + // Read file tool + readFileTool := mcp.NewTool("read_file", + mcp.WithDescription("Read file contents"), + mcp.WithInputSchema[struct { + Path string `json:"path" jsonschema_description:"File path to read" jsonschema:"required"` + }](), + mcp.WithOutputSchema[FileOperationResponse](), + ) + + s.AddTool(createFileTool, mcp.NewStructuredToolHandler(handleCreateFile)) + s.AddTool(readFileTool, mcp.NewStructuredToolHandler(handleReadFile)) + + server.ServeStdio(s) +} + +func handleCreateFile(ctx context.Context, req mcp.CallToolRequest, args FileOperationRequest) (FileOperationResponse, error) { + // Validate path for security + if strings.Contains(args.Path, "..") { + return FileOperationResponse{ + Success: false, + Path: args.Path, + Message: "Invalid path: directory traversal not allowed", + }, nil + } + + // Handle different encodings + var data []byte + switch args.Encoding { + case "base64": + var err error + data, err = base64.StdEncoding.DecodeString(args.Content) + if err != nil { + return FileOperationResponse{ + Success: false, + Path: args.Path, + Message: fmt.Sprintf("Invalid base64 content: %v", err), + }, nil + } + default: + data = []byte(args.Content) + } + + // Create file + if err := os.WriteFile(args.Path, data, 0644); err != nil { + return FileOperationResponse{ + Success: false, + Path: args.Path, + Message: fmt.Sprintf("Failed to create file: %v", err), + }, nil + } + + // Get file info + info, _ := os.Stat(args.Path) + + return FileOperationResponse{ + Success: true, + Path: args.Path, + Message: "File created successfully", + Size: info.Size(), + Modified: info.ModTime(), + }, nil +} + +func handleReadFile(ctx context.Context, req mcp.CallToolRequest, args struct{ Path string }) (FileOperationResponse, error) { + // Read file + data, err := os.ReadFile(args.Path) + if err != nil { + return FileOperationResponse{ + Success: false, + Path: args.Path, + Message: fmt.Sprintf("Failed to read file: %v", err), + }, nil + } + + // Get file info + info, _ := os.Stat(args.Path) + + return FileOperationResponse{ + Success: true, + Path: args.Path, + Message: "File read successfully", + Content: string(data), + Size: info.Size(), + Modified: info.ModTime(), + }, nil +} +``` + ## Tool Handlers Tool handlers process the actual function calls from LLMs. MCP-Go provides convenient helper methods for safe parameter extraction.