Skip to content

Commit a36d3df

Browse files
committed
feat: implement structured tool output support
This commit introduces the core functionality for structured tool outputs. In `mcp/tools.go`: - Adds `OutputSchema` to `Tool` and `StructuredContent` to `CallToolResult`. - Renames `ToolInputSchema` to `ToolSchema` for unified handling. - Implements `ValidateStructuredOutput` with schema compilation and caching. - Adds a series of `WithOutput*` functions for programmatic schema creation. - Adds comments for new public APIs and internal logic. In `mcp/utils.go`: - Adds `NewStructuredToolResult` and `NewStructuredToolError` helpers to simplify creating structured results with backward-compatible text content.
1 parent be4ee49 commit a36d3df

File tree

2 files changed

+312
-12
lines changed

2 files changed

+312
-12
lines changed

mcp/tools.go

Lines changed: 260 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package mcp
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"errors"
67
"fmt"
78
"reflect"
89
"strconv"
10+
11+
"github.com/santhosh-tekuri/jsonschema"
912
)
1013

1114
var errToolSchemaConflict = errors.New("provide either InputSchema or RawInputSchema, not both")
@@ -36,6 +39,8 @@ type ListToolsResult struct {
3639
type CallToolResult struct {
3740
Result
3841
Content []Content `json:"content"` // Can be TextContent, ImageContent, AudioContent, or EmbeddedResource
42+
// Structured content that conforms to the tool's output schema
43+
StructuredContent any `json:"structuredContent,omitempty"`
3944
// Whether the tool call ended in an error.
4045
//
4146
// If not set, this is assumed to be false (the call was successful).
@@ -472,10 +477,17 @@ type ToolListChangedNotification struct {
472477
type Tool struct {
473478
// The name of the tool.
474479
Name string `json:"name"`
480+
// A human-friendly display name for the tool.
481+
// This is used for UI display purposes, while Name is used for programmatic identification.
482+
Title string `json:"title,omitempty"`
475483
// A human-readable description of the tool.
476484
Description string `json:"description,omitempty"`
477485
// A JSON Schema object defining the expected parameters for the tool.
478-
InputSchema ToolInputSchema `json:"inputSchema"`
486+
InputSchema ToolSchema `json:"inputSchema"`
487+
// A JSON Schema object defining the expected output for the tool.
488+
OutputSchema ToolSchema `json:"outputSchema,omitempty"`
489+
// Compiled JSON schema validator for output validation, cached for performance
490+
compiledOutputSchema *jsonschema.Schema `json:"-"`
479491
// Alternative to InputSchema - allows arbitrary JSON Schema to be provided
480492
RawInputSchema json.RawMessage `json:"-"` // Hide this from JSON marshaling
481493
// Optional properties describing tool behavior
@@ -487,14 +499,83 @@ func (t Tool) GetName() string {
487499
return t.Name
488500
}
489501

502+
// GetTitle returns the display title for the tool.
503+
// It follows the precedence: direct title field → annotations.title → empty string.
504+
func (t Tool) GetTitle() string {
505+
if title := t.Title; title != "" {
506+
return title
507+
}
508+
return t.Annotations.Title
509+
}
510+
511+
// HasOutputSchema returns true if the tool has an output schema defined.
512+
// This indicates that the tool can return structured content.
513+
func (t Tool) HasOutputSchema() bool {
514+
return t.OutputSchema.Type != "" && len(t.OutputSchema.Properties) > 0
515+
}
516+
517+
// validateStructuredOutput performs the actual validation using the compiled schema
518+
func (t Tool) validateStructuredOutput(result *CallToolResult) error {
519+
return t.compiledOutputSchema.ValidateInterface(result.StructuredContent)
520+
}
521+
522+
// ensureOutputSchemaValidator compiles and caches the JSON schema validator if not already done
523+
func (t *Tool) ensureOutputSchemaValidator() error {
524+
if t.compiledOutputSchema != nil {
525+
return nil
526+
}
527+
528+
schemaBytes, err := t.OutputSchema.MarshalJSON()
529+
if err != nil {
530+
return err
531+
}
532+
533+
compiler := jsonschema.NewCompiler()
534+
535+
const validatorKey = "output-schema-validator"
536+
if err := compiler.AddResource(validatorKey, bytes.NewReader(schemaBytes)); err != nil {
537+
return err
538+
}
539+
540+
compiledSchema, err := compiler.Compile(validatorKey)
541+
if err != nil {
542+
return err
543+
}
544+
545+
t.compiledOutputSchema = compiledSchema
546+
return nil
547+
}
548+
549+
// ValidateStructuredOutput validates the structured content against the tool's output schema.
550+
// Returns nil if the tool has no output schema or if validation passes.
551+
// Returns an error if the tool has an output schema but the structured content is invalid.
552+
func (t Tool) ValidateStructuredOutput(result *CallToolResult) error {
553+
if !t.HasOutputSchema() {
554+
return nil
555+
}
556+
557+
if result.StructuredContent == nil {
558+
return fmt.Errorf("tool %s has output schema but structuredContent is nil", t.Name)
559+
}
560+
561+
if err := t.ensureOutputSchemaValidator(); err != nil {
562+
return err
563+
}
564+
565+
return t.validateStructuredOutput(result)
566+
}
567+
490568
// MarshalJSON implements the json.Marshaler interface for Tool.
491569
// It handles marshaling either InputSchema or RawInputSchema based on which is set.
492570
func (t Tool) MarshalJSON() ([]byte, error) {
493571
// Create a map to build the JSON structure
494-
m := make(map[string]any, 3)
572+
m := make(map[string]any, 6)
495573

496-
// Add the name and description
574+
// Add the name and title
497575
m["name"] = t.Name
576+
if t.Title != "" {
577+
m["title"] = t.Title
578+
}
498579
if t.Description != "" {
499580
m["description"] = t.Description
500581
}
@@ -510,29 +591,34 @@ func (t Tool) MarshalJSON() ([]byte, error) {
510591
m["inputSchema"] = t.InputSchema
511592
}
512593

594+
// Add output schema if defined
595+
if t.HasOutputSchema() {
596+
m["outputSchema"] = t.OutputSchema
597+
}
598+
513599
m["annotations"] = t.Annotations
514600

515601
return json.Marshal(m)
516602
}
517603

518-
type ToolInputSchema struct {
604+
type ToolSchema struct {
519605
Type string `json:"type"`
520606
Properties map[string]any `json:"properties,omitempty"`
521607
Required []string `json:"required,omitempty"`
522608
}
523609

524-
// MarshalJSON implements the json.Marshaler interface for ToolInputSchema.
525-
func (tis ToolInputSchema) MarshalJSON() ([]byte, error) {
610+
// MarshalJSON implements the json.Marshaler interface for ToolSchema.
611+
func (schema ToolSchema) MarshalJSON() ([]byte, error) {
526612
m := make(map[string]any)
527-
m["type"] = tis.Type
613+
m["type"] = schema.Type
528614

529615
// Marshal Properties to '{}' rather than `nil` when its length equals zero
530-
if tis.Properties != nil {
531-
m["properties"] = tis.Properties
616+
if schema.Properties != nil {
617+
m["properties"] = schema.Properties
532618
}
533619

534-
if len(tis.Required) > 0 {
535-
m["required"] = tis.Required
620+
if len(schema.Required) > 0 {
621+
m["required"] = schema.Required
536622
}
537623

538624
return json.Marshal(m)
@@ -569,11 +655,17 @@ type PropertyOption func(map[string]any)
569655
func NewTool(name string, opts ...ToolOption) Tool {
570656
tool := Tool{
571657
Name: name,
572-
InputSchema: ToolInputSchema{
658+
InputSchema: ToolSchema{
573659
Type: "object",
574660
Properties: make(map[string]any),
575661
Required: nil, // Will be omitted from JSON if empty
576662
},
663+
OutputSchema: ToolSchema{
664+
Type: "",
665+
Properties: make(map[string]any),
666+
Required: nil, // Will be omitted from JSON if empty
667+
},
668+
compiledOutputSchema: nil,
577669
Annotations: ToolAnnotation{
578670
Title: "",
579671
ReadOnlyHint: ToBoolPtr(false),
@@ -615,6 +707,14 @@ func WithDescription(description string) ToolOption {
615707
}
616708
}
617709

710+
// WithTitle sets the direct title field of the Tool.
711+
// This title takes precedence over the annotation title when displaying the tool.
712+
func WithTitle(title string) ToolOption {
713+
return func(t *Tool) {
714+
t.Title = title
715+
}
716+
}
717+
618718
// WithToolAnnotation adds optional hints about the Tool.
619719
func WithToolAnnotation(annotation ToolAnnotation) ToolOption {
620720
return func(t *Tool) {
@@ -1076,3 +1176,151 @@ func WithBooleanItems(opts ...PropertyOption) PropertyOption {
10761176
schema["items"] = itemSchema
10771177
}
10781178
}
1179+
1180+
//
1181+
// Output Schema Configuration Functions
1182+
//
1183+
1184+
// WithOutputSchema sets the output schema for the Tool.
1185+
// This allows the tool to define the structure of its return data.
1186+
func WithOutputSchema(schema ToolSchema) ToolOption {
1187+
return func(t *Tool) {
1188+
t.OutputSchema = schema
1189+
}
1190+
}
1191+
1192+
// WithOutputBoolean adds a boolean property to the tool's output schema.
1193+
// It accepts property options to configure the boolean property's behavior and constraints.
1194+
func WithOutputBoolean(name string, opts ...PropertyOption) ToolOption {
1195+
return func(t *Tool) {
1196+
// Initialize output schema if not set
1197+
if t.OutputSchema.Type == "" {
1198+
t.OutputSchema.Type = "object"
1199+
}
1200+
1201+
schema := map[string]any{
1202+
"type": "boolean",
1203+
}
1204+
1205+
for _, opt := range opts {
1206+
opt(schema)
1207+
}
1208+
1209+
// Remove required from property schema and add to OutputSchema.required
1210+
if required, ok := schema["required"].(bool); ok && required {
1211+
delete(schema, "required")
1212+
t.OutputSchema.Required = append(t.OutputSchema.Required, name)
1213+
}
1214+
1215+
t.OutputSchema.Properties[name] = schema
1216+
}
1217+
}
1218+
1219+
// WithOutputNumber adds a number property to the tool's output schema.
1220+
// It accepts property options to configure the number property's behavior and constraints.
1221+
func WithOutputNumber(name string, opts ...PropertyOption) ToolOption {
1222+
return func(t *Tool) {
1223+
// Initialize output schema if not set
1224+
if t.OutputSchema.Type == "" {
1225+
t.OutputSchema.Type = "object"
1226+
}
1227+
1228+
schema := map[string]any{
1229+
"type": "number",
1230+
}
1231+
1232+
for _, opt := range opts {
1233+
opt(schema)
1234+
}
1235+
1236+
// Remove required from property schema and add to OutputSchema.required
1237+
if required, ok := schema["required"].(bool); ok && required {
1238+
delete(schema, "required")
1239+
t.OutputSchema.Required = append(t.OutputSchema.Required, name)
1240+
}
1241+
1242+
t.OutputSchema.Properties[name] = schema
1243+
}
1244+
}
1245+
1246+
// WithOutputString adds a string property to the tool's output schema.
1247+
// It accepts property options to configure the string property's behavior and constraints.
1248+
func WithOutputString(name string, opts ...PropertyOption) ToolOption {
1249+
return func(t *Tool) {
1250+
// Initialize output schema if not set
1251+
if t.OutputSchema.Type == "" {
1252+
t.OutputSchema.Type = "object"
1253+
}
1254+
1255+
schema := map[string]any{
1256+
"type": "string",
1257+
}
1258+
1259+
for _, opt := range opts {
1260+
opt(schema)
1261+
}
1262+
1263+
// Remove required from property schema and add to OutputSchema.required
1264+
if required, ok := schema["required"].(bool); ok && required {
1265+
delete(schema, "required")
1266+
t.OutputSchema.Required = append(t.OutputSchema.Required, name)
1267+
}
1268+
1269+
t.OutputSchema.Properties[name] = schema
1270+
}
1271+
}
1272+
1273+
// WithOutputObject adds an object property to the tool's output schema.
1274+
// It accepts property options to configure the object property's behavior and constraints.
1275+
func WithOutputObject(name string, opts ...PropertyOption) ToolOption {
1276+
return func(t *Tool) {
1277+
// Initialize output schema if not set
1278+
if t.OutputSchema.Type == "" {
1279+
t.OutputSchema.Type = "object"
1280+
}
1281+
1282+
schema := map[string]any{
1283+
"type": "object",
1284+
"properties": map[string]any{},
1285+
}
1286+
1287+
for _, opt := range opts {
1288+
opt(schema)
1289+
}
1290+
1291+
// Remove required from property schema and add to OutputSchema.required
1292+
if required, ok := schema["required"].(bool); ok && required {
1293+
delete(schema, "required")
1294+
t.OutputSchema.Required = append(t.OutputSchema.Required, name)
1295+
}
1296+
1297+
t.OutputSchema.Properties[name] = schema
1298+
}
1299+
}
1300+
1301+
// WithOutputArray adds an array property to the tool's output schema.
1302+
// It accepts property options to configure the array property's behavior and constraints.
1303+
func WithOutputArray(name string, opts ...PropertyOption) ToolOption {
1304+
return func(t *Tool) {
1305+
// Initialize output schema if not set
1306+
if t.OutputSchema.Type == "" {
1307+
t.OutputSchema.Type = "object"
1308+
}
1309+
1310+
schema := map[string]any{
1311+
"type": "array",
1312+
}
1313+
1314+
for _, opt := range opts {
1315+
opt(schema)
1316+
}
1317+
1318+
// Remove required from property schema and add to OutputSchema.required
1319+
if required, ok := schema["required"].(bool); ok && required {
1320+
delete(schema, "required")
1321+
t.OutputSchema.Required = append(t.OutputSchema.Required, name)
1322+
}
1323+
1324+
t.OutputSchema.Properties[name] = schema
1325+
}
1326+
}

0 commit comments

Comments
 (0)