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 newRecipeOverwriteResult(recipeID string) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 408	output := SaveRecipeOutput{RecipeID: recipeID}
 409
 410	return newToolResult(formatRecipeOverwrite(output), output), output, nil
 411}
 412
 413func (s *Server) callDeleteRecipeTool(
 414	ctx context.Context,
 415	_ *sdk.CallToolRequest,
 416	arguments DeleteRecipeArguments,
 417) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
 418	recipeID := strings.TrimSpace(arguments.RecipeID)
 419	if recipeID == "" {
 420		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
 421	}
 422
 423	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
 424		return nil, DeleteRecipeOutput{}, err
 425	}
 426
 427	output := DeleteRecipeOutput{RecipeID: recipeID}
 428
 429	return newToolResult(formatRecipeDelete(output), output), output, nil
 430}
 431
 432func (s *Server) callChangeShoppingListTool(
 433	ctx context.Context,
 434	_ *sdk.CallToolRequest,
 435	arguments ChangeShoppingListArguments,
 436) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 437	action := strings.TrimSpace(arguments.Action)
 438	switch action {
 439	case "add":
 440		return s.addShoppingListIngredients(ctx, arguments)
 441	case "clear":
 442		return s.clearShoppingList(ctx)
 443	case "remove":
 444		return s.removeShoppingListProductGroups(ctx, arguments)
 445	case "replace_selection":
 446		return s.replaceShoppingListSelection(ctx, arguments)
 447	case "add_selection":
 448		return s.addShoppingListSelection(ctx, arguments)
 449	case "remove_selection":
 450		return s.removeShoppingListSelection(ctx, arguments)
 451	case "update_item":
 452		return s.updateShoppingListProductGroup(ctx, arguments)
 453	case "":
 454		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
 455	default:
 456		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 457			"unsupported change_shopping_list action %q (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)",
 458			action,
 459		)
 460	}
 461}
 462
 463func (s *Server) addShoppingListIngredients(
 464	ctx context.Context,
 465	arguments ChangeShoppingListArguments,
 466) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 467	ingredients := normalizeShoppingListAddIngredients(arguments.Ingredients)
 468	if ingredients == "" {
 469		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
 470	}
 471
 472	result, err := s.backend.AddShoppingListIngredients(
 473		ctx,
 474		ingredients,
 475		strings.TrimSpace(arguments.RecipeID),
 476	)
 477	if err != nil {
 478		return nil, ChangeShoppingListOutput{}, err
 479	}
 480
 481	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
 482	text := fmt.Sprintf("Added %d shopping-list ingredients.", result.AddedCount)
 483
 484	return newToolResult(text, output), output, nil
 485}
 486
 487func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 488	if err := s.backend.ClearShoppingList(ctx); err != nil {
 489		return nil, ChangeShoppingListOutput{}, err
 490	}
 491
 492	output := ChangeShoppingListOutput{Cleared: true}
 493
 494	return newToolResult("Cleared shopping list.", output), output, nil
 495}
 496
 497func (s *Server) removeShoppingListProductGroups(
 498	ctx context.Context,
 499	arguments ChangeShoppingListArguments,
 500) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 501	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 502	if len(ids) == 0 {
 503		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
 504	}
 505
 506	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
 507		return nil, ChangeShoppingListOutput{}, err
 508	}
 509
 510	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
 511	text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
 512
 513	return newToolResult(text, output), output, nil
 514}
 515
 516func (s *Server) replaceShoppingListSelection(
 517	ctx context.Context,
 518	arguments ChangeShoppingListArguments,
 519) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 520	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 521	if len(ids) == 0 {
 522		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 523			"product_group_ids is required when action is replace_selection",
 524		)
 525	}
 526
 527	return s.setShoppingListSelection(ctx, ids)
 528}
 529
 530func (s *Server) addShoppingListSelection(
 531	ctx context.Context,
 532	arguments ChangeShoppingListArguments,
 533) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 534	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 535	if len(ids) == 0 {
 536		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 537			"product_group_ids is required when action is add_selection",
 538		)
 539	}
 540
 541	shoppingList, err := s.backend.ReadShoppingList(ctx)
 542	if err != nil {
 543		return nil, ChangeShoppingListOutput{}, err
 544	}
 545
 546	return s.setShoppingListSelection(
 547		ctx,
 548		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 549	)
 550}
 551
 552func (s *Server) removeShoppingListSelection(
 553	ctx context.Context,
 554	arguments ChangeShoppingListArguments,
 555) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 556	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 557	if len(ids) == 0 {
 558		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 559			"product_group_ids is required when action is remove_selection",
 560		)
 561	}
 562
 563	shoppingList, err := s.backend.ReadShoppingList(ctx)
 564	if err != nil {
 565		return nil, ChangeShoppingListOutput{}, err
 566	}
 567
 568	return s.setShoppingListSelection(
 569		ctx,
 570		subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 571	)
 572}
 573
 574func (s *Server) setShoppingListSelection(
 575	ctx context.Context,
 576	ids []string,
 577) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 578	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
 579		return nil, ChangeShoppingListOutput{}, err
 580	}
 581
 582	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
 583	text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
 584
 585	return newToolResult(text, output), output, nil
 586}
 587
 588func (s *Server) updateShoppingListProductGroup(
 589	ctx context.Context,
 590	arguments ChangeShoppingListArguments,
 591) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 592	productGroupID := strings.TrimSpace(arguments.ProductGroupID)
 593	if productGroupID == "" {
 594		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 595			"product_group_id is required when action is update_item",
 596		)
 597	}
 598
 599	shoppingList, err := s.backend.ReadShoppingList(ctx)
 600	if err != nil {
 601		return nil, ChangeShoppingListOutput{}, err
 602	}
 603
 604	product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
 605	if !ok {
 606		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 607			"product_group_id %q was not found in the current shopping list",
 608			productGroupID,
 609		)
 610	}
 611
 612	update := cooked.ShoppingListProductGroupUpdate{
 613		Name:     product.Name,
 614		Quantity: product.Quantity,
 615		AisleID:  aisleID,
 616		Selected: product.Selected,
 617	}
 618	if arguments.Name != nil {
 619		update.Name = *arguments.Name
 620	}
 621	if arguments.Quantity != nil {
 622		update.Quantity = *arguments.Quantity
 623	}
 624	if arguments.AisleID != nil {
 625		update.AisleID = strings.TrimSpace(*arguments.AisleID)
 626	}
 627	if arguments.Selected != nil {
 628		update.Selected = *arguments.Selected
 629	}
 630
 631	if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
 632		return nil, ChangeShoppingListOutput{}, err
 633	}
 634
 635	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
 636	text := fmt.Sprintf("Updated shopping-list product group (id: %s).", productGroupID)
 637
 638	return newToolResult(text, output), output, nil
 639}
 640
 641func readTool() *sdk.Tool {
 642	openWorld := true
 643	return &sdk.Tool{
 644		Name:        readToolName,
 645		Title:       "Read Cooked data",
 646		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.",
 647		Annotations: &sdk.ToolAnnotations{
 648			Title:         "Read Cooked data",
 649			ReadOnlyHint:  true,
 650			OpenWorldHint: &openWorld,
 651		},
 652	}
 653}
 654
 655func extractIntoRecipeTool() *sdk.Tool {
 656	openWorld := true
 657	return &sdk.Tool{
 658		Name:        extractIntoRecipeToolName,
 659		Title:       "Extract raw recipe text into Cooked recipe",
 660		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.",
 661		Annotations: &sdk.ToolAnnotations{
 662			Title:         "Extract raw recipe text into Cooked recipe",
 663			ReadOnlyHint:  true,
 664			OpenWorldHint: &openWorld,
 665		},
 666	}
 667}
 668
 669func saveRecipeTool() *sdk.Tool {
 670	openWorld := true
 671	return &sdk.Tool{
 672		Name:        saveRecipeToolName,
 673		Title:       "Save recipe",
 674		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.",
 675		Annotations: &sdk.ToolAnnotations{
 676			Title:         "Save recipe",
 677			OpenWorldHint: &openWorld,
 678		},
 679	}
 680}
 681
 682func deleteRecipeTool() *sdk.Tool {
 683	destructive := true
 684	openWorld := true
 685	return &sdk.Tool{
 686		Name:        deleteRecipeToolName,
 687		Title:       "Delete recipe",
 688		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
 689		Annotations: &sdk.ToolAnnotations{
 690			Title:           "Delete recipe",
 691			DestructiveHint: &destructive,
 692			OpenWorldHint:   &openWorld,
 693		},
 694	}
 695}
 696
 697func changeShoppingListTool() *sdk.Tool {
 698	destructive := true
 699	openWorld := true
 700	return &sdk.Tool{
 701		Name:        changeShoppingListToolName,
 702		Title:       "Change shopping list",
 703		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.",
 704		InputSchema: changeShoppingListInputSchema(),
 705		Annotations: &sdk.ToolAnnotations{
 706			Title:           "Change shopping list",
 707			DestructiveHint: &destructive,
 708			OpenWorldHint:   &openWorld,
 709		},
 710	}
 711}
 712
 713func changeShoppingListInputSchema() *jsonschema.Schema {
 714	schema, err := jsonschema.For[ChangeShoppingListArguments](&jsonschema.ForOptions{
 715		TypeSchemas: map[reflect.Type]*jsonschema.Schema{
 716			reflect.TypeFor[ProductGroupIDs](): {
 717				Type:  "array",
 718				Items: &jsonschema.Schema{Type: "string"},
 719			},
 720		},
 721	})
 722	if err != nil {
 723		panic(fmt.Errorf("generate change_shopping_list input schema: %w", err))
 724	}
 725
 726	return schema
 727}
 728
 729func normalizeRecipePage(page, limit int) (int, int) {
 730	if page < 1 {
 731		page = 1
 732	}
 733	if limit < 1 {
 734		limit = 10
 735	}
 736	if limit > 30 {
 737		limit = 30
 738	}
 739
 740	return page, limit
 741}
 742
 743func normalizeProductGroupIDs(ids []string) []string {
 744	normalized := make([]string, 0, len(ids))
 745	for _, id := range ids {
 746		id = strings.TrimSpace(id)
 747		if id != "" {
 748			normalized = append(normalized, id)
 749		}
 750	}
 751
 752	return normalized
 753}
 754
 755func normalizeShoppingListAddIngredients(raw string) string {
 756	var lines []string
 757	for line := range strings.SplitSeq(raw, "\n") {
 758		line = strings.TrimSpace(line)
 759		if line == "" {
 760			continue
 761		}
 762
 763		lines = append(lines, normalizeShoppingListAddIngredientLine(line))
 764	}
 765
 766	return strings.Join(lines, "\n")
 767}
 768
 769func normalizeShoppingListAddIngredientLine(line string) string {
 770	const separator = " — "
 771
 772	index := strings.LastIndex(line, separator)
 773	if index < 0 {
 774		return line
 775	}
 776
 777	name := strings.TrimSpace(line[:index])
 778	quantity := strings.TrimSpace(line[index+len(separator):])
 779	if name == "" || quantity == "" || !startsWithDigit(quantity) {
 780		return line
 781	}
 782
 783	return quantity + " " + name
 784}
 785
 786func startsWithDigit(value string) bool {
 787	return value[0] >= '0' && value[0] <= '9'
 788}
 789
 790func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
 791	var ids []string
 792	for _, aisle := range shoppingList.Aisles {
 793		for _, product := range aisle.ProductGroups {
 794			if product.Selected {
 795				ids = append(ids, product.ID)
 796			}
 797		}
 798	}
 799
 800	return ids
 801}
 802
 803func appendMissingProductGroupIDs(existing, additions []string) []string {
 804	seen := make(map[string]struct{}, len(existing)+len(additions))
 805	result := make([]string, 0, len(existing)+len(additions))
 806
 807	for _, id := range existing {
 808		if _, ok := seen[id]; ok {
 809			continue
 810		}
 811		seen[id] = struct{}{}
 812		result = append(result, id)
 813	}
 814
 815	for _, id := range additions {
 816		if _, ok := seen[id]; ok {
 817			continue
 818		}
 819		seen[id] = struct{}{}
 820		result = append(result, id)
 821	}
 822
 823	return result
 824}
 825
 826func subtractProductGroupIDs(existing, removals []string) []string {
 827	removed := make(map[string]struct{}, len(removals))
 828	for _, id := range removals {
 829		removed[id] = struct{}{}
 830	}
 831
 832	seen := make(map[string]struct{}, len(existing))
 833	result := make([]string, 0, len(existing))
 834	for _, id := range existing {
 835		if _, ok := removed[id]; ok {
 836			continue
 837		}
 838		if _, ok := seen[id]; ok {
 839			continue
 840		}
 841		seen[id] = struct{}{}
 842		result = append(result, id)
 843	}
 844
 845	return result
 846}
 847
 848func findShoppingListProductGroup(
 849	shoppingList cooked.ShoppingList,
 850	productGroupID string,
 851) (cooked.ProductGroup, string, bool) {
 852	for _, aisle := range shoppingList.Aisles {
 853		for _, product := range aisle.ProductGroups {
 854			if product.ID == productGroupID {
 855				return product, aisle.ID, true
 856			}
 857		}
 858	}
 859
 860	return cooked.ProductGroup{}, "", false
 861}
 862
 863func formatShoppingList(shoppingList cooked.ShoppingList) string {
 864	if len(shoppingList.Aisles) == 0 {
 865		return "Shopping list is empty."
 866	}
 867
 868	totalItems := shoppingListProductGroupCount(shoppingList)
 869	var builder strings.Builder
 870	if totalItems > maxShoppingListTextItems {
 871		fmt.Fprintf(&builder, "Shopping list (showing first %d of %d items):\n",
 872			maxShoppingListTextItems,
 873			totalItems)
 874	} else {
 875		builder.WriteString("Shopping list:\n")
 876	}
 877
 878	shownItems := 0
 879	for _, aisle := range shoppingList.Aisles {
 880		if shownItems >= maxShoppingListTextItems {
 881			break
 882		}
 883		shownItems = writeShoppingListAisle(&builder, aisle, shownItems)
 884	}
 885	if totalItems > maxShoppingListTextItems {
 886		fmt.Fprintf(
 887			&builder,
 888			"Additional shopping-list items are available in structured_content from this tool response: %d.\n",
 889			totalItems-maxShoppingListTextItems,
 890		)
 891	}
 892
 893	return strings.TrimRight(builder.String(), "\n")
 894}
 895
 896func writeShoppingListAisle(builder *strings.Builder, aisle cooked.Aisle, shownItems int) int {
 897	builder.WriteString("- ")
 898	builder.WriteString(aisle.Name)
 899	builder.WriteString(":")
 900	if len(aisle.ProductGroups) == 0 {
 901		builder.WriteString(" no items\n")
 902
 903		return shownItems
 904	}
 905	builder.WriteByte('\n')
 906
 907	for _, product := range aisle.ProductGroups {
 908		if shownItems >= maxShoppingListTextItems {
 909			break
 910		}
 911
 912		writeShoppingListProductGroup(builder, product)
 913		shownItems++
 914	}
 915
 916	return shownItems
 917}
 918
 919func writeShoppingListProductGroup(builder *strings.Builder, product cooked.ProductGroup) {
 920	builder.WriteString("  - ")
 921	builder.WriteString(product.Name)
 922	if product.Quantity != "" {
 923		builder.WriteString(" — ")
 924		builder.WriteString(product.Quantity)
 925	}
 926	builder.WriteString(" (id: ")
 927	builder.WriteString(product.ID)
 928	builder.WriteString(")")
 929	if product.Selected {
 930		builder.WriteString(" selected")
 931	}
 932	builder.WriteByte('\n')
 933}
 934
 935func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
 936	count := 0
 937	for _, aisle := range shoppingList.Aisles {
 938		count += len(aisle.ProductGroups)
 939	}
 940
 941	return count
 942}
 943
 944func formatRecipes(recipes []cooked.RecipeCard) string {
 945	if len(recipes) == 0 {
 946		return "No saved recipes found."
 947	}
 948
 949	var builder strings.Builder
 950	builder.WriteString("Saved recipes:\n")
 951	for _, recipe := range recipes {
 952		builder.WriteString("- ")
 953		builder.WriteString(recipe.Title)
 954		builder.WriteString(" (id: ")
 955		builder.WriteString(recipe.ID)
 956		builder.WriteString(")\n")
 957	}
 958
 959	return strings.TrimRight(builder.String(), "\n")
 960}
 961
 962func formatRecipeNextPageHint(query string, nextPage, limit int) string {
 963	if query == "" {
 964		return fmt.Sprintf(
 965			"More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
 966			nextPage,
 967			limit,
 968		)
 969	}
 970
 971	return fmt.Sprintf(
 972		"More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
 973		query,
 974		nextPage,
 975		limit,
 976	)
 977}
 978
 979func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
 980	return fmt.Sprintf(
 981		"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.",
 982		query,
 983		page,
 984		limit,
 985		nextPage,
 986	)
 987}
 988
 989func formatRecipe(recipe RecipeDetail) string {
 990	title := recipe.Title
 991	if title == "" {
 992		title = "(untitled recipe)"
 993	}
 994
 995	var builder strings.Builder
 996	builder.WriteString("Recipe: ")
 997	builder.WriteString(title)
 998	builder.WriteString(" (id: ")
 999	builder.WriteString(recipe.ID)
1000	builder.WriteString(")\n")
1001	if recipe.Owner != "" {
1002		builder.WriteString("Owner: ")
1003		builder.WriteString(recipe.Owner)
1004		builder.WriteByte('\n')
1005	}
1006	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(recipe.Portions))
1007
1008	content := strings.TrimSpace(recipe.Content)
1009	if content == "" {
1010		builder.WriteString("\nNo recipe content returned.")
1011	} else {
1012		builder.WriteString("\n")
1013		builder.WriteString(content)
1014	}
1015
1016	return strings.TrimRight(builder.String(), "\n")
1017}
1018
1019func formatRecipeTextPreview(preview ExtractIntoRecipeOutput) string {
1020	title := preview.Title
1021	if title == "" {
1022		title = "(untitled extraction)"
1023	}
1024
1025	var builder strings.Builder
1026	builder.WriteString("Extracted recipe text: ")
1027	builder.WriteString(title)
1028	builder.WriteByte('\n')
1029	fmt.Fprintf(&builder, "Portions: %s\n", formatPortions(preview.Portions))
1030
1031	markdown := strings.TrimSpace(preview.Markdown)
1032	if markdown == "" {
1033		builder.WriteString("\nNo preview markdown returned.")
1034	} else {
1035		builder.WriteString("\n")
1036		builder.WriteString(markdown)
1037	}
1038
1039	return strings.TrimRight(builder.String(), "\n")
1040}
1041
1042func formatPortions(portions float64) string { return strconv.FormatFloat(portions, 'f', -1, 64) }
1043
1044func formatRecipeSave(output SaveRecipeOutput) string {
1045	return "Saved recipe (id: " + output.RecipeID + ")."
1046}
1047
1048func formatRecipeOverwrite(output SaveRecipeOutput) string {
1049	return "Saved draft into existing recipe (id: " + output.RecipeID + ")."
1050}
1051
1052func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
1053	title := output.Title
1054	if strings.TrimSpace(title) == "" {
1055		title = "(untitled draft)"
1056	}
1057
1058	var builder strings.Builder
1059	builder.WriteString("Imported recipe URL as draft: ")
1060	builder.WriteString(title)
1061	builder.WriteString(" (draft_id: ")
1062	builder.WriteString(output.DraftID)
1063	builder.WriteString("). Review the markdown and portions before saving.")
1064	if output.Portions > 0 {
1065		fmt.Fprintf(&builder, "\nPortions: %s", formatPortions(output.Portions))
1066	}
1067	if strings.TrimSpace(output.Markdown) != "" {
1068		builder.WriteString("\n\n")
1069		builder.WriteString(output.Markdown)
1070	}
1071
1072	return strings.TrimRight(builder.String(), "\n")
1073}
1074
1075func formatRecipeDelete(output DeleteRecipeOutput) string {
1076	return "Deleted recipe (id: " + output.RecipeID + ")."
1077}
1078
1079func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
1080	summaries := make([]RecipeSummary, 0, len(recipes))
1081	for _, recipe := range recipes {
1082		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
1083	}
1084
1085	return summaries
1086}
1087
1088// ReadArguments contains read tool arguments.
1089type ReadArguments struct {
1090	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, recipe, or example_recipes."`
1091	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
1092	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
1093	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
1094	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
1095}
1096
1097// ReadOutput is the structured output for the current read tool slice.
1098type ReadOutput struct {
1099	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
1100	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
1101	Recipes             []RecipeSummary `json:"recipes,omitempty"`
1102	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
1103	ExampleRecipes      string          `json:"example_recipes,omitempty"`
1104}
1105
1106// RecipeSummary is the MCP-safe saved recipe summary.
1107type RecipeSummary struct {
1108	ID    string `json:"id"`
1109	Title string `json:"title"`
1110}
1111
1112// RecipeDetail is the MCP-safe single recipe output.
1113type RecipeDetail struct {
1114	ID       string  `json:"id"`
1115	Title    string  `json:"title"`
1116	Owner    string  `json:"owner"`
1117	Content  string  `json:"content"`
1118	Portions float64 `json:"portions"`
1119}
1120
1121// ExtractIntoRecipeArguments contains extract_into_recipe tool arguments.
1122type ExtractIntoRecipeArguments struct {
1123	Title string `json:"title" jsonschema:"Recipe title for the extraction."`
1124	Text  string `json:"text"  jsonschema:"Raw recipe text to extract into Cooked recipe markdown without saving."`
1125}
1126
1127// ExtractIntoRecipeOutput is the structured output for extract_into_recipe.
1128type ExtractIntoRecipeOutput struct {
1129	Title    string  `json:"title"`
1130	Markdown string  `json:"markdown"`
1131	Portions float64 `json:"portions"`
1132}
1133
1134// SaveRecipeArguments contains save_recipe tool arguments.
1135type SaveRecipeArguments struct {
1136	Source   string  `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
1137	RecipeID string  `json:"recipe_id,omitempty" jsonschema:"Recipe ID for source=existing updates or source=draft overwrite into an existing recipe."`
1138	DraftID  string  `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
1139	Title    string  `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
1140	Text     string  `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
1141	URL      string  `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
1142	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."`
1143	Portions float64 `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, optional reviewed URL import draft content, or existing recipe updates."`
1144}
1145
1146// SaveRecipeOutput is the structured output for save_recipe.
1147type SaveRecipeOutput struct {
1148	RecipeID string  `json:"recipe_id,omitempty"`
1149	DraftID  string  `json:"draft_id,omitempty"`
1150	Title    string  `json:"title,omitempty"`
1151	Markdown string  `json:"markdown,omitempty"`
1152	Portions float64 `json:"portions,omitempty"`
1153}
1154
1155// DeleteRecipeArguments contains delete_recipe tool arguments.
1156type DeleteRecipeArguments struct {
1157	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
1158}
1159
1160// DeleteRecipeOutput is the structured output for delete_recipe.
1161type DeleteRecipeOutput struct {
1162	RecipeID string `json:"recipe_id"`
1163}
1164
1165// ChangeShoppingListArguments contains change_shopping_list tool arguments.
1166type ChangeShoppingListArguments struct {
1167	Action          string          `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
1168	Ingredients     string          `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add. Put quantities before names, for example 1 milk."`
1169	RecipeID        string          `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
1170	ProductGroupIDs ProductGroupIDs `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
1171	ProductGroupID  string          `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
1172	Name            *string         `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
1173	Quantity        *string         `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
1174	AisleID         *string         `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
1175	Selected        *bool           `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
1176}
1177
1178// ProductGroupIDs are Cooked shopping-list product group IDs used by bulk actions.
1179type ProductGroupIDs []string
1180
1181// ChangeShoppingListOutput is the structured output for change_shopping_list.
1182type ChangeShoppingListOutput struct {
1183	Cleared                 bool     `json:"cleared,omitempty"`
1184	AddedCount              int      `json:"added_count,omitempty"`
1185	Ingredients             []string `json:"ingredients,omitempty"`
1186	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
1187	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
1188	UpdatedProductGroupID   string   `json:"updated_product_group_id,omitempty"`
1189}