server.go

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