diff --git a/internal/mcp/server.go b/internal/mcp/server.go index be1b3e51d19c055f65e5a8c8568c32b75e5439da..51dbd9411d0c6ae06f56a39e04d32dba9f00286e 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -16,11 +16,12 @@ import ( ) const ( - serverName = "Cooked" - readToolName = "read" - previewRecipeTextToolName = "preview_recipe_text" - saveRecipeToolName = "save_recipe" - deleteRecipeToolName = "delete_recipe" + serverName = "Cooked" + readToolName = "read" + previewRecipeTextToolName = "preview_recipe_text" + saveRecipeToolName = "save_recipe" + deleteRecipeToolName = "delete_recipe" + changeShoppingListToolName = "change_shopping_list" ) // Backend provides the Cooked operations exposed as MCP tools. @@ -34,6 +35,7 @@ type Backend interface { SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error) UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error DeleteRecipe(ctx context.Context, recipeID string) error + ClearShoppingList(ctx context.Context) error } // Server exposes Cooked as an MCP server. @@ -50,6 +52,7 @@ func NewServer(backend Backend, version string) *Server { sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool) sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool) sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool) + sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool) return server } @@ -325,6 +328,33 @@ func (s *Server) callDeleteRecipeTool( }, output, nil } +func (s *Server) callChangeShoppingListTool( + ctx context.Context, + _ *sdk.CallToolRequest, + arguments ChangeShoppingListArguments, +) (*sdk.CallToolResult, ChangeShoppingListOutput, error) { + action := strings.TrimSpace(arguments.Action) + switch action { + case "clear": + if err := s.backend.ClearShoppingList(ctx); err != nil { + return nil, ChangeShoppingListOutput{}, err + } + + output := ChangeShoppingListOutput{Cleared: true} + + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}}, + }, output, nil + case "": + return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required") + default: + return nil, ChangeShoppingListOutput{}, fmt.Errorf( + "unsupported change_shopping_list action %q in this slice", + action, + ) + } +} + func readTool() *sdk.Tool { openWorld := true return &sdk.Tool{ @@ -381,6 +411,21 @@ func deleteRecipeTool() *sdk.Tool { } } +func changeShoppingListTool() *sdk.Tool { + destructive := true + openWorld := true + return &sdk.Tool{ + Name: changeShoppingListToolName, + Title: "Change shopping list", + Description: "Change the Cooked shopping list. This slice supports only action clear, which is destructive and deletes all shopping-list items. Other actions are reserved for later slices.", + Annotations: &sdk.ToolAnnotations{ + Title: "Change shopping list", + DestructiveHint: &destructive, + OpenWorldHint: &openWorld, + }, + } +} + func normalizeRecipePage(page, limit int) (int, int) { if page < 1 { page = 1 @@ -591,3 +636,13 @@ type DeleteRecipeArguments struct { type DeleteRecipeOutput struct { RecipeID string `json:"recipe_id"` } + +// ChangeShoppingListArguments contains change_shopping_list tool arguments. +type ChangeShoppingListArguments struct { + Action string `json:"action" jsonschema:"Shopping-list action. Supported now: clear."` +} + +// ChangeShoppingListOutput is the structured output for change_shopping_list. +type ChangeShoppingListOutput struct { + Cleared bool `json:"cleared,omitempty"` +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 3e3605105bcf6e1b6e937eaf63047254e43472cb..010f2173a43df79b3bf8002c96576731867cad7d 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -569,35 +569,107 @@ func TestDeleteRecipeToolACIDToolsAnnotations2To5MarksDestructiveOpenWorldTool(t } } +func TestCallToolACIDShoppingListClear1And2ClearsShoppingList(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, output, err := server.callChangeShoppingListTool( + context.Background(), + nil, + ChangeShoppingListArguments{Action: " clear "}, + ) + if err != nil { + t.Fatalf("callChangeShoppingListTool() error = %v", err) + } + + if backend.clearShoppingListCalls != 1 { + t.Fatalf("clear calls = %d, want 1", backend.clearShoppingListCalls) + } + if !output.Cleared { + t.Fatalf("clear output cleared = %v, want true", output.Cleared) + } +} + +func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked(t *testing.T) { + tests := []struct { + name string + arguments ChangeShoppingListArguments + }{ + {name: "empty", arguments: ChangeShoppingListArguments{Action: " "}}, + {name: "unknown", arguments: ChangeShoppingListArguments{Action: "add"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, _, err := server.callChangeShoppingListTool(context.Background(), nil, tt.arguments) + if err == nil { + t.Fatal("callChangeShoppingListTool() error = nil, want action error") + } + if backend.clearShoppingListCalls != 0 { + t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls) + } + }) + } +} + +func TestChangeShoppingListToolACIDToolsAnnotations2And4To6MarksDestructiveOpenWorldTool(t *testing.T) { + tool := changeShoppingListTool() + if tool.Name != changeShoppingListToolName { + t.Fatalf("tool name = %q, want %q", tool.Name, changeShoppingListToolName) + } + if tool.Annotations == nil { + t.Fatal("tool annotations nil") + } + if tool.Annotations.ReadOnlyHint { + t.Fatal("change_shopping_list read-only hint = true, want false") + } + if tool.Annotations.DestructiveHint == nil || !*tool.Annotations.DestructiveHint { + t.Fatalf("change_shopping_list destructive hint = %#v, want true", tool.Annotations.DestructiveHint) + } + if tool.Annotations.OpenWorldHint == nil || !*tool.Annotations.OpenWorldHint { + t.Fatalf("change_shopping_list open-world hint = %#v, want true", tool.Annotations.OpenWorldHint) + } + if tool.Annotations.Title == "" { + t.Fatal("change_shopping_list annotation title empty") + } + if !strings.Contains(tool.Description, "clear") || !strings.Contains(tool.Description, "destructive") { + t.Fatalf("change_shopping_list description = %q, want clear/destructive warning", tool.Description) + } +} + type fakeBackend struct { - shoppingList cooked.ShoppingList - recipes []cooked.RecipeCard - searchRecipes []cooked.RecipeCard - recipeMetadata cooked.RecipeMetadata - recipeContent cooked.RecipeContent - preview cooked.RecipeTextPreview - saveRecipeID string - page int - limit int - searchQuery string - searchPage int - metadataRecipeID string - contentRecipeID string - previewTitle string - previewText string - saveTitle string - saveMarkdown string - savePortions int - updateRecipeID string - updateMarkdown string - updatePortions int - deleteRecipeID string - metadataCalls int - contentCalls int - previewCalls int - saveCalls int - updateCalls int - deleteCalls int + shoppingList cooked.ShoppingList + recipes []cooked.RecipeCard + searchRecipes []cooked.RecipeCard + recipeMetadata cooked.RecipeMetadata + recipeContent cooked.RecipeContent + preview cooked.RecipeTextPreview + saveRecipeID string + page int + limit int + searchQuery string + searchPage int + metadataRecipeID string + contentRecipeID string + previewTitle string + previewText string + saveTitle string + saveMarkdown string + savePortions int + updateRecipeID string + updateMarkdown string + updatePortions int + deleteRecipeID string + metadataCalls int + contentCalls int + previewCalls int + saveCalls int + updateCalls int + deleteCalls int + clearShoppingListCalls int } func (f *fakeBackend) ReadShoppingList(context.Context) (cooked.ShoppingList, error) { @@ -667,3 +739,9 @@ func (f *fakeBackend) DeleteRecipe(_ context.Context, recipeID string) error { return nil } + +func (f *fakeBackend) ClearShoppingList(context.Context) error { + f.clearShoppingListCalls++ + + return nil +}