Skip to content

Commit a077d27

Browse files
authored
feat: add type-safe array items helper functions (#396)
- Add WithStringItems(), WithNumberItems(), WithBooleanItems() for type-safe array schema construction - Add WithStringEnumItems() for string enum arrays with cleaner API - Improve Items() documentation with examples and usage guidance - Add comprehensive compatibility tests ensuring new APIs generate identical schemas
1 parent 25775b4 commit a077d27

File tree

2 files changed

+289
-1
lines changed

2 files changed

+289
-1
lines changed

mcp/tools.go

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -945,7 +945,20 @@ func PropertyNames(schema map[string]any) PropertyOption {
945945
}
946946
}
947947

948-
// Items defines the schema for array items
948+
// Items defines the schema for array items.
949+
// Accepts any schema definition for maximum flexibility.
950+
//
951+
// Example:
952+
//
953+
// Items(map[string]any{
954+
// "type": "object",
955+
// "properties": map[string]any{
956+
// "name": map[string]any{"type": "string"},
957+
// "age": map[string]any{"type": "number"},
958+
// },
959+
// })
960+
//
961+
// For simple types, use ItemsString(), ItemsNumber(), ItemsBoolean() instead.
949962
func Items(schema any) PropertyOption {
950963
return func(schemaMap map[string]any) {
951964
schemaMap["items"] = schema
@@ -972,3 +985,94 @@ func UniqueItems(unique bool) PropertyOption {
972985
schema["uniqueItems"] = unique
973986
}
974987
}
988+
989+
// WithStringItems configures an array's items to be of type string.
990+
//
991+
// Supported options: Description(), DefaultString(), Enum(), MaxLength(), MinLength(), Pattern()
992+
// Note: Options like Required() are not valid for item schemas and will be ignored.
993+
//
994+
// Examples:
995+
//
996+
// mcp.WithArray("tags", mcp.WithStringItems())
997+
// mcp.WithArray("colors", mcp.WithStringItems(mcp.Enum("red", "green", "blue")))
998+
// mcp.WithArray("names", mcp.WithStringItems(mcp.MinLength(1), mcp.MaxLength(50)))
999+
//
1000+
// Limitations: Only supports simple string arrays. Use Items() for complex objects.
1001+
func WithStringItems(opts ...PropertyOption) PropertyOption {
1002+
return func(schema map[string]any) {
1003+
itemSchema := map[string]any{
1004+
"type": "string",
1005+
}
1006+
1007+
for _, opt := range opts {
1008+
opt(itemSchema)
1009+
}
1010+
1011+
schema["items"] = itemSchema
1012+
}
1013+
}
1014+
1015+
// WithStringEnumItems configures an array's items to be of type string with a specified enum.
1016+
// Example:
1017+
//
1018+
// mcp.WithArray("priority", mcp.WithStringEnumItems([]string{"low", "medium", "high"}))
1019+
//
1020+
// Limitations: Only supports string enums. Use WithStringItems(Enum(...)) for more flexibility.
1021+
func WithStringEnumItems(values []string) PropertyOption {
1022+
return func(schema map[string]any) {
1023+
schema["items"] = map[string]any{
1024+
"type": "string",
1025+
"enum": values,
1026+
}
1027+
}
1028+
}
1029+
1030+
// WithNumberItems configures an array's items to be of type number.
1031+
//
1032+
// Supported options: Description(), DefaultNumber(), Min(), Max(), MultipleOf()
1033+
// Note: Options like Required() are not valid for item schemas and will be ignored.
1034+
//
1035+
// Examples:
1036+
//
1037+
// mcp.WithArray("scores", mcp.WithNumberItems(mcp.Min(0), mcp.Max(100)))
1038+
// mcp.WithArray("prices", mcp.WithNumberItems(mcp.Min(0)))
1039+
//
1040+
// Limitations: Only supports simple number arrays. Use Items() for complex objects.
1041+
func WithNumberItems(opts ...PropertyOption) PropertyOption {
1042+
return func(schema map[string]any) {
1043+
itemSchema := map[string]any{
1044+
"type": "number",
1045+
}
1046+
1047+
for _, opt := range opts {
1048+
opt(itemSchema)
1049+
}
1050+
1051+
schema["items"] = itemSchema
1052+
}
1053+
}
1054+
1055+
// WithBooleanItems configures an array's items to be of type boolean.
1056+
//
1057+
// Supported options: Description(), DefaultBool()
1058+
// Note: Options like Required() are not valid for item schemas and will be ignored.
1059+
//
1060+
// Examples:
1061+
//
1062+
// mcp.WithArray("flags", mcp.WithBooleanItems())
1063+
// mcp.WithArray("permissions", mcp.WithBooleanItems(mcp.Description("User permissions")))
1064+
//
1065+
// Limitations: Only supports simple boolean arrays. Use Items() for complex objects.
1066+
func WithBooleanItems(opts ...PropertyOption) PropertyOption {
1067+
return func(schema map[string]any) {
1068+
itemSchema := map[string]any{
1069+
"type": "boolean",
1070+
}
1071+
1072+
for _, opt := range opts {
1073+
opt(itemSchema)
1074+
}
1075+
1076+
schema["items"] = itemSchema
1077+
}
1078+
}

mcp/tools_test.go

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,3 +528,187 @@ func TestFlexibleArgumentsJSONMarshalUnmarshal(t *testing.T) {
528528
assert.Equal(t, "value1", args["key1"])
529529
assert.Equal(t, float64(123), args["key2"]) // JSON numbers are unmarshaled as float64
530530
}
531+
532+
// TestNewItemsAPICompatibility tests that the new Items API functions
533+
// generate the same schema as the original Items() function with manual schema objects
534+
func TestNewItemsAPICompatibility(t *testing.T) {
535+
tests := []struct {
536+
name string
537+
oldTool Tool
538+
newTool Tool
539+
}{
540+
{
541+
name: "WithStringItems basic",
542+
oldTool: NewTool("old-string-array",
543+
WithDescription("Tool with string array using old API"),
544+
WithArray("items",
545+
Description("List of string items"),
546+
Items(map[string]any{
547+
"type": "string",
548+
}),
549+
),
550+
),
551+
newTool: NewTool("new-string-array",
552+
WithDescription("Tool with string array using new API"),
553+
WithArray("items",
554+
Description("List of string items"),
555+
WithStringItems(),
556+
),
557+
),
558+
},
559+
{
560+
name: "WithStringEnumItems",
561+
oldTool: NewTool("old-enum-array",
562+
WithDescription("Tool with enum array using old API"),
563+
WithArray("status",
564+
Description("Filter by status"),
565+
Items(map[string]any{
566+
"type": "string",
567+
"enum": []string{"active", "inactive", "pending"},
568+
}),
569+
),
570+
),
571+
newTool: NewTool("new-enum-array",
572+
WithDescription("Tool with enum array using new API"),
573+
WithArray("status",
574+
Description("Filter by status"),
575+
WithStringEnumItems([]string{"active", "inactive", "pending"}),
576+
),
577+
),
578+
},
579+
{
580+
name: "WithStringItems with options",
581+
oldTool: NewTool("old-string-with-opts",
582+
WithDescription("Tool with string array with options using old API"),
583+
WithArray("names",
584+
Description("List of names"),
585+
Items(map[string]any{
586+
"type": "string",
587+
"minLength": 1,
588+
"maxLength": 50,
589+
}),
590+
),
591+
),
592+
newTool: NewTool("new-string-with-opts",
593+
WithDescription("Tool with string array with options using new API"),
594+
WithArray("names",
595+
Description("List of names"),
596+
WithStringItems(MinLength(1), MaxLength(50)),
597+
),
598+
),
599+
},
600+
{
601+
name: "WithNumberItems basic",
602+
oldTool: NewTool("old-number-array",
603+
WithDescription("Tool with number array using old API"),
604+
WithArray("scores",
605+
Description("List of scores"),
606+
Items(map[string]any{
607+
"type": "number",
608+
}),
609+
),
610+
),
611+
newTool: NewTool("new-number-array",
612+
WithDescription("Tool with number array using new API"),
613+
WithArray("scores",
614+
Description("List of scores"),
615+
WithNumberItems(),
616+
),
617+
),
618+
},
619+
{
620+
name: "WithNumberItems with constraints",
621+
oldTool: NewTool("old-number-with-constraints",
622+
WithDescription("Tool with constrained number array using old API"),
623+
WithArray("ratings",
624+
Description("List of ratings"),
625+
Items(map[string]any{
626+
"type": "number",
627+
"minimum": 0.0,
628+
"maximum": 10.0,
629+
}),
630+
),
631+
),
632+
newTool: NewTool("new-number-with-constraints",
633+
WithDescription("Tool with constrained number array using new API"),
634+
WithArray("ratings",
635+
Description("List of ratings"),
636+
WithNumberItems(Min(0), Max(10)),
637+
),
638+
),
639+
},
640+
{
641+
name: "WithBooleanItems basic",
642+
oldTool: NewTool("old-boolean-array",
643+
WithDescription("Tool with boolean array using old API"),
644+
WithArray("flags",
645+
Description("List of feature flags"),
646+
Items(map[string]any{
647+
"type": "boolean",
648+
}),
649+
),
650+
),
651+
newTool: NewTool("new-boolean-array",
652+
WithDescription("Tool with boolean array using new API"),
653+
WithArray("flags",
654+
Description("List of feature flags"),
655+
WithBooleanItems(),
656+
),
657+
),
658+
},
659+
}
660+
661+
for _, tt := range tests {
662+
t.Run(tt.name, func(t *testing.T) {
663+
// Marshal both tools to JSON
664+
oldData, err := json.Marshal(tt.oldTool)
665+
assert.NoError(t, err)
666+
667+
newData, err := json.Marshal(tt.newTool)
668+
assert.NoError(t, err)
669+
670+
// Unmarshal to maps for comparison
671+
var oldResult, newResult map[string]any
672+
err = json.Unmarshal(oldData, &oldResult)
673+
assert.NoError(t, err)
674+
675+
err = json.Unmarshal(newData, &newResult)
676+
assert.NoError(t, err)
677+
678+
// Compare the inputSchema properties (ignoring tool names and descriptions)
679+
oldSchema := oldResult["inputSchema"].(map[string]any)
680+
newSchema := newResult["inputSchema"].(map[string]any)
681+
682+
oldProperties := oldSchema["properties"].(map[string]any)
683+
newProperties := newSchema["properties"].(map[string]any)
684+
685+
// Get the array property (should be the only one in these tests)
686+
var oldArrayProp, newArrayProp map[string]any
687+
for _, prop := range oldProperties {
688+
if propMap, ok := prop.(map[string]any); ok && propMap["type"] == "array" {
689+
oldArrayProp = propMap
690+
break
691+
}
692+
}
693+
for _, prop := range newProperties {
694+
if propMap, ok := prop.(map[string]any); ok && propMap["type"] == "array" {
695+
newArrayProp = propMap
696+
break
697+
}
698+
}
699+
700+
assert.NotNil(t, oldArrayProp, "Old tool should have array property")
701+
assert.NotNil(t, newArrayProp, "New tool should have array property")
702+
703+
// Compare the items schema - this is the critical part
704+
oldItems := oldArrayProp["items"]
705+
newItems := newArrayProp["items"]
706+
707+
assert.Equal(t, oldItems, newItems, "Items schema should be identical between old and new API")
708+
709+
// Also compare other array properties like description
710+
assert.Equal(t, oldArrayProp["description"], newArrayProp["description"], "Array descriptions should match")
711+
assert.Equal(t, oldArrayProp["type"], newArrayProp["type"], "Array types should match")
712+
})
713+
}
714+
}

0 commit comments

Comments
 (0)