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