mcp: document recipe markdown examples

Amolith created

Change summary

features/cooked-mcp/tools.feature.yaml      |  5 +
internal/mcp/example_recipes.go             | 55 +++++++++++++++++++++++
internal/mcp/server.go                      | 18 ++----
internal/mcp/server_example_recipes_test.go | 46 +++++++++++++++++++
internal/mcp/server_wording_test.go         | 37 +++++++++++++++
internal/mcp/tool_result.go                 | 14 +++++
6 files changed, 161 insertions(+), 14 deletions(-)

Detailed changes

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.

internal/mcp/example_recipes.go 🔗

@@ -0,0 +1,55 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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.`

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."`
 }
 

internal/mcp/server_example_recipes_test.go 🔗

@@ -0,0 +1,46 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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,
+		)
+	}
+}

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)
+		}
+	}
+}

internal/mcp/tool_result.go 🔗

@@ -0,0 +1,14 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// 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,
+	}
+}