Skip to content

Commit 8f9d538

Browse files
authored
feat: support structured content and output schema (#460)
* feat: support structured content and output schema * fix: support nested arrays wrapped in objects, more elegant implementation * fix: improve error handling
1 parent a43b104 commit 8f9d538

File tree

8 files changed

+380
-2
lines changed

8 files changed

+380
-2
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Structured Content Example
2+
3+
This example shows how to return `structuredContent` in tool result with corresponding `OutputSchema`.
4+
5+
Defined in the MCP spec here: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#structured-content
6+
7+
## Usage
8+
9+
Define a struct for your output:
10+
11+
```go
12+
type WeatherResponse struct {
13+
Location string `json:"location" jsonschema_description:"The location"`
14+
Temperature float64 `json:"temperature" jsonschema_description:"Current temperature"`
15+
Conditions string `json:"conditions" jsonschema_description:"Weather conditions"`
16+
}
17+
```
18+
19+
Add it to your tool:
20+
21+
```go
22+
tool := mcp.NewTool("get_weather",
23+
mcp.WithDescription("Get weather information"),
24+
mcp.WithOutputSchema[WeatherResponse](),
25+
mcp.WithString("location", mcp.Required()),
26+
)
27+
```
28+
29+
Return structured data in tool result:
30+
31+
```go
32+
func weatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (*mcp.CallToolResult, error) {
33+
response := WeatherResponse{
34+
Location: args.Location,
35+
Temperature: 25.0,
36+
Conditions: "Cloudy",
37+
}
38+
39+
fallbackText := fmt.Sprintf("Weather in %s: %.1f°C, %s",
40+
response.Location, response.Temperature, response.Conditions)
41+
42+
return mcp.NewToolResultStructured(response, fallbackText), nil
43+
}
44+
```
45+
46+
See [main.go](./main.go) for more examples.

examples/structured_output/main.go

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/mark3labs/mcp-go/mcp"
9+
"github.com/mark3labs/mcp-go/server"
10+
)
11+
12+
// Note: The jsonschema_description tag is added to the JSON schema as description
13+
// Ideally use better descriptions, this is just an example
14+
type WeatherRequest struct {
15+
Location string `json:"location" jsonschema_description:"City or location"`
16+
Units string `json:"units,omitempty" jsonschema_description:"celsius or fahrenheit"`
17+
}
18+
19+
type WeatherResponse struct {
20+
Location string `json:"location" jsonschema_description:"Location"`
21+
Temperature float64 `json:"temperature" jsonschema_description:"Temperature"`
22+
Units string `json:"units" jsonschema_description:"Units"`
23+
Conditions string `json:"conditions" jsonschema_description:"Weather conditions"`
24+
Timestamp time.Time `json:"timestamp" jsonschema_description:"When retrieved"`
25+
}
26+
27+
type UserProfile struct {
28+
ID string `json:"id" jsonschema_description:"User ID"`
29+
Name string `json:"name" jsonschema_description:"Full name"`
30+
Email string `json:"email" jsonschema_description:"Email"`
31+
Tags []string `json:"tags" jsonschema_description:"User tags"`
32+
}
33+
34+
type UserRequest struct {
35+
UserID string `json:"userId" jsonschema_description:"User ID"`
36+
}
37+
38+
type Asset struct {
39+
ID string `json:"id" jsonschema_description:"Asset identifier"`
40+
Name string `json:"name" jsonschema_description:"Asset name"`
41+
Value float64 `json:"value" jsonschema_description:"Current value"`
42+
Currency string `json:"currency" jsonschema_description:"Currency code"`
43+
}
44+
45+
type AssetListRequest struct {
46+
Limit int `json:"limit,omitempty" jsonschema_description:"Number of assets to return"`
47+
}
48+
49+
func main() {
50+
s := server.NewMCPServer(
51+
"Structured Output Example",
52+
"1.0.0",
53+
server.WithToolCapabilities(false),
54+
)
55+
56+
// Example 1: Auto-generated schema from struct
57+
weatherTool := mcp.NewTool("get_weather",
58+
mcp.WithDescription("Get weather with structured output"),
59+
mcp.WithOutputSchema[WeatherResponse](),
60+
mcp.WithString("location", mcp.Required()),
61+
mcp.WithString("units", mcp.Enum("celsius", "fahrenheit"), mcp.DefaultString("celsius")),
62+
)
63+
s.AddTool(weatherTool, mcp.NewStructuredToolHandler(getWeatherHandler))
64+
65+
// Example 2: Nested struct schema
66+
userTool := mcp.NewTool("get_user_profile",
67+
mcp.WithDescription("Get user profile"),
68+
mcp.WithOutputSchema[UserProfile](),
69+
mcp.WithString("userId", mcp.Required()),
70+
)
71+
s.AddTool(userTool, mcp.NewStructuredToolHandler(getUserProfileHandler))
72+
73+
// Example 3: Array output - direct array of objects
74+
assetsTool := mcp.NewTool("get_assets",
75+
mcp.WithDescription("Get list of assets as array"),
76+
mcp.WithOutputSchema[[]Asset](),
77+
mcp.WithNumber("limit", mcp.Min(1), mcp.Max(100), mcp.DefaultNumber(10)),
78+
)
79+
s.AddTool(assetsTool, mcp.NewStructuredToolHandler(getAssetsHandler))
80+
81+
// Example 4: Manual result creation
82+
manualTool := mcp.NewTool("manual_structured",
83+
mcp.WithDescription("Manual structured result"),
84+
mcp.WithOutputSchema[WeatherResponse](),
85+
mcp.WithString("location", mcp.Required()),
86+
)
87+
s.AddTool(manualTool, mcp.NewTypedToolHandler(manualWeatherHandler))
88+
89+
if err := server.ServeStdio(s); err != nil {
90+
fmt.Printf("Server error: %v\n", err)
91+
}
92+
}
93+
94+
func getWeatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (WeatherResponse, error) {
95+
temp := 22.5
96+
if args.Units == "fahrenheit" {
97+
temp = temp*9/5 + 32
98+
}
99+
100+
return WeatherResponse{
101+
Location: args.Location,
102+
Temperature: temp,
103+
Units: args.Units,
104+
Conditions: "Cloudy with a chance of meatballs",
105+
Timestamp: time.Now(),
106+
}, nil
107+
}
108+
109+
func getUserProfileHandler(ctx context.Context, request mcp.CallToolRequest, args UserRequest) (UserProfile, error) {
110+
return UserProfile{
111+
ID: args.UserID,
112+
Name: "John Doe",
113+
114+
Tags: []string{"developer", "golang"},
115+
}, nil
116+
}
117+
118+
func getAssetsHandler(ctx context.Context, request mcp.CallToolRequest, args AssetListRequest) ([]Asset, error) {
119+
limit := args.Limit
120+
if limit <= 0 {
121+
limit = 10
122+
}
123+
124+
assets := []Asset{
125+
{ID: "btc", Name: "Bitcoin", Value: 45000.50, Currency: "USD"},
126+
{ID: "eth", Name: "Ethereum", Value: 3200.75, Currency: "USD"},
127+
{ID: "ada", Name: "Cardano", Value: 0.85, Currency: "USD"},
128+
{ID: "sol", Name: "Solana", Value: 125.30, Currency: "USD"},
129+
{ID: "dot", Name: "Pottedot", Value: 18.45, Currency: "USD"},
130+
}
131+
132+
if limit > len(assets) {
133+
limit = len(assets)
134+
}
135+
136+
return assets[:limit], nil
137+
}
138+
139+
func manualWeatherHandler(ctx context.Context, request mcp.CallToolRequest, args WeatherRequest) (*mcp.CallToolResult, error) {
140+
response := WeatherResponse{
141+
Location: args.Location,
142+
Temperature: 25.0,
143+
Units: "celsius",
144+
Conditions: "Sunny, yesterday my life was filled with rain",
145+
Timestamp: time.Now(),
146+
}
147+
148+
fallbackText := fmt.Sprintf("Weather in %s: %.1f°C, %s",
149+
response.Location, response.Temperature, response.Conditions)
150+
151+
return mcp.NewToolResultStructured(response, fallbackText), nil
152+
}

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ go 1.23
44

55
require (
66
github.com/google/uuid v1.6.0
7+
github.com/invopop/jsonschema v0.13.0
78
github.com/spf13/cast v1.7.1
89
github.com/stretchr/testify v1.9.0
910
github.com/yosida95/uritemplate/v3 v3.0.2
1011
)
1112

1213
require (
14+
github.com/bahlo/generic-list-go v0.2.0 // indirect
15+
github.com/buger/jsonparser v1.1.1 // indirect
1316
github.com/davecgh/go-spew v1.1.1 // indirect
17+
github.com/mailru/easyjson v0.7.7 // indirect
1418
github.com/pmezard/go-difflib v1.0.0 // indirect
19+
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
1520
gopkg.in/yaml.v3 v3.0.1 // indirect
1621
)

go.sum

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
2+
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
3+
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
4+
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
15
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
26
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
37
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=
610
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
711
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
812
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
13+
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
14+
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
15+
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
916
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
1017
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
1118
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
1219
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
20+
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
21+
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
1322
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
1423
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
1524
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=
1827
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
1928
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
2029
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
30+
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
31+
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
2132
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
2233
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
2334
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

mcp/tools.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
"net/http"
88
"reflect"
99
"strconv"
10+
11+
"github.com/invopop/jsonschema"
1012
)
1113

1214
var errToolSchemaConflict = errors.New("provide either InputSchema or RawInputSchema, not both")
@@ -38,6 +40,10 @@ type ListToolsResult struct {
3840
type CallToolResult struct {
3941
Result
4042
Content []Content `json:"content"` // Can be TextContent, ImageContent, AudioContent, or EmbeddedResource
43+
// Structured content returned as a JSON object in the structuredContent field of a result.
44+
// For backwards compatibility, a tool that returns structured content SHOULD also return
45+
// functionally equivalent unstructured content.
46+
StructuredContent any `json:"structuredContent,omitempty"`
4147
// Whether the tool call ended in an error.
4248
//
4349
// If not set, this is assumed to be false (the call was successful).
@@ -547,6 +553,8 @@ type Tool struct {
547553
InputSchema ToolInputSchema `json:"inputSchema"`
548554
// Alternative to InputSchema - allows arbitrary JSON Schema to be provided
549555
RawInputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling
556+
// Optional JSON Schema defining expected output structure
557+
RawOutputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling
550558
// Optional properties describing tool behavior
551559
Annotations ToolAnnotation `json:"annotations"`
552560
}
@@ -560,15 +568,15 @@ func (t Tool) GetName() string {
560568
// It handles marshaling either InputSchema or RawInputSchema based on which is set.
561569
func (t Tool) MarshalJSON() ([]byte, error) {
562570
// Create a map to build the JSON structure
563-
m := make(map[string]any, 3)
571+
m := make(map[string]any, 5)
564572

565573
// Add the name and description
566574
m["name"] = t.Name
567575
if t.Description != "" {
568576
m["description"] = t.Description
569577
}
570578

571-
// Determine which schema to use
579+
// Determine which input schema to use
572580
if t.RawInputSchema != nil {
573581
if t.InputSchema.Type != "" {
574582
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) {
579587
m["inputSchema"] = t.InputSchema
580588
}
581589

590+
// Add output schema if present
591+
if t.RawOutputSchema != nil {
592+
m["outputSchema"] = t.RawOutputSchema
593+
}
594+
582595
m["annotations"] = t.Annotations
583596

584597
return json.Marshal(m)
@@ -689,6 +702,46 @@ func WithDescription(description string) ToolOption {
689702
}
690703
}
691704

705+
// WithOutputSchema creates a ToolOption that sets the output schema for a tool.
706+
// It accepts any Go type, usually a struct, and automatically generates a JSON schema from it.
707+
func WithOutputSchema[T any]() ToolOption {
708+
return func(t *Tool) {
709+
var zero T
710+
711+
// Generate schema using invopop/jsonschema library
712+
// Configure reflector to generate clean, MCP-compatible schemas
713+
reflector := jsonschema.Reflector{
714+
DoNotReference: true, // Removes $defs map, outputs entire structure inline
715+
Anonymous: true, // Hides auto-generated Schema IDs
716+
AllowAdditionalProperties: true, // Removes additionalProperties: false
717+
}
718+
schema := reflector.Reflect(zero)
719+
720+
// Clean up schema for MCP compliance
721+
schema.Version = "" // Remove $schema field
722+
723+
// Convert to raw JSON for MCP
724+
mcpSchema, err := json.Marshal(schema)
725+
if err != nil {
726+
// Skip and maintain backward compatibility
727+
return
728+
}
729+
730+
t.RawOutputSchema = json.RawMessage(mcpSchema)
731+
}
732+
}
733+
734+
// WithRawOutputSchema sets a raw JSON schema for the tool's output.
735+
// Use this when you need full control over the schema or when working with
736+
// complex schemas that can't be generated from Go types. The jsonschema library
737+
// can handle complex schemas and provides nice extension points, so be sure to
738+
// check that out before using this.
739+
func WithRawOutputSchema(schema json.RawMessage) ToolOption {
740+
return func(t *Tool) {
741+
t.RawOutputSchema = schema
742+
}
743+
}
744+
692745
// WithToolAnnotation adds optional hints about the Tool.
693746
func WithToolAnnotation(annotation ToolAnnotation) ToolOption {
694747
return func(t *Tool) {

0 commit comments

Comments
 (0)