Skip to content

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
merged 4 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions examples/structured_output/README.md
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.
152 changes: 152 additions & 0 deletions examples/structured_output/main.go
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
}
5 changes: 5 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
57 changes: 55 additions & 2 deletions mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"net/http"
"reflect"
"strconv"

"github.com/invopop/jsonschema"
)

var errToolSchemaConflict = errors.New("provide either InputSchema or RawInputSchema, not both")
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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"`
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we log this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The 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) {
Expand Down
Loading