diff --git a/features/cooked-mcp/tools.feature.yaml b/features/cooked-mcp/tools.feature.yaml index 0ac801f8a9824ec626f9a73dc8cc90e68cc948fe..2ea56f3fad200a0b7de5fe0cc2fc9b85aa85771a 100644 --- a/features/cooked-mcp/tools.feature.yaml +++ b/features/cooked-mcp/tools.feature.yaml @@ -12,15 +12,16 @@ components: 5: The server exposes a shopping-list mutation tool named change_shopping_list. 6: The server does not expose one MCP tool per Cooked API endpoint. 7: The server does not expose MCP resources. - 8: The read tool supports recipe lists, single recipes, and the shopping list through explicit target values. + 8: The read tool supports recipe lists, single recipes, recipe format examples, and the shopping list through explicit target values. READ_TOOL: requirements: - 1: The read tool target values are recipes, recipe, and shopping_list. + 1: The read tool target values are recipes, recipe, example_recipes, and shopping_list. 2: The read tool accepts recipe_id when target is recipe. 3: The read tool requires recipe_id when target is recipe. 4: The read tool accepts query when target is recipes. 5: The read tool accepts page with default 1 and minimum 1. 6: The read tool accepts limit with default 10, minimum 1, and maximum 30. + 7: The read tool returns Cooked recipe markdown examples when target is example_recipes. PREVIEW_RECIPE_TEXT_TOOL: requirements: 1: The preview_recipe_text tool requires title. diff --git a/internal/mcp/example_recipes.go b/internal/mcp/example_recipes.go new file mode 100644 index 0000000000000000000000000000000000000000..d3f29cc017451c89fb585a1dbfaa3813c3ff593b --- /dev/null +++ b/internal/mcp/example_recipes.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import sdk "github.com/modelcontextprotocol/go-sdk/mcp" + +func callExampleRecipesReadTool() (*sdk.CallToolResult, ReadOutput, error) { + output := ReadOutput{ExampleRecipes: exampleRecipesMarkdown} + + return newToolResult(exampleRecipesMarkdown, output), output, nil +} + +const exampleRecipesMarkdown = `Cooked recipe markdown examples + +Title and portions are separate fields. Recipe markdown starts with ingredient bullets, then uses # headings for instruction sections. Ordered-list items are steps. Paragraphs are notes and may appear between steps. + +Example: Shokupan w/ yudane improver + +- 45g strong bread flour (for yudane) +- 45g boiling water (for yudane) +- 285g strong bread flour (for bread) +- 4.5g instant dry yeast or 5.4g active dry or 13.5g fresh +- 6g salt +- 15g sugar +- 30g soft butter +- 172.5g milk (or alternative, or water) + +# Yudane dough improver + +1. Get the flour-for-yudane in a bowl, then pour the boiling-water-for-yudane in and thoroughly mix. It'll still be very sticky; the goal is incorporating all the flour. +2. Cover immediately and leave to cool completely. Can be used once cool, or refrigerated and used the next day. + +# Shokupan + +1. Combine milk, butter, yeast, and yudane from previous section in a large bowl. +2. Add salt and sugar, then mix well. +3. Add the flour-for-bread, then mix to a dough. + +This is a note between steps. + +4. Tip the dough out on the table and knead for 5-10 minutes. + +Example: Simple toast + +- 1 slice shokupan +- 1 tbsp brown sugar +- 1 tbsp butter + +# Toast + +1. Toast the bread until golden. +2. Stir the brown sugar and butter together. +3. Spread on the toast while warm.` diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 740b1a5650b6790c106e2c547c51a0eb509e05a5..fe02e62fba046f4a4bfa3abf21ead7f1dc35e9dc 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -96,9 +96,11 @@ func (s *Server) callReadTool( return s.callRecipeReadTool(ctx, arguments.RecipeID) case "recipes": return s.callRecipesReadTool(ctx, arguments) + case "example_recipes": + return callExampleRecipesReadTool() default: return nil, ReadOutput{}, fmt.Errorf( - "unsupported read target %q (supported: shopping_list, recipes, recipe)", + "unsupported read target %q (supported: shopping_list, recipes, recipe, example_recipes)", arguments.Target, ) } @@ -652,19 +654,12 @@ func (s *Server) updateShoppingListProductGroup( return newToolResult(text, output), output, nil } -func newToolResult[Output any](text string, output Output) *sdk.CallToolResult { - return &sdk.CallToolResult{ - Content: []sdk.Content{&sdk.TextContent{Text: text}}, - StructuredContent: output, - } -} - func readTool() *sdk.Tool { openWorld := true return &sdk.Tool{ Name: readToolName, Title: "Read Cooked data", - Description: "Read Cooked data. Use target shopping_list for the current shopping list, target recipes to list or search saved recipe IDs and titles, or target recipe with recipe_id to read a single recipe. Recipe results omit images and thumbnails.", + Description: "Read Cooked data. Use target shopping_list for the current shopping list, recipes to list or search saved recipe IDs and titles, recipe with recipe_id to read a single recipe, or example_recipes for Cooked recipe markdown examples. Recipe results omit images and thumbnails.", Annotations: &sdk.ToolAnnotations{ Title: "Read Cooked data", ReadOnlyHint: true, @@ -1088,7 +1083,7 @@ func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary { // ReadArguments contains read tool arguments. type ReadArguments struct { - Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."` + Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, recipe, or example_recipes."` RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."` Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."` Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."` @@ -1101,6 +1096,7 @@ type ReadOutput struct { ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"` Recipes []RecipeSummary `json:"recipes,omitempty"` Recipe *RecipeDetail `json:"recipe,omitempty"` + ExampleRecipes string `json:"example_recipes,omitempty"` } // RecipeSummary is the MCP-safe saved recipe summary. @@ -1140,7 +1136,7 @@ type SaveRecipeArguments struct { Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."` Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."` URL string `json:"url,omitempty" jsonschema:"Recipe URL for url imports."` - Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."` + Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates. Read target example_recipes first for Cooked's markdown dialect and examples."` Portions float64 `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."` } diff --git a/internal/mcp/server_example_recipes_test.go b/internal/mcp/server_example_recipes_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7696a98e9757a22713a3511a667644e28c42226f --- /dev/null +++ b/internal/mcp/server_example_recipes_test.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import ( + "context" + "strings" + "testing" +) + +// tools.READ_TOOL.7 tools.SCHEMA.3 +func TestCallToolACIDToolsReadTool7ReadsExampleRecipes(t *testing.T) { + backend := &fakeBackend{} + server := NewServer(backend, "test") + + result, output, err := server.callReadTool(context.Background(), nil, ReadArguments{Target: "example_recipes"}) + if err != nil { + t.Fatalf("callReadTool() error = %v", err) + } + + text := requireTextContent(t, result) + for _, want := range []string{ + "Title and portions are separate fields", + "# Yudane dough improver", + "This is a note between steps", + "# Toast", + } { + if !strings.Contains(text, want) { + t.Fatalf("example_recipes text missing %q: %q", want, text) + } + if !strings.Contains(output.ExampleRecipes, want) { + t.Fatalf("example_recipes output missing %q: %q", want, output.ExampleRecipes) + } + } + if backend.readShoppingListCalls != 0 || backend.page != 0 || backend.limit != 0 || backend.metadataCalls != 0 { + t.Fatalf( + "backend calls = shopping list %d list recipes page/limit %d/%d metadata %d, want none", + backend.readShoppingListCalls, + backend.page, + backend.limit, + backend.metadataCalls, + ) + } +} diff --git a/internal/mcp/server_wording_test.go b/internal/mcp/server_wording_test.go index 5ac257247421717853d92713dbd555b24b47a443..5755df7c80f784ac1fbcdc877ac5e53572770bfe 100644 --- a/internal/mcp/server_wording_test.go +++ b/internal/mcp/server_wording_test.go @@ -6,6 +6,7 @@ package mcp import ( "context" + "reflect" "strings" "testing" ) @@ -19,7 +20,7 @@ func TestCallToolACIDToolsReadTool1RejectsUnsupportedTargetBeforeCallingCooked(t 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)` { + if err.Error() != `unsupported read target "meal_plan" (supported: shopping_list, recipes, recipe, example_recipes)` { t.Fatalf("CallReadTool() error = %q", err.Error()) } if backend.readShoppingListCalls != 0 || backend.metadataCalls != 0 || backend.contentCalls != 0 { @@ -51,6 +52,19 @@ func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t // tools.SCHEMA.3 tools.SCHEMA.5 tools.SAVE_RECIPE_TOOL.1 tools.CHANGE_SHOPPING_LIST_TOOL.1 func TestToolDescriptionsACIDToolsSchema3And5DescribeCurrentLimits(t *testing.T) { + read := readTool() + for _, want := range []string{ + "shopping_list", + "recipes", + "recipe with recipe_id", + "example_recipes", + "Recipe results omit images and thumbnails", + } { + if !strings.Contains(read.Description, want) { + t.Fatalf("read description missing %q: %q", want, read.Description) + } + } + save := saveRecipeTool() for _, want := range []string{ "raw text", @@ -83,3 +97,24 @@ func TestToolDescriptionsACIDToolsSchema3And5DescribeCurrentLimits(t *testing.T) } } } + +// 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") + if !ok { + t.Fatal("ReadArguments.Target field missing") + } + if !strings.Contains(string(readTarget.Tag), "example_recipes") { + t.Fatalf("ReadArguments.Target tag = %q, want example_recipes", readTarget.Tag) + } + + saveMarkdown, ok := reflect.TypeFor[SaveRecipeArguments]().FieldByName("Markdown") + if !ok { + t.Fatal("SaveRecipeArguments.Markdown field missing") + } + for _, want := range []string{"example_recipes", "Cooked's markdown dialect"} { + if !strings.Contains(string(saveMarkdown.Tag), want) { + t.Fatalf("SaveRecipeArguments.Markdown tag missing %q: %q", want, saveMarkdown.Tag) + } + } +} diff --git a/internal/mcp/tool_result.go b/internal/mcp/tool_result.go new file mode 100644 index 0000000000000000000000000000000000000000..9c3f467c7dc68689986d68dfdb17a51a5126f28f --- /dev/null +++ b/internal/mcp/tool_result.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Amolith +// +// SPDX-License-Identifier: LicenseRef-MutuaL-1.2 + +package mcp + +import sdk "github.com/modelcontextprotocol/go-sdk/mcp" + +func newToolResult[Output any](text string, output Output) *sdk.CallToolResult { + return &sdk.CallToolResult{ + Content: []sdk.Content{&sdk.TextContent{Text: text}}, + StructuredContent: output, + } +}