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