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