mcp: expose product group IDs as array

Amolith created

Change summary

features/cooked-mcp/tools.feature.yaml |  1 
go.mod                                 |  2 
internal/mcp/server.go                 | 42 +++++++++++++++++++++------
internal/mcp/server_wording_test.go    | 36 ++++++++++++++++++++++++
4 files changed, 70 insertions(+), 11 deletions(-)

Detailed changes

features/cooked-mcp/tools.feature.yaml 🔗

@@ -83,3 +83,4 @@ constraints:
       3: Tool descriptions state important behavioural limits and follow-up tools.
       4: Tool schemas reject unsupported read targets, recipe save sources, and shopping-list actions before calling Cooked.
       5: The shopping-list mutation tool description distinguishes destructive remove and clear actions from non-destructive actions.
+      6: The shopping-list mutation tool exposes product_group_ids as an optional array of strings that does not accept null.

go.mod 🔗

@@ -4,12 +4,12 @@ go 1.26.4
 
 require (
 	github.com/BurntSushi/toml v1.6.0
+	github.com/google/jsonschema-go v0.4.3
 	github.com/modelcontextprotocol/go-sdk v1.6.1
 	golang.org/x/net v0.55.0
 )
 
 require (
-	github.com/google/jsonschema-go v0.4.3 // indirect
 	github.com/segmentio/asm v1.1.3 // indirect
 	github.com/segmentio/encoding v0.5.4 // indirect
 	github.com/yosida95/uritemplate/v3 v3.0.2 // indirect

internal/mcp/server.go 🔗

@@ -8,9 +8,11 @@ package mcp
 import (
 	"context"
 	"fmt"
+	"reflect"
 	"strconv"
 	"strings"
 
+	"github.com/google/jsonschema-go/jsonschema"
 	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
 
 	"git.secluded.site/cooked-mcp/internal/cooked"
@@ -694,6 +696,7 @@ func changeShoppingListTool() *sdk.Tool {
 		Name:        changeShoppingListToolName,
 		Title:       "Change shopping list",
 		Description: "Change the Cooked shopping list. Supports add, update_item, replace_selection, add_selection, remove_selection, remove, and clear. Add accepts newline-separated ingredients and optional recipe_id; put quantities before names, such as `1 milk`. Simple read-output lines like `milk — 1` are normalized. Remove and clear are destructive.",
+		InputSchema: changeShoppingListInputSchema(),
 		Annotations: &sdk.ToolAnnotations{
 			Title:           "Change shopping list",
 			DestructiveHint: &destructive,
@@ -702,6 +705,22 @@ func changeShoppingListTool() *sdk.Tool {
 	}
 }
 
+func changeShoppingListInputSchema() *jsonschema.Schema {
+	schema, err := jsonschema.For[ChangeShoppingListArguments](&jsonschema.ForOptions{
+		TypeSchemas: map[reflect.Type]*jsonschema.Schema{
+			reflect.TypeFor[ProductGroupIDs](): {
+				Type:  "array",
+				Items: &jsonschema.Schema{Type: "string"},
+			},
+		},
+	})
+	if err != nil {
+		panic(fmt.Errorf("generate change_shopping_list input schema: %w", err))
+	}
+
+	return schema
+}
+
 func normalizeRecipePage(page, limit int) (int, int) {
 	if page < 1 {
 		page = 1
@@ -1138,16 +1157,19 @@ type DeleteRecipeOutput struct {
 
 // ChangeShoppingListArguments contains change_shopping_list tool arguments.
 type ChangeShoppingListArguments struct {
-	Action          string   `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
-	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
-	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
-	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
-	ProductGroupID  string   `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
-	Name            *string  `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
-	Quantity        *string  `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
-	AisleID         *string  `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
-	Selected        *bool    `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
-}
+	Action          string          `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
+	Ingredients     string          `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
+	RecipeID        string          `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
+	ProductGroupIDs ProductGroupIDs `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
+	ProductGroupID  string          `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
+	Name            *string         `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
+	Quantity        *string         `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
+	AisleID         *string         `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
+	Selected        *bool           `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
+}
+
+// ProductGroupIDs are Cooked shopping-list product group IDs used by bulk actions.
+type ProductGroupIDs []string
 
 // ChangeShoppingListOutput is the structured output for change_shopping_list.
 type ChangeShoppingListOutput struct {

internal/mcp/server_wording_test.go 🔗

@@ -7,6 +7,7 @@ package mcp
 import (
 	"context"
 	"reflect"
+	"slices"
 	"strings"
 	"testing"
 )
@@ -98,6 +99,41 @@ func TestToolDescriptionsACIDToolsSchema3And5DescribeCurrentLimits(t *testing.T)
 	}
 }
 
+// tools.SCHEMA.6
+func TestChangeShoppingListToolACIDToolsSchema6ExposesProductGroupIDsAsOptionalNonNullableStringArray(t *testing.T) {
+	schema := changeShoppingListInputSchema()
+	property, ok := schema.Properties["product_group_ids"]
+	if !ok {
+		t.Fatal("product_group_ids schema missing")
+	}
+	if slices.Contains(schema.Required, "product_group_ids") {
+		t.Fatalf("required fields = %#v, want product_group_ids optional", schema.Required)
+	}
+	if property.Type != "array" {
+		t.Fatalf("product_group_ids type = %q, want array", property.Type)
+	}
+	if len(property.Types) != 0 {
+		t.Fatalf("product_group_ids types = %#v, want none", property.Types)
+	}
+	if len(property.AnyOf) != 0 {
+		t.Fatalf("product_group_ids anyOf = %#v, want none", property.AnyOf)
+	}
+	if property.Items == nil || property.Items.Type != "string" {
+		t.Fatalf("product_group_ids items = %#v, want string items", property.Items)
+	}
+
+	resolved, err := schema.Resolve(nil)
+	if err != nil {
+		t.Fatalf("Resolve() error = %v", err)
+	}
+	if err := resolved.Validate(map[string]any{"action": "clear"}); err != nil {
+		t.Fatalf("Validate() with omitted product_group_ids error = %v", err)
+	}
+	if err := resolved.Validate(map[string]any{"action": "clear", "product_group_ids": nil}); err == nil {
+		t.Fatal("Validate() with null product_group_ids error = nil, want rejection")
+	}
+}
+
 // tools.READ_TOOL.1 tools.SAVE_RECIPE_TOOL.3 tools.SAVE_RECIPE_TOOL.5 tools.SAVE_RECIPE_TOOL.6
 func TestArgumentDescriptionsACIDToolsReadTool1AndSaveRecipeTool3And5And6PointToExampleRecipes(t *testing.T) {
 	readTarget, ok := reflect.TypeFor[ReadArguments]().FieldByName("Target")