diff --git a/cmd/cooked-mcp/main.go b/cmd/cooked-mcp/main.go index c95a5f46cd51a6d9fa14177d6c505b1caa868db4..e19310db4e9a95da8ef47c9c62c23c3416dd595d 100644 --- a/cmd/cooked-mcp/main.go +++ b/cmd/cooked-mcp/main.go @@ -41,8 +41,8 @@ func run() error { transport := flag.String("transport", "stdio", "MCP transport: stdio") flag.Parse() - if *transport != "stdio" { - return fmt.Errorf("unsupported transport %q in this slice", *transport) + if err := validateTransport(*transport); err != nil { + return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -69,6 +69,14 @@ func run() error { return nil } +func validateTransport(transport string) error { + if transport == "stdio" { + return nil + } + + return fmt.Errorf("unsupported transport %q (supported: stdio)", transport) +} + func builtVersion() string { info, ok := debug.ReadBuildInfo() if !ok { diff --git a/cmd/cooked-mcp/main_test.go b/cmd/cooked-mcp/main_test.go index 0d66f5ebd672d84505680b6c0e7d36ceeaa14bf6..515d6953ba95b7197dc6509dc5ffd1a564464bc2 100644 --- a/cmd/cooked-mcp/main_test.go +++ b/cmd/cooked-mcp/main_test.go @@ -9,6 +9,24 @@ import ( "testing" ) +// server.TRANSPORT.4 +func TestValidateTransportACIDServerTransport4AcceptsStdio(t *testing.T) { + if err := validateTransport("stdio"); err != nil { + t.Fatalf("validateTransport() error = %v, want nil", err) + } +} + +// server.TRANSPORT.4 +func TestValidateTransportACIDServerTransport4RejectsUnknownTransport(t *testing.T) { + err := validateTransport("bogus") + if err == nil { + t.Fatal("validateTransport() error = nil, want unsupported transport error") + } + if err.Error() != `unsupported transport "bogus" (supported: stdio)` { + t.Fatalf("validateTransport() error = %q", err.Error()) + } +} + func TestVersionFromBuildInfoPrefersModuleVersion(t *testing.T) { got := versionFromBuildInfo(&debug.BuildInfo{Main: debug.Module{Version: "1.2.3"}}) if got != "1.2.3" { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 095ab95f4e87c1da62c173c5478ae126e344e1af..2187dbd74a307d56de27ebea6f8c15501387066b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -95,7 +95,10 @@ func (s *Server) callReadTool( case "recipes": return s.callRecipesReadTool(ctx, arguments) default: - return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target) + return nil, ReadOutput{}, fmt.Errorf( + "unsupported read target %q (supported: shopping_list, recipes, recipe)", + arguments.Target, + ) } } @@ -235,7 +238,10 @@ func (s *Server) callSaveRecipeTool( case "": return nil, SaveRecipeOutput{}, fmt.Errorf("source is required") default: - return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source) + return nil, SaveRecipeOutput{}, fmt.Errorf( + "unsupported save_recipe source %q (supported: raw_text, prepared, url, draft, existing)", + source, + ) } } @@ -448,7 +454,7 @@ func (s *Server) callChangeShoppingListTool( return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required") default: return nil, ChangeShoppingListOutput{}, fmt.Errorf( - "unsupported change_shopping_list action %q in this slice", + "unsupported change_shopping_list action %q (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)", action, ) } @@ -682,7 +688,7 @@ func saveRecipeTool() *sdk.Tool { return &sdk.Tool{ Name: saveRecipeToolName, Title: "Save recipe", - Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, source url with url, source draft with draft_id, markdown, and portions, and source existing with recipe_id, markdown, and portions.", + Description: "Save a recipe from raw text, prepared markdown, a URL import, a reviewed URL-import draft, or an existing recipe update. URL imports may return a draft_id for review instead of saving immediately.", Annotations: &sdk.ToolAnnotations{ Title: "Save recipe", OpenWorldHint: &openWorld, @@ -711,7 +717,7 @@ func changeShoppingListTool() *sdk.Tool { return &sdk.Tool{ Name: changeShoppingListToolName, Title: "Change shopping list", - Description: "Change the Cooked shopping list. This slice supports action add with newline-separated ingredients and optional recipe_id, action remove, replace_selection, add_selection, or remove_selection with product_group_ids, action update_item with product_group_id and optional name, quantity, aisle_id, or selected, and action clear. Remove and clear are destructive. Other actions are reserved for later slices.", + 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. Remove and clear are destructive.", Annotations: &sdk.ToolAnnotations{ Title: "Change shopping list", DestructiveHint: &destructive, @@ -1016,7 +1022,7 @@ type PreviewRecipeTextOutput struct { // SaveRecipeArguments contains save_recipe tool arguments. type SaveRecipeArguments struct { - Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, url, draft, and existing."` + Source string `json:"source" jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."` RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."` DraftID string `json:"draft_id,omitempty" jsonschema:"Draft ID for reviewed URL import draft saves."` Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."` @@ -1047,7 +1053,7 @@ type DeleteRecipeOutput struct { // ChangeShoppingListArguments contains change_shopping_list tool arguments. type ChangeShoppingListArguments struct { - Action string `json:"action" jsonschema:"Shopping-list action. Supported now: add, remove, replace_selection, add_selection, remove_selection, update_item, and clear."` + 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."` 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."` diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index dde6ab0a4cad4ddfc0e2fd0ff377701c60df94c6..43bba776f8f64d301b211e7b458e2304d1f257d1 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -360,19 +360,6 @@ func TestCallToolACIDRecipesSave2_1To2_3RequiresPreparedFields(t *testing.T) { } } -func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) { - backend := &fakeBackend{} - server := NewServer(backend, "test") - - _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "unsupported"}) - if err == nil { - t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error") - } - if backend.saveCalls != 0 || backend.draftSaveCalls != 0 { - t.Fatalf("save calls = prepared %d draft %d, want none", backend.saveCalls, backend.draftSaveCalls) - } -} - func TestCallToolACIDRecipesSave1And11SavesRawTextRecipe(t *testing.T) { backend := &fakeBackend{ preview: cooked.RecipeTextPreview{ @@ -595,9 +582,14 @@ func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked tests := []struct { name string arguments ChangeShoppingListArguments + wantError string }{ - {name: "empty", arguments: ChangeShoppingListArguments{Action: " "}}, - {name: "unknown", arguments: ChangeShoppingListArguments{Action: "dance"}}, + {name: "empty", arguments: ChangeShoppingListArguments{Action: " "}, wantError: "action is required"}, + { + name: "unknown", + arguments: ChangeShoppingListArguments{Action: "dance"}, + wantError: `unsupported change_shopping_list action "dance" (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)`, + }, } for _, tt := range tests { @@ -609,6 +601,9 @@ func TestCallToolACIDShoppingListActions2RejectsUnknownActionBeforeCallingCooked if err == nil { t.Fatal("callChangeShoppingListTool() error = nil, want action error") } + if err.Error() != tt.wantError { + t.Fatalf("callChangeShoppingListTool() error = %q", err.Error()) + } if backend.clearShoppingListCalls != 0 { t.Fatalf("clear calls = %d, want none", backend.clearShoppingListCalls) } diff --git a/internal/mcp/server_wording_test.go b/internal/mcp/server_wording_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f684e9a4dee3847f53dbb4d59a9095c93cfc8654 --- /dev/null +++ b/internal/mcp/server_wording_test.go @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import ( + "context" + "strings" + "testing" +) + +// tools.READ_TOOL.1 tools.SCHEMA.4 tools.RESPONSES.6 +func TestCallToolACIDToolsReadTool1RejectsUnsupportedTargetBeforeCallingCooked(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "meal_plan"}) + if err == nil { + t.Fatal("CallReadTool() error = nil, want unsupported target error") + } + if err.Error() != `unsupported read target "meal_plan" (supported: shopping_list, recipes, recipe)` { + t.Fatalf("CallReadTool() error = %q", err.Error()) + } + if backend.readShoppingListCalls != 0 || backend.metadataCalls != 0 || backend.contentCalls != 0 { + t.Fatalf( + "backend calls = shopping list %d metadata %d content %d, want none", + backend.readShoppingListCalls, + backend.metadataCalls, + backend.contentCalls, + ) + } +} + +// recipes.SAVE.10 tools.SCHEMA.4 tools.RESPONSES.6 +func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "unsupported"}) + if err == nil { + t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error") + } + if err.Error() != `unsupported save_recipe source "unsupported" (supported: raw_text, prepared, url, draft, existing)` { + t.Fatalf("callSaveRecipeTool() error = %q", err.Error()) + } + if backend.saveCalls != 0 || backend.draftSaveCalls != 0 { + t.Fatalf("save calls = prepared %d draft %d, want none", backend.saveCalls, backend.draftSaveCalls) + } +} + +// tools.SCHEMA.3 tools.SCHEMA.5 tools.SAVE_RECIPE_TOOL.1 tools.CHANGE_SHOPPING_LIST_TOOL.1 +func TestToolDescriptionsACIDToolsSchema3And5DescribeCurrentLimits(t *testing.T) { + save := saveRecipeTool() + for _, want := range []string{ + "raw text", + "prepared markdown", + "URL import", + "reviewed URL-import draft", + "existing recipe update", + "draft_id", + } { + if !strings.Contains(save.Description, want) { + t.Fatalf("save_recipe description missing %q: %q", want, save.Description) + } + } + + shopping := changeShoppingListTool() + for _, want := range []string{ + "add", + "update_item", + "replace_selection", + "add_selection", + "remove_selection", + "remove", + "clear", + "Remove and clear are destructive", + } { + if !strings.Contains(shopping.Description, want) { + t.Fatalf("change_shopping_list description missing %q: %q", want, shopping.Description) + } + } +}