server.go

   1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
   2//
   3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
   4
   5// Package mcp implements the MCP surface for cooked-mcp.
   6package mcp
   7
   8import (
   9	"context"
  10	"fmt"
  11	"reflect"
  12	"strconv"
  13	"strings"
  14
  15	"github.com/google/jsonschema-go/jsonschema"
  16	sdk "github.com/modelcontextprotocol/go-sdk/mcp"
  17
  18	"git.secluded.site/cooked-mcp/internal/cooked"
  19)
  20
  21const (
  22	serverName                 = "Cooked"
  23	maxShoppingListTextItems   = 30
  24	readToolName               = "read"
  25	extractIntoRecipeToolName  = "extract_into_recipe"
  26	saveRecipeToolName         = "save_recipe"
  27	deleteRecipeToolName       = "delete_recipe"
  28	changeShoppingListToolName = "change_shopping_list"
  29)
  30
  31// Backend provides the Cooked operations exposed as MCP tools.
  32type Backend interface {
  33	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
  34	ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
  35	SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
  36	ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
  37	ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
  38	PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
  39	SavePreparedRecipe(ctx context.Context, title, markdown string, portions float64) (string, error)
  40	SaveRecipeDraft(ctx context.Context, draftID, markdown string, portions float64) (string, error)
  41	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions float64) error
  42	ImportRecipeURL(ctx context.Context, recipeURL string) (cooked.RecipeURLImport, error)
  43	DeleteRecipe(ctx context.Context, recipeID string) error
  44	ClearShoppingList(ctx context.Context) error
  45	AddShoppingListIngredients(ctx context.Context, ingredients, recipeID string) (cooked.AddShoppingListResult, error)
  46	RemoveShoppingListProductGroups(ctx context.Context, productGroupIDs []string) error
  47	ReplaceShoppingListSelection(ctx context.Context, productGroupIDs []string) error
  48	UpdateShoppingListProductGroup(
  49		ctx context.Context,
  50		productGroupID string,
  51		update cooked.ShoppingListProductGroupUpdate,
  52	) error
  53}
  54
  55// Server exposes Cooked as an MCP server.
  56type Server struct {
  57	backend Backend
  58	sdk     *sdk.Server
  59}
  60
  61// NewServer returns an MCP server for a Cooked backend.
  62func NewServer(backend Backend, version string) *Server {
  63	server := &Server{backend: backend}
  64	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
  65	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
  66	sdk.AddTool(server.sdk, extractIntoRecipeTool(), server.callExtractIntoRecipeTool)
  67	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
  68	sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
  69	sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
  70
  71	return server
  72}
  73
  74// RunStdio serves MCP over the official SDK stdio transport.
  75func (s *Server) RunStdio(ctx context.Context) error {
  76	return s.sdk.Run(ctx, &sdk.StdioTransport{})
  77}
  78
  79// CallReadTool invokes the read tool without asserting MCP wire presentation.
  80func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
  81	_, output, err := s.callReadTool(ctx, nil, arguments)
  82	if err != nil {
  83		return ReadOutput{}, err
  84	}
  85
  86	return output, nil
  87}
  88
  89func (s *Server) callReadTool(
  90	ctx context.Context,
  91	_ *sdk.CallToolRequest,
  92	arguments ReadArguments,
  93) (*sdk.CallToolResult, ReadOutput, error) {
  94	switch arguments.Target {
  95	case "shopping_list":
  96		return s.callShoppingListReadTool(ctx)
  97	case "recipe":
  98		return s.callRecipeReadTool(ctx, arguments.RecipeID)
  99	case "recipes":
 100		return s.callRecipesReadTool(ctx, arguments)
 101	case "example_recipes":
 102		return callExampleRecipesReadTool()
 103	default:
 104		return nil, ReadOutput{}, fmt.Errorf(
 105			"unsupported read target %q (supported: shopping_list, recipes, recipe, example_recipes)",
 106			arguments.Target,
 107		)
 108	}
 109}
 110
 111func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
 112	shoppingList, err := s.backend.ReadShoppingList(ctx)
 113	if err != nil {
 114		return nil, ReadOutput{}, err
 115	}
 116
 117	output := ReadOutput{
 118		Aisles:              shoppingList.Aisles,
 119		ShoppingListRecipes: shoppingList.Recipes,
 120	}
 121
 122	return newToolResult(formatShoppingList(shoppingList), output), output, nil
 123}
 124
 125func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
 126	recipeID := strings.TrimSpace(rawRecipeID)
 127	if recipeID == "" {
 128		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
 129	}
 130
 131	metadata, content, err := s.readRecipeParts(ctx, recipeID)
 132	if err != nil {
 133		return nil, ReadOutput{}, err
 134	}
 135
 136	recipe := RecipeDetail{
 137		ID:       recipeID,
 138		Title:    metadata.Title,
 139		Owner:    metadata.Owner,
 140		Content:  content.Content,
 141		Portions: content.Portions,
 142	}
 143	output := ReadOutput{Recipe: &recipe}
 144
 145	return newToolResult(formatRecipe(recipe), output), output, nil
 146}
 147
 148func (s *Server) readRecipeParts(
 149	ctx context.Context,
 150	recipeID string,
 151) (cooked.RecipeMetadata, cooked.RecipeContent, error) {
 152	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
 153	if err != nil {
 154		return cooked.RecipeMetadata{}, cooked.RecipeContent{}, err
 155	}
 156	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
 157	if err != nil {
 158		return cooked.RecipeMetadata{}, cooked.RecipeContent{}, err
 159	}
 160
 161	return metadata, content, nil
 162}
 163
 164func (s *Server) callRecipesReadTool(
 165	ctx context.Context,
 166	arguments ReadArguments,
 167) (*sdk.CallToolResult, ReadOutput, error) {
 168	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
 169	query := strings.TrimSpace(arguments.Query)
 170	recipes, paginationHint, err := s.readRecipeCards(ctx, query, page, limit)
 171	if err != nil {
 172		return nil, ReadOutput{}, err
 173	}
 174
 175	output := ReadOutput{Recipes: recipeSummaries(recipes)}
 176	text := formatRecipes(recipes)
 177	if paginationHint != "" {
 178		text += "\n\n" + paginationHint
 179	}
 180
 181	return newToolResult(text, output), output, nil
 182}
 183
 184func (s *Server) readRecipeCards(
 185	ctx context.Context,
 186	query string,
 187	page, limit int,
 188) ([]cooked.RecipeCard, string, error) {
 189	if query == "" {
 190		recipes, err := s.backend.ListRecipes(ctx, page, limit)
 191		if err != nil {
 192			return nil, "", err
 193		}
 194		if len(recipes) == limit {
 195			return recipes, formatRecipeNextPageHint(query, page+1, limit), nil
 196		}
 197
 198		return recipes, "", nil
 199	}
 200
 201	recipes, err := s.backend.SearchRecipes(ctx, query, page)
 202	if err != nil {
 203		return nil, "", err
 204	}
 205	if len(recipes) == limit {
 206		return recipes, formatRecipeNextPageHint(query, page+1, limit), nil
 207	}
 208	if len(recipes) > limit {
 209		available := len(recipes)
 210		recipes = recipes[:limit]
 211		return recipes, formatRecipeSearchLimitHint(query, page, page+1, available), nil
 212	}
 213
 214	return recipes, "", nil
 215}
 216
 217func (s *Server) callExtractIntoRecipeTool(
 218	ctx context.Context,
 219	_ *sdk.CallToolRequest,
 220	arguments ExtractIntoRecipeArguments,
 221) (*sdk.CallToolResult, ExtractIntoRecipeOutput, error) {
 222	title := strings.TrimSpace(arguments.Title)
 223	if title == "" {
 224		return nil, ExtractIntoRecipeOutput{}, fmt.Errorf("title is required")
 225	}
 226	if strings.TrimSpace(arguments.Text) == "" {
 227		return nil, ExtractIntoRecipeOutput{}, fmt.Errorf("text is required")
 228	}
 229
 230	extraction, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
 231	if err != nil {
 232		return nil, ExtractIntoRecipeOutput{}, err
 233	}
 234
 235	output := ExtractIntoRecipeOutput{
 236		Title:    extraction.Title,
 237		Markdown: extraction.Markdown,
 238		Portions: extraction.Portions,
 239	}
 240
 241	return newToolResult(formatRecipeTextPreview(output), output), output, nil
 242}
 243
 244func (s *Server) callSaveRecipeTool(
 245	ctx context.Context,
 246	_ *sdk.CallToolRequest,
 247	arguments SaveRecipeArguments,
 248) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 249	source := strings.TrimSpace(arguments.Source)
 250	switch source {
 251	case "raw_text":
 252		return s.callRawTextSaveRecipeTool(ctx, arguments)
 253	case "prepared":
 254		return s.callPreparedSaveRecipeTool(ctx, arguments)
 255	case "url":
 256		return s.callURLSaveRecipeTool(ctx, arguments)
 257	case "draft":
 258		return s.callDraftSaveRecipeTool(ctx, arguments)
 259	case "existing":
 260		return s.callExistingSaveRecipeTool(ctx, arguments)
 261	case "":
 262		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
 263	default:
 264		return nil, SaveRecipeOutput{}, fmt.Errorf(
 265			"unsupported save_recipe source %q (supported: raw_text, prepared, url, draft, existing)",
 266			source,
 267		)
 268	}
 269}
 270
 271func (s *Server) callRawTextSaveRecipeTool(
 272	ctx context.Context,
 273	arguments SaveRecipeArguments,
 274) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 275	title := strings.TrimSpace(arguments.Title)
 276	if title == "" {
 277		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
 278	}
 279	if strings.TrimSpace(arguments.Text) == "" {
 280		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
 281	}
 282
 283	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
 284	if err != nil {
 285		return nil, SaveRecipeOutput{}, err
 286	}
 287	saveTitle := strings.TrimSpace(preview.Title)
 288	if saveTitle == "" {
 289		saveTitle = title
 290	}
 291	if strings.TrimSpace(preview.Markdown) == "" {
 292		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
 293	}
 294	if preview.Portions < 1 {
 295		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
 296	}
 297
 298	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
 299}
 300
 301func (s *Server) callPreparedSaveRecipeTool(
 302	ctx context.Context,
 303	arguments SaveRecipeArguments,
 304) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 305	title := strings.TrimSpace(arguments.Title)
 306	if title == "" {
 307		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
 308	}
 309	if err := validateSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions); err != nil {
 310		return nil, SaveRecipeOutput{}, err
 311	}
 312
 313	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
 314}
 315
 316func (s *Server) callURLSaveRecipeTool(
 317	ctx context.Context,
 318	arguments SaveRecipeArguments,
 319) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 320	recipeURL := strings.TrimSpace(arguments.URL)
 321	if recipeURL == "" {
 322		return nil, SaveRecipeOutput{}, fmt.Errorf("url is required when source is url")
 323	}
 324
 325	imported, err := s.backend.ImportRecipeURL(ctx, recipeURL)
 326	if err != nil {
 327		return nil, SaveRecipeOutput{}, err
 328	}
 329
 330	recipeID := strings.TrimSpace(imported.RecipeID)
 331	if recipeID != "" {
 332		return newRecipeSaveResult(recipeID)
 333	}
 334
 335	draftID := strings.TrimSpace(imported.DraftID)
 336	if draftID == "" {
 337		return nil, SaveRecipeOutput{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
 338	}
 339
 340	metadata, content, err := s.readRecipeParts(ctx, draftID)
 341	if err != nil {
 342		return nil, SaveRecipeOutput{}, err
 343	}
 344
 345	output := SaveRecipeOutput{
 346		DraftID:  draftID,
 347		Title:    metadata.Title,
 348		Markdown: content.Content,
 349		Portions: content.Portions,
 350	}
 351
 352	return newToolResult(formatRecipeURLImportDraft(output), output), output, nil
 353}
 354
 355func (s *Server) callExistingSaveRecipeTool(
 356	ctx context.Context,
 357	arguments SaveRecipeArguments,
 358) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 359	recipeID := strings.TrimSpace(arguments.RecipeID)
 360	if recipeID == "" {
 361		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
 362	}
 363	if err := validateSaveRecipeContent("existing", arguments.Markdown, arguments.Portions); err != nil {
 364		return nil, SaveRecipeOutput{}, err
 365	}
 366
 367	if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
 368		return nil, SaveRecipeOutput{}, err
 369	}
 370
 371	return newRecipeSaveResult(recipeID)
 372}
 373
 374func validateSaveRecipeContent(source, markdown string, portions float64) error {
 375	if strings.TrimSpace(markdown) == "" {
 376		return fmt.Errorf("markdown is required when source is %s", source)
 377	}
 378	if portions < 1 {
 379		return fmt.Errorf("portions is required when source is %s", source)
 380	}
 381
 382	return nil
 383}
 384
 385func (s *Server) savePreparedRecipe(
 386	ctx context.Context,
 387	title, markdown string,
 388	portions float64,
 389) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 390	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
 391	if err != nil {
 392		return nil, SaveRecipeOutput{}, err
 393	}
 394	if strings.TrimSpace(recipeID) == "" {
 395		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
 396	}
 397
 398	return newRecipeSaveResult(recipeID)
 399}
 400
 401func newRecipeSaveResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 402	output := SaveRecipeOutput{RecipeID: recipeID}
 403
 404	return newToolResult(formatRecipeSave(output), output), output, nil
 405}
 406
 407func (s *Server) callDeleteRecipeTool(
 408	ctx context.Context,
 409	_ *sdk.CallToolRequest,
 410	arguments DeleteRecipeArguments,
 411) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
 412	recipeID := strings.TrimSpace(arguments.RecipeID)
 413	if recipeID == "" {
 414		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
 415	}
 416
 417	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
 418		return nil, DeleteRecipeOutput{}, err
 419	}
 420
 421	output := DeleteRecipeOutput{RecipeID: recipeID}
 422
 423	return newToolResult(formatRecipeDelete(output), output), output, nil
 424}
 425
 426func (s *Server) callChangeShoppingListTool(
 427	ctx context.Context,
 428	_ *sdk.CallToolRequest,
 429	arguments ChangeShoppingListArguments,
 430) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 431	action := strings.TrimSpace(arguments.Action)
 432	switch action {
 433	case "add":
 434		return s.addShoppingListIngredients(ctx, arguments)
 435	case "clear":
 436		return s.clearShoppingList(ctx)
 437	case "remove":
 438		return s.removeShoppingListProductGroups(ctx, arguments)
 439	case "replace_selection":
 440		return s.replaceShoppingListSelection(ctx, arguments)
 441	case "add_selection":
 442		return s.addShoppingListSelection(ctx, arguments)
 443	case "remove_selection":
 444		return s.removeShoppingListSelection(ctx, arguments)
 445	case "update_item":
 446		return s.updateShoppingListProductGroup(ctx, arguments)
 447	case "":
 448		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
 449	default:
 450		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 451			"unsupported change_shopping_list action %q (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)",
 452			action,
 453		)
 454	}
 455}
 456
 457func (s *Server) addShoppingListIngredients(
 458	ctx context.Context,
 459	arguments ChangeShoppingListArguments,
 460) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 461	ingredients := normalizeShoppingListAddIngredients(arguments.Ingredients)
 462	if ingredients == "" {
 463		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
 464	}
 465
 466	result, err := s.backend.AddShoppingListIngredients(
 467		ctx,
 468		ingredients,
 469		strings.TrimSpace(arguments.RecipeID),
 470	)
 471	if err != nil {
 472		return nil, ChangeShoppingListOutput{}, err
 473	}
 474
 475	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
 476	text := fmt.Sprintf("Added %d shopping-list ingredients.", result.AddedCount)
 477
 478	return newToolResult(text, output), output, nil
 479}
 480
 481func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 482	if err := s.backend.ClearShoppingList(ctx); err != nil {
 483		return nil, ChangeShoppingListOutput{}, err
 484	}
 485
 486	output := ChangeShoppingListOutput{Cleared: true}
 487
 488	return newToolResult("Cleared shopping list.", output), output, nil
 489}
 490
 491func (s *Server) removeShoppingListProductGroups(
 492	ctx context.Context,
 493	arguments ChangeShoppingListArguments,
 494) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 495	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 496	if len(ids) == 0 {
 497		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
 498	}
 499
 500	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
 501		return nil, ChangeShoppingListOutput{}, err
 502	}
 503
 504	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
 505	text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
 506
 507	return newToolResult(text, output), output, nil
 508}
 509
 510func (s *Server) replaceShoppingListSelection(
 511	ctx context.Context,
 512	arguments ChangeShoppingListArguments,
 513) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 514	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 515	if len(ids) == 0 {
 516		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 517			"product_group_ids is required when action is replace_selection",
 518		)
 519	}
 520
 521	return s.setShoppingListSelection(ctx, ids)
 522}
 523
 524func (s *Server) addShoppingListSelection(
 525	ctx context.Context,
 526	arguments ChangeShoppingListArguments,
 527) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 528	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 529	if len(ids) == 0 {
 530		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 531			"product_group_ids is required when action is add_selection",
 532		)
 533	}
 534
 535	shoppingList, err := s.backend.ReadShoppingList(ctx)
 536	if err != nil {
 537		return nil, ChangeShoppingListOutput{}, err
 538	}
 539
 540	return s.setShoppingListSelection(
 541		ctx,
 542		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 543	)
 544}
 545
 546func (s *Server) removeShoppingListSelection(
 547	ctx context.Context,
 548	arguments ChangeShoppingListArguments,
 549) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 550	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 551	if len(ids) == 0 {
 552		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 553			"product_group_ids is required when action is remove_selection",
 554		)
 555	}
 556
 557	shoppingList, err := s.backend.ReadShoppingList(ctx)
 558	if err != nil {
 559		return nil, ChangeShoppingListOutput{}, err
 560	}
 561
 562	return s.setShoppingListSelection(
 563		ctx,
 564		subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 565	)
 566}
 567
 568func (s *Server) setShoppingListSelection(
 569	ctx context.Context,
 570	ids []string,
 571) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 572	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
 573		return nil, ChangeShoppingListOutput{}, err
 574	}
 575
 576	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
 577	text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
 578
 579	return newToolResult(text, output), output, nil
 580}
 581
 582func (s *Server) updateShoppingListProductGroup(
 583	ctx context.Context,
 584	arguments ChangeShoppingListArguments,
 585) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 586	productGroupID := strings.TrimSpace(arguments.ProductGroupID)
 587	if productGroupID == "" {
 588		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 589			"product_group_id is required when action is update_item",
 590		)
 591	}
 592
 593	shoppingList, err := s.backend.ReadShoppingList(ctx)
 594	if err != nil {
 595		return nil, ChangeShoppingListOutput{}, err
 596	}
 597
 598	product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
 599	if !ok {
 600		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 601			"product_group_id %q was not found in the current shopping list",
 602			productGroupID,
 603		)
 604	}
 605
 606	update := cooked.ShoppingListProductGroupUpdate{
 607		Name:     product.Name,
 608		Quantity: product.Quantity,
 609		AisleID:  aisleID,
 610		Selected: product.Selected,
 611	}
 612	if arguments.Name != nil {
 613		update.Name = *arguments.Name
 614	}
 615	if arguments.Quantity != nil {
 616		update.Quantity = *arguments.Quantity
 617	}
 618	if arguments.AisleID != nil {
 619		update.AisleID = strings.TrimSpace(*arguments.AisleID)
 620	}
 621	if arguments.Selected != nil {
 622		update.Selected = *arguments.Selected
 623	}
 624
 625	if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
 626		return nil, ChangeShoppingListOutput{}, err
 627	}
 628
 629	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
 630	text := fmt.Sprintf("Updated shopping-list product group (id: %s).", productGroupID)
 631
 632	return newToolResult(text, output), output, nil
 633}
 634
 635func readTool() *sdk.Tool {
 636	openWorld := true
 637	return &sdk.Tool{
 638		Name:        readToolName,
 639		Title:       "Read Cooked data",
 640		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.",
 641		Annotations: &sdk.ToolAnnotations{
 642			Title:         "Read Cooked data",
 643			ReadOnlyHint:  true,
 644			OpenWorldHint: &openWorld,
 645		},
 646	}
 647}
 648
 649func extractIntoRecipeTool() *sdk.Tool {
 650	openWorld := true
 651	return &sdk.Tool{
 652		Name:        extractIntoRecipeToolName,
 653		Title:       "Extract raw recipe text into Cooked recipe",
 654		Description: "Extract raw recipe text into Cooked recipe markdown and portions without saving or updating a recipe. Extraction rewrites ingredients and steps and infers portions; review the result before saving. If the extracted result is incorrect, save the correct version with source=prepared and your own markdown and portions.",
 655		Annotations: &sdk.ToolAnnotations{
 656			Title:         "Extract raw recipe text into Cooked recipe",
 657			ReadOnlyHint:  true,
 658			OpenWorldHint: &openWorld,
 659		},
 660	}
 661}
 662
 663func saveRecipeTool() *sdk.Tool {
 664	openWorld := true
 665	return &sdk.Tool{
 666		Name:        saveRecipeToolName,
 667		Title:       "Save recipe",
 668		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.",
 669		Annotations: &sdk.ToolAnnotations{
 670			Title:         "Save recipe",
 671			OpenWorldHint: &openWorld,
 672		},
 673	}
 674}
 675
 676func deleteRecipeTool() *sdk.Tool {
 677	destructive := true
 678	openWorld := true
 679	return &sdk.Tool{
 680		Name:        deleteRecipeToolName,
 681		Title:       "Delete recipe",
 682		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
 683		Annotations: &sdk.ToolAnnotations{
 684			Title:           "Delete recipe",
 685			DestructiveHint: &destructive,
 686			OpenWorldHint:   &openWorld,
 687		},
 688	}
 689}
 690
 691func changeShoppingListTool() *sdk.Tool {
 692	destructive := true
 693	openWorld := true
 694	return &sdk.Tool{
 695		Name:        changeShoppingListToolName,
 696		Title:       "Change shopping list",
 697		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; put quantities before names, such as `1 milk`. Simple read-output lines like `milk — 1` are normalized. Remove and clear are destructive.",
 698		InputSchema: changeShoppingListInputSchema(),
 699		Annotations: &sdk.ToolAnnotations{
 700			Title:           "Change shopping list",
 701			DestructiveHint: &destructive,
 702			OpenWorldHint:   &openWorld,
 703		},
 704	}
 705}
 706
 707func changeShoppingListInputSchema() *jsonschema.Schema {
 708	schema, err := jsonschema.For[ChangeShoppingListArguments](&jsonschema.ForOptions{
 709		TypeSchemas: map[reflect.Type]*jsonschema.Schema{
 710			reflect.TypeFor[ProductGroupIDs](): {
 711				Type:  "array",
 712				Items: &jsonschema.Schema{Type: "string"},
 713			},
 714		},
 715	})
 716	if err != nil {
 717		panic(fmt.Errorf("generate change_shopping_list input schema: %w", err))
 718	}
 719
 720	return schema
 721}
 722
 723func normalizeRecipePage(page, limit int) (int, int) {
 724	if page < 1 {
 725		page = 1
 726	}
 727	if limit < 1 {
 728		limit = 10
 729	}
 730	if limit > 30 {
 731		limit = 30
 732	}
 733
 734	return page, limit
 735}
 736
 737func normalizeProductGroupIDs(ids []string) []string {
 738	normalized := make([]string, 0, len(ids))
 739	for _, id := range ids {
 740		id = strings.TrimSpace(id)
 741		if id != "" {
 742			normalized = append(normalized, id)
 743		}
 744	}
 745
 746	return normalized
 747}
 748
 749func normalizeShoppingListAddIngredients(raw string) string {
 750	var lines []string
 751	for line := range strings.SplitSeq(raw, "\n") {
 752		line = strings.TrimSpace(line)
 753		if line == "" {
 754			continue
 755		}
 756
 757		lines = append(lines, normalizeShoppingListAddIngredientLine(line))
 758	}
 759
 760	return strings.Join(lines, "\n")
 761}
 762
 763func normalizeShoppingListAddIngredientLine(line string) string {
 764	const separator = " — "
 765
 766	index := strings.LastIndex(line, separator)
 767	if index < 0 {
 768		return line
 769	}
 770
 771	name := strings.TrimSpace(line[:index])
 772	quantity := strings.TrimSpace(line[index+len(separator):])
 773	if name == "" || quantity == "" || !startsWithDigit(quantity) {
 774		return line
 775	}
 776
 777	return quantity + " " + name
 778}
 779
 780func startsWithDigit(value string) bool {
 781	return value[0] >= '0' && value[0] <= '9'
 782}
 783
 784func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
 785	var ids []string
 786	for _, aisle := range shoppingList.Aisles {
 787		for _, product := range aisle.ProductGroups {
 788			if product.Selected {
 789				ids = append(ids, product.ID)
 790			}
 791		}
 792	}
 793
 794	return ids
 795}
 796
 797func appendMissingProductGroupIDs(existing, additions []string) []string {
 798	seen := make(map[string]struct{}, len(existing)+len(additions))
 799	result := make([]string, 0, len(existing)+len(additions))
 800
 801	for _, id := range existing {
 802		if _, ok := seen[id]; ok {
 803			continue
 804		}
 805		seen[id] = struct{}{}
 806		result = append(result, id)
 807	}
 808
 809	for _, id := range additions {
 810		if _, ok := seen[id]; ok {
 811			continue
 812		}
 813		seen[id] = struct{}{}
 814		result = append(result, id)
 815	}
 816
 817	return result
 818}
 819
 820func subtractProductGroupIDs(existing, removals []string) []string {
 821	removed := make(map[string]struct{}, len(removals))
 822	for _, id := range removals {
 823		removed[id] = struct{}{}
 824	}
 825
 826	seen := make(map[string]struct{}, len(existing))
 827	result := make([]string, 0, len(existing))
 828	for _, id := range existing {
 829		if _, ok := removed[id]; ok {
 830			continue
 831		}
 832		if _, ok := seen[id]; ok {
 833			continue
 834		}
 835		seen[id] = struct{}{}
 836		result = append(result, id)
 837	}
 838
 839	return result
 840}
 841
 842func findShoppingListProductGroup(
 843	shoppingList cooked.ShoppingList,
 844	productGroupID string,
 845) (cooked.ProductGroup, string, bool) {
 846	for _, aisle := range shoppingList.Aisles {
 847		for _, product := range aisle.ProductGroups {
 848			if product.ID == productGroupID {
 849				return product, aisle.ID, true
 850			}
 851		}
 852	}
 853
 854	return cooked.ProductGroup{}, "", false
 855}
 856
 857func formatShoppingList(shoppingList cooked.ShoppingList) string {
 858	if len(shoppingList.Aisles) == 0 {
 859		return "Shopping list is empty."
 860	}
 861
 862	totalItems := shoppingListProductGroupCount(shoppingList)
 863	var builder strings.Builder
 864	if totalItems > maxShoppingListTextItems {
 865		fmt.Fprintf(&builder, "Shopping list (showing first %d of %d items):\n",
 866			maxShoppingListTextItems,
 867			totalItems)
 868	} else {
 869		builder.WriteString("Shopping list:\n")
 870	}
 871
 872	shownItems := 0
 873	for _, aisle := range shoppingList.Aisles {
 874		if shownItems >= maxShoppingListTextItems {
 875			break
 876		}
 877		shownItems = writeShoppingListAisle(&builder, aisle, shownItems)
 878	}
 879	if totalItems > maxShoppingListTextItems {
 880		fmt.Fprintf(
 881			&builder,
 882			"Additional shopping-list items are available in structured_content from this tool response: %d.\n",
 883			totalItems-maxShoppingListTextItems,
 884		)
 885	}
 886
 887	return strings.TrimRight(builder.String(), "\n")
 888}
 889
 890func writeShoppingListAisle(builder *strings.Builder, aisle cooked.Aisle, shownItems int) int {
 891	builder.WriteString("- ")
 892	builder.WriteString(aisle.Name)
 893	builder.WriteString(":")
 894	if len(aisle.ProductGroups) == 0 {
 895		builder.WriteString(" no items\n")
 896
 897		return shownItems
 898	}
 899	builder.WriteByte('\n')
 900
 901	for _, product := range aisle.ProductGroups {
 902		if shownItems >= maxShoppingListTextItems {
 903			break
 904		}
 905
 906		writeShoppingListProductGroup(builder, product)
 907		shownItems++
 908	}
 909
 910	return shownItems
 911}
 912
 913func writeShoppingListProductGroup(builder *strings.Builder, product cooked.ProductGroup) {
 914	builder.WriteString("  - ")
 915	builder.WriteString(product.Name)
 916	if product.Quantity != "" {
 917		builder.WriteString(" — ")
 918		builder.WriteString(product.Quantity)
 919	}
 920	builder.WriteString(" (id: ")
 921	builder.WriteString(product.ID)
 922	builder.WriteString(")")
 923	if product.Selected {
 924		builder.WriteString(" selected")
 925	}
 926	builder.WriteByte('\n')
 927}
 928
 929func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
 930	count := 0
 931	for _, aisle := range shoppingList.Aisles {
 932		count += len(aisle.ProductGroups)
 933	}
 934
 935	return count
 936}
 937
 938func formatRecipes(recipes []cooked.RecipeCard) string {
 939	if len(recipes) == 0 {
 940		return "No saved recipes found."
 941	}
 942
 943	var builder strings.Builder
 944	builder.WriteString("Saved recipes:\n")
 945	for _, recipe := range recipes {
 946		builder.WriteString("- ")
 947		builder.WriteString(recipe.Title)
 948		builder.WriteString(" (id: ")
 949		builder.WriteString(recipe.ID)
 950		builder.WriteString(")\n")
 951	}
 952
 953	return strings.TrimRight(builder.String(), "\n")
 954}
 955
 956func formatRecipeNextPageHint(query string, nextPage, limit int) string {
 957	if query == "" {
 958		return fmt.Sprintf(
 959			"More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
 960			nextPage,
 961			limit,
 962		)
 963	}
 964
 965	return fmt.Sprintf(
 966		"More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
 967		query,
 968		nextPage,
 969		limit,
 970	)
 971}
 972
 973func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
 974	return fmt.Sprintf(
 975		"More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to include more results from this page before trying page %d.",
 976		query,
 977		page,
 978		limit,
 979		nextPage,
 980	)
 981}
 982
 983func formatRecipe(recipe RecipeDetail) string {
 984	title := recipe.Title
 985	if title == "" {
 986		title = "(untitled recipe)"
 987	}
 988
 989	var builder strings.Builder
 990	builder.WriteString("Recipe: ")
 991	builder.WriteString(title)
 992	builder.WriteString(" (id: ")
 993	builder.WriteString(recipe.ID)
 994	builder.WriteString(")\n")
 995	if recipe.Owner != "" {
 996		builder.WriteString("Owner: ")
 997		builder.WriteString(recipe.Owner)
 998		builder.WriteByte('\n')
 999	}
1000	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))
1001
1002	content := strings.TrimSpace(recipe.Content)
1003	if content == "" {
1004		builder.WriteString("\nNo recipe content returned.")
1005	} else {
1006		builder.WriteString("\n")
1007		builder.WriteString(content)
1008	}
1009
1010	return strings.TrimRight(builder.String(), "\n")
1011}
1012
1013func formatRecipeTextPreview(preview ExtractIntoRecipeOutput) string {
1014	title := preview.Title
1015	if title == "" {
1016		title = "(untitled extraction)"
1017	}
1018
1019	var builder strings.Builder
1020	builder.WriteString("Extracted recipe text: ")
1021	builder.WriteString(title)
1022	builder.WriteByte('\n')
1023	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))
1024
1025	markdown := strings.TrimSpace(preview.Markdown)
1026	if markdown == "" {
1027		builder.WriteString("\nNo preview markdown returned.")
1028	} else {
1029		builder.WriteString("\n")
1030		builder.WriteString(markdown)
1031	}
1032
1033	return strings.TrimRight(builder.String(), "\n")
1034}
1035
1036func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }
1037
1038func formatRecipeSave(output SaveRecipeOutput) string {
1039	return "Saved recipe (id: " + output.RecipeID + ")."
1040}
1041
1042func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
1043	title := output.Title
1044	if strings.TrimSpace(title) == "" {
1045		title = "(untitled draft)"
1046	}
1047
1048	var builder strings.Builder
1049	builder.WriteString("Imported recipe URL as draft: ")
1050	builder.WriteString(title)
1051	builder.WriteString(" (draft_id: ")
1052	builder.WriteString(output.DraftID)
1053	builder.WriteString("). Review the markdown and portions before saving.")
1054	if output.Portions > 0 {
1055		fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
1056	}
1057	if strings.TrimSpace(output.Markdown) != "" {
1058		builder.WriteString("\n\n")
1059		builder.WriteString(output.Markdown)
1060	}
1061
1062	return strings.TrimRight(builder.String(), "\n")
1063}
1064
1065func formatRecipeDelete(output DeleteRecipeOutput) string {
1066	return "Deleted recipe (id: " + output.RecipeID + ")."
1067}
1068
1069func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
1070	summaries := make([]RecipeSummary, 0, len(recipes))
1071	for _, recipe := range recipes {
1072		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
1073	}
1074
1075	return summaries
1076}
1077
1078// ReadArguments contains read tool arguments.
1079type ReadArguments struct {
1080	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, recipe, or example_recipes."`
1081	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
1082	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
1083	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
1084	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
1085}
1086
1087// ReadOutput is the structured output for the current read tool slice.
1088type ReadOutput struct {
1089	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
1090	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
1091	Recipes             []RecipeSummary `json:"recipes,omitempty"`
1092	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
1093	ExampleRecipes      string          `json:"example_recipes,omitempty"`
1094}
1095
1096// RecipeSummary is the MCP-safe saved recipe summary.
1097type RecipeSummary struct {
1098	ID    string `json:"id"`
1099	Title string `json:"title"`
1100}
1101
1102// RecipeDetail is the MCP-safe single recipe output.
1103type RecipeDetail struct {
1104	ID       string  `json:"id"`
1105	Title    string  `json:"title"`
1106	Owner    string  `json:"owner"`
1107	Content  string  `json:"content"`
1108	Portions float64 `json:"portions"`
1109}
1110
1111// ExtractIntoRecipeArguments contains extract_into_recipe tool arguments.
1112type ExtractIntoRecipeArguments struct {
1113	Title string `json:"title" jsonschema:"Recipe title for the extraction."`
1114	Text  string `json:"text"  jsonschema:"Raw recipe text to extract into Cooked recipe markdown without saving."`
1115}
1116
1117// ExtractIntoRecipeOutput is the structured output for extract_into_recipe.
1118type ExtractIntoRecipeOutput struct {
1119	Title    string  `json:"title"`
1120	Markdown string  `json:"markdown"`
1121	Portions float64 `json:"portions"`
1122}
1123
1124// SaveRecipeArguments contains save_recipe tool arguments.
1125type SaveRecipeArguments struct {
1126	Source   string  `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
1127	RecipeID string  `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
1128	DraftID  string  `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
1129	Title    string  `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
1130	Text     string  `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
1131	URL      string  `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
1132	Markdown string  `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, optional reviewed URL import draft content, or existing recipe updates. Read target example_recipes first for Cooked's markdown dialect and examples."`
1133	Portions float64 `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, optional reviewed URL import draft content, or existing recipe updates."`
1134}
1135
1136// SaveRecipeOutput is the structured output for save_recipe.
1137type SaveRecipeOutput struct {
1138	RecipeID string  `json:"recipe_id,omitempty"`
1139	DraftID  string  `json:"draft_id,omitempty"`
1140	Title    string  `json:"title,omitempty"`
1141	Markdown string  `json:"markdown,omitempty"`
1142	Portions float64 `json:"portions,omitempty"`
1143}
1144
1145// DeleteRecipeArguments contains delete_recipe tool arguments.
1146type DeleteRecipeArguments struct {
1147	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
1148}
1149
1150// DeleteRecipeOutput is the structured output for delete_recipe.
1151type DeleteRecipeOutput struct {
1152	RecipeID string `json:"recipe_id"`
1153}
1154
1155// ChangeShoppingListArguments contains change_shopping_list tool arguments.
1156type ChangeShoppingListArguments struct {
1157	Action          string          `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
1158	Ingredients     string          `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
1159	RecipeID        string          `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
1160	ProductGroupIDs ProductGroupIDs `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
1161	ProductGroupID  string          `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
1162	Name            *string         `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
1163	Quantity        *string         `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
1164	AisleID         *string         `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
1165	Selected        *bool           `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
1166}
1167
1168// ProductGroupIDs are Cooked shopping-list product group IDs used by bulk actions.
1169type ProductGroupIDs []string
1170
1171// ChangeShoppingListOutput is the structured output for change_shopping_list.
1172type ChangeShoppingListOutput struct {
1173	Cleared                 bool     `json:"cleared,omitempty"`
1174	AddedCount              int      `json:"added_count,omitempty"`
1175	Ingredients             []string `json:"ingredients,omitempty"`
1176	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
1177	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
1178	UpdatedProductGroupID   string   `json:"updated_product_group_id,omitempty"`
1179}