server.go

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