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