@@ -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 {
@@ -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")