-
Notifications
You must be signed in to change notification settings - Fork 697
feat: support structured content and output schema #460
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+380
−2
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
5ce9da8
feat: support structured content and output schema
pottekkat 085c67d
fix: support nested arrays wrapped in objects, more elegant implement…
pottekkat decdee8
Merge branch 'main' into pottekkat/structured-schema
pottekkat 0334dc0
fix: improve error handling
pottekkat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: "[email protected]", | ||
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 | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,15 +568,15 @@ 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 | ||
if t.Description != "" { | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we log this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe we can add a tool validation logic later? |
||
} | ||
|
||
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) { | ||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.