From 120c486b16bcd79ab20b07bac98809fb27a7d061 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 14 Jun 2026 21:27:49 -0600 Subject: [PATCH] mcp: expose product group IDs as array --- 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(-) diff --git a/features/cooked-mcp/tools.feature.yaml b/features/cooked-mcp/tools.feature.yaml index 00974aaee582796cb2b68476cd5fb7281cfaee62..a063f124b3ffa5d72b778cc771ce2c7bc54ce59f 100644 --- a/features/cooked-mcp/tools.feature.yaml +++ b/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. diff --git a/go.mod b/go.mod index 680f537e315c3c7188ace0b6b58e2dfbe28ce1b4..8fa609de02b4c6e9fd808334823ba2513748fe44 100644 --- a/go.mod +++ b/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 diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 886c378f6dc02dc05eed007e292261ac059c8307..e0399ab139f85724cf7b1d37078497b88c5532c5 100644 --- a/internal/mcp/server.go +++ b/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 { diff --git a/internal/mcp/server_wording_test.go b/internal/mcp/server_wording_test.go index 5755df7c80f784ac1fbcdc877ac5e53572770bfe..f6e96c1d7482dc3923ece9df063d8bc1b30cb1ea 100644 --- a/internal/mcp/server_wording_test.go +++ b/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")