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