@@ -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"`
+}
@@ -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
+}