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	if strings.TrimSpace(arguments.Ingredients) == "" {
 481		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
 482	}
 483
 484	result, err := s.backend.AddShoppingListIngredients(
 485		ctx,
 486		arguments.Ingredients,
 487		strings.TrimSpace(arguments.RecipeID),
 488	)
 489	if err != nil {
 490		return nil, ChangeShoppingListOutput{}, err
 491	}
 492
 493	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
 494	text := fmt.Sprintf("Added %d shopping-list ingredients.", result.AddedCount)
 495
 496	return newToolResult(text, output), output, nil
 497}
 498
 499func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 500	if err := s.backend.ClearShoppingList(ctx); err != nil {
 501		return nil, ChangeShoppingListOutput{}, err
 502	}
 503
 504	output := ChangeShoppingListOutput{Cleared: true}
 505
 506	return newToolResult("Cleared shopping list.", output), output, nil
 507}
 508
 509func (s *Server) removeShoppingListProductGroups(
 510	ctx context.Context,
 511	arguments ChangeShoppingListArguments,
 512) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 513	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 514	if len(ids) == 0 {
 515		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
 516	}
 517
 518	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
 519		return nil, ChangeShoppingListOutput{}, err
 520	}
 521
 522	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
 523	text := fmt.Sprintf("Removed %d shopping-list product groups.", len(ids))
 524
 525	return newToolResult(text, output), output, nil
 526}
 527
 528func (s *Server) replaceShoppingListSelection(
 529	ctx context.Context,
 530	arguments ChangeShoppingListArguments,
 531) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 532	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 533	if len(ids) == 0 {
 534		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 535			"product_group_ids is required when action is replace_selection",
 536		)
 537	}
 538
 539	return s.setShoppingListSelection(ctx, ids)
 540}
 541
 542func (s *Server) addShoppingListSelection(
 543	ctx context.Context,
 544	arguments ChangeShoppingListArguments,
 545) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 546	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 547	if len(ids) == 0 {
 548		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 549			"product_group_ids is required when action is add_selection",
 550		)
 551	}
 552
 553	shoppingList, err := s.backend.ReadShoppingList(ctx)
 554	if err != nil {
 555		return nil, ChangeShoppingListOutput{}, err
 556	}
 557
 558	return s.setShoppingListSelection(
 559		ctx,
 560		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 561	)
 562}
 563
 564func (s *Server) removeShoppingListSelection(
 565	ctx context.Context,
 566	arguments ChangeShoppingListArguments,
 567) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 568	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 569	if len(ids) == 0 {
 570		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 571			"product_group_ids is required when action is remove_selection",
 572		)
 573	}
 574
 575	shoppingList, err := s.backend.ReadShoppingList(ctx)
 576	if err != nil {
 577		return nil, ChangeShoppingListOutput{}, err
 578	}
 579
 580	return s.setShoppingListSelection(
 581		ctx,
 582		subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 583	)
 584}
 585
 586func (s *Server) setShoppingListSelection(
 587	ctx context.Context,
 588	ids []string,
 589) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 590	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
 591		return nil, ChangeShoppingListOutput{}, err
 592	}
 593
 594	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
 595	text := fmt.Sprintf("Selected %d shopping-list product groups.", len(ids))
 596
 597	return newToolResult(text, output), output, nil
 598}
 599
 600func (s *Server) updateShoppingListProductGroup(
 601	ctx context.Context,
 602	arguments ChangeShoppingListArguments,
 603) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 604	productGroupID := strings.TrimSpace(arguments.ProductGroupID)
 605	if productGroupID == "" {
 606		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 607			"product_group_id is required when action is update_item",
 608		)
 609	}
 610
 611	shoppingList, err := s.backend.ReadShoppingList(ctx)
 612	if err != nil {
 613		return nil, ChangeShoppingListOutput{}, err
 614	}
 615
 616	product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
 617	if !ok {
 618		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 619			"product_group_id %q was not found in the current shopping list",
 620			productGroupID,
 621		)
 622	}
 623
 624	update := cooked.ShoppingListProductGroupUpdate{
 625		Name:     product.Name,
 626		Quantity: product.Quantity,
 627		AisleID:  aisleID,
 628		Selected: product.Selected,
 629	}
 630	if arguments.Name != nil {
 631		update.Name = *arguments.Name
 632	}
 633	if arguments.Quantity != nil {
 634		update.Quantity = *arguments.Quantity
 635	}
 636	if arguments.AisleID != nil {
 637		update.AisleID = strings.TrimSpace(*arguments.AisleID)
 638	}
 639	if arguments.Selected != nil {
 640		update.Selected = *arguments.Selected
 641	}
 642
 643	if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
 644		return nil, ChangeShoppingListOutput{}, err
 645	}
 646
 647	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
 648	text := fmt.Sprintf("Updated shopping-list product group (id: %s).", productGroupID)
 649
 650	return newToolResult(text, output), output, nil
 651}
 652
 653func newToolResult[Output any](text string, output Output) *sdk.CallToolResult {
 654	return &sdk.CallToolResult{
 655		Content:           []sdk.Content{&sdk.TextContent{Text: text}},
 656		StructuredContent: output,
 657	}
 658}
 659
 660func readTool() *sdk.Tool {
 661	openWorld := true
 662	return &sdk.Tool{
 663		Name:        readToolName,
 664		Title:       "Read Cooked data",
 665		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.",
 666		Annotations: &sdk.ToolAnnotations{
 667			Title:         "Read Cooked data",
 668			ReadOnlyHint:  true,
 669			OpenWorldHint: &openWorld,
 670		},
 671	}
 672}
 673
 674func previewRecipeTextTool() *sdk.Tool {
 675	openWorld := true
 676	return &sdk.Tool{
 677		Name:        previewRecipeTextToolName,
 678		Title:       "Preview recipe text",
 679		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
 680		Annotations: &sdk.ToolAnnotations{
 681			Title:         "Preview recipe text",
 682			ReadOnlyHint:  true,
 683			OpenWorldHint: &openWorld,
 684		},
 685	}
 686}
 687
 688func saveRecipeTool() *sdk.Tool {
 689	openWorld := true
 690	return &sdk.Tool{
 691		Name:        saveRecipeToolName,
 692		Title:       "Save recipe",
 693		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.",
 694		Annotations: &sdk.ToolAnnotations{
 695			Title:         "Save recipe",
 696			OpenWorldHint: &openWorld,
 697		},
 698	}
 699}
 700
 701func deleteRecipeTool() *sdk.Tool {
 702	destructive := true
 703	openWorld := true
 704	return &sdk.Tool{
 705		Name:        deleteRecipeToolName,
 706		Title:       "Delete recipe",
 707		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
 708		Annotations: &sdk.ToolAnnotations{
 709			Title:           "Delete recipe",
 710			DestructiveHint: &destructive,
 711			OpenWorldHint:   &openWorld,
 712		},
 713	}
 714}
 715
 716func changeShoppingListTool() *sdk.Tool {
 717	destructive := true
 718	openWorld := true
 719	return &sdk.Tool{
 720		Name:        changeShoppingListToolName,
 721		Title:       "Change shopping list",
 722		Description: "Change the Cooked shopping list. Supports add, update_item, replace_selection, add_selection, remove_selection, remove, and clear. Add accepts newline-separated ingredients and optional recipe_id. Remove and clear are destructive.",
 723		Annotations: &sdk.ToolAnnotations{
 724			Title:           "Change shopping list",
 725			DestructiveHint: &destructive,
 726			OpenWorldHint:   &openWorld,
 727		},
 728	}
 729}
 730
 731func normalizeRecipePage(page, limit int) (int, int) {
 732	if page < 1 {
 733		page = 1
 734	}
 735	if limit < 1 {
 736		limit = 10
 737	}
 738	if limit > 30 {
 739		limit = 30
 740	}
 741
 742	return page, limit
 743}
 744
 745func normalizeProductGroupIDs(ids []string) []string {
 746	normalized := make([]string, 0, len(ids))
 747	for _, id := range ids {
 748		id = strings.TrimSpace(id)
 749		if id != "" {
 750			normalized = append(normalized, id)
 751		}
 752	}
 753
 754	return normalized
 755}
 756
 757func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
 758	var ids []string
 759	for _, aisle := range shoppingList.Aisles {
 760		for _, product := range aisle.ProductGroups {
 761			if product.Selected {
 762				ids = append(ids, product.ID)
 763			}
 764		}
 765	}
 766
 767	return ids
 768}
 769
 770func appendMissingProductGroupIDs(existing, additions []string) []string {
 771	seen := make(map[string]struct{}, len(existing)+len(additions))
 772	result := make([]string, 0, len(existing)+len(additions))
 773
 774	for _, id := range existing {
 775		if _, ok := seen[id]; ok {
 776			continue
 777		}
 778		seen[id] = struct{}{}
 779		result = append(result, id)
 780	}
 781
 782	for _, id := range additions {
 783		if _, ok := seen[id]; ok {
 784			continue
 785		}
 786		seen[id] = struct{}{}
 787		result = append(result, id)
 788	}
 789
 790	return result
 791}
 792
 793func subtractProductGroupIDs(existing, removals []string) []string {
 794	removed := make(map[string]struct{}, len(removals))
 795	for _, id := range removals {
 796		removed[id] = struct{}{}
 797	}
 798
 799	seen := make(map[string]struct{}, len(existing))
 800	result := make([]string, 0, len(existing))
 801	for _, id := range existing {
 802		if _, ok := removed[id]; ok {
 803			continue
 804		}
 805		if _, ok := seen[id]; ok {
 806			continue
 807		}
 808		seen[id] = struct{}{}
 809		result = append(result, id)
 810	}
 811
 812	return result
 813}
 814
 815func findShoppingListProductGroup(
 816	shoppingList cooked.ShoppingList,
 817	productGroupID string,
 818) (cooked.ProductGroup, string, bool) {
 819	for _, aisle := range shoppingList.Aisles {
 820		for _, product := range aisle.ProductGroups {
 821			if product.ID == productGroupID {
 822				return product, aisle.ID, true
 823			}
 824		}
 825	}
 826
 827	return cooked.ProductGroup{}, "", false
 828}
 829
 830func formatShoppingList(shoppingList cooked.ShoppingList) string {
 831	if len(shoppingList.Aisles) == 0 {
 832		return "Shopping list is empty."
 833	}
 834
 835	totalItems := shoppingListProductGroupCount(shoppingList)
 836	var builder strings.Builder
 837	if totalItems > maxShoppingListTextItems {
 838		fmt.Fprintf(&builder, "Shopping list (showing first %d of %d items):\n",
 839			maxShoppingListTextItems,
 840			totalItems)
 841	} else {
 842		builder.WriteString("Shopping list:\n")
 843	}
 844
 845	shownItems := 0
 846	for _, aisle := range shoppingList.Aisles {
 847		if shownItems >= maxShoppingListTextItems {
 848			break
 849		}
 850		shownItems = writeShoppingListAisle(&builder, aisle, shownItems)
 851	}
 852	if totalItems > maxShoppingListTextItems {
 853		fmt.Fprintf(
 854			&builder,
 855			"Additional shopping-list items are available in structured_content from this tool response: %d.\n",
 856			totalItems-maxShoppingListTextItems,
 857		)
 858	}
 859
 860	return strings.TrimRight(builder.String(), "\n")
 861}
 862
 863func writeShoppingListAisle(builder *strings.Builder, aisle cooked.Aisle, shownItems int) int {
 864	builder.WriteString("- ")
 865	builder.WriteString(aisle.Name)
 866	builder.WriteString(":")
 867	if len(aisle.ProductGroups) == 0 {
 868		builder.WriteString(" no items\n")
 869
 870		return shownItems
 871	}
 872	builder.WriteByte('\n')
 873
 874	for _, product := range aisle.ProductGroups {
 875		if shownItems >= maxShoppingListTextItems {
 876			break
 877		}
 878
 879		writeShoppingListProductGroup(builder, product)
 880		shownItems++
 881	}
 882
 883	return shownItems
 884}
 885
 886func writeShoppingListProductGroup(builder *strings.Builder, product cooked.ProductGroup) {
 887	builder.WriteString("  - ")
 888	builder.WriteString(product.Name)
 889	if product.Quantity != "" {
 890		builder.WriteString(" — ")
 891		builder.WriteString(product.Quantity)
 892	}
 893	builder.WriteString(" (id: ")
 894	builder.WriteString(product.ID)
 895	builder.WriteString(")")
 896	if product.Selected {
 897		builder.WriteString(" selected")
 898	}
 899	builder.WriteByte('\n')
 900}
 901
 902func shoppingListProductGroupCount(shoppingList cooked.ShoppingList) int {
 903	count := 0
 904	for _, aisle := range shoppingList.Aisles {
 905		count += len(aisle.ProductGroups)
 906	}
 907
 908	return count
 909}
 910
 911func formatRecipes(recipes []cooked.RecipeCard) string {
 912	if len(recipes) == 0 {
 913		return "No saved recipes found."
 914	}
 915
 916	var builder strings.Builder
 917	builder.WriteString("Saved recipes:\n")
 918	for _, recipe := range recipes {
 919		builder.WriteString("- ")
 920		builder.WriteString(recipe.Title)
 921		builder.WriteString(" (id: ")
 922		builder.WriteString(recipe.ID)
 923		builder.WriteString(")\n")
 924	}
 925
 926	return strings.TrimRight(builder.String(), "\n")
 927}
 928
 929func formatRecipeNextPageHint(query string, nextPage, limit int) string {
 930	if query == "" {
 931		return fmt.Sprintf(
 932			"More recipes may be available. Call read with target recipes, page %d, limit %d to continue.",
 933			nextPage,
 934			limit,
 935		)
 936	}
 937
 938	return fmt.Sprintf(
 939		"More matching recipes may be available. Call read with target recipes, query %q, page %d, limit %d to continue.",
 940		query,
 941		nextPage,
 942		limit,
 943	)
 944}
 945
 946func formatRecipeSearchLimitHint(query string, page, nextPage, limit int) string {
 947	return fmt.Sprintf(
 948		"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.",
 949		query,
 950		page,
 951		limit,
 952		nextPage,
 953	)
 954}
 955
 956func formatRecipe(recipe RecipeDetail) string {
 957	title := recipe.Title
 958	if title == "" {
 959		title = "(untitled recipe)"
 960	}
 961
 962	var builder strings.Builder
 963	builder.WriteString("Recipe: ")
 964	builder.WriteString(title)
 965	builder.WriteString(" (id: ")
 966	builder.WriteString(recipe.ID)
 967	builder.WriteString(")\n")
 968	if recipe.Owner != "" {
 969		builder.WriteString("Owner: ")
 970		builder.WriteString(recipe.Owner)
 971		builder.WriteByte('\n')
 972	}
 973	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
 974	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
 975
 976	content := strings.TrimSpace(recipe.Content)
 977	if content == "" {
 978		builder.WriteString("\nNo recipe content returned.")
 979	} else {
 980		builder.WriteString("\n")
 981		builder.WriteString(content)
 982	}
 983
 984	return strings.TrimRight(builder.String(), "\n")
 985}
 986
 987func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
 988	title := preview.Title
 989	if title == "" {
 990		title = "(untitled preview)"
 991	}
 992
 993	var builder strings.Builder
 994	builder.WriteString("Recipe text preview: ")
 995	builder.WriteString(title)
 996	builder.WriteByte('\n')
 997	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
 998
 999	markdown := strings.TrimSpace(preview.Markdown)
1000	if markdown == "" {
1001		builder.WriteString("\nNo preview markdown returned.")
1002	} else {
1003		builder.WriteString("\n")
1004		builder.WriteString(markdown)
1005	}
1006
1007	return strings.TrimRight(builder.String(), "\n")
1008}
1009
1010func formatRecipeSave(output SaveRecipeOutput) string {
1011	return "Saved recipe (id: " + output.RecipeID + ")."
1012}
1013
1014func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
1015	title := output.Title
1016	if strings.TrimSpace(title) == "" {
1017		title = "(untitled draft)"
1018	}
1019
1020	var builder strings.Builder
1021	builder.WriteString("Imported recipe URL as draft: ")
1022	builder.WriteString(title)
1023	builder.WriteString(" (draft_id: ")
1024	builder.WriteString(output.DraftID)
1025	builder.WriteString("). Review the markdown and portions before saving.")
1026	if output.Portions > 0 {
1027		fmt.Fprintf(&builder, "\nPortions: %d", output.Portions)
1028	}
1029	if strings.TrimSpace(output.Markdown) != "" {
1030		builder.WriteString("\n\n")
1031		builder.WriteString(output.Markdown)
1032	}
1033
1034	return strings.TrimRight(builder.String(), "\n")
1035}
1036
1037func formatRecipeDelete(output DeleteRecipeOutput) string {
1038	return "Deleted recipe (id: " + output.RecipeID + ")."
1039}
1040
1041func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
1042	summaries := make([]RecipeSummary, 0, len(recipes))
1043	for _, recipe := range recipes {
1044		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
1045	}
1046
1047	return summaries
1048}
1049
1050// ReadArguments contains read tool arguments.
1051type ReadArguments struct {
1052	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
1053	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
1054	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
1055	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
1056	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
1057}
1058
1059// ReadOutput is the structured output for the current read tool slice.
1060type ReadOutput struct {
1061	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
1062	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
1063	Recipes             []RecipeSummary `json:"recipes,omitempty"`
1064	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
1065}
1066
1067// RecipeSummary is the MCP-safe saved recipe summary.
1068type RecipeSummary struct {
1069	ID    string `json:"id"`
1070	Title string `json:"title"`
1071}
1072
1073// RecipeDetail is the MCP-safe single recipe output.
1074type RecipeDetail struct {
1075	ID             string `json:"id"`
1076	Title          string `json:"title"`
1077	Owner          string `json:"owner"`
1078	EditPermission bool   `json:"edit_permission"`
1079	Content        string `json:"content"`
1080	Portions       int    `json:"portions"`
1081}
1082
1083// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
1084type PreviewRecipeTextArguments struct {
1085	Title string `json:"title" jsonschema:"Recipe title for the preview."`
1086	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
1087}
1088
1089// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
1090type PreviewRecipeTextOutput struct {
1091	Title    string `json:"title"`
1092	Markdown string `json:"markdown"`
1093	Portions int    `json:"portions"`
1094}
1095
1096// SaveRecipeArguments contains save_recipe tool arguments.
1097type SaveRecipeArguments struct {
1098	Source   string `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
1099	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
1100	DraftID  string `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
1101	Title    string `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
1102	Text     string `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
1103	URL      string `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
1104	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."`
1105	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
1106}
1107
1108// SaveRecipeOutput is the structured output for save_recipe.
1109type SaveRecipeOutput struct {
1110	RecipeID string `json:"recipe_id,omitempty"`
1111	DraftID  string `json:"draft_id,omitempty"`
1112	Title    string `json:"title,omitempty"`
1113	Markdown string `json:"markdown,omitempty"`
1114	Portions int    `json:"portions,omitempty"`
1115}
1116
1117// DeleteRecipeArguments contains delete_recipe tool arguments.
1118type DeleteRecipeArguments struct {
1119	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
1120}
1121
1122// DeleteRecipeOutput is the structured output for delete_recipe.
1123type DeleteRecipeOutput struct {
1124	RecipeID string `json:"recipe_id"`
1125}
1126
1127// ChangeShoppingListArguments contains change_shopping_list tool arguments.
1128type ChangeShoppingListArguments struct {
1129	Action          string   `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
1130	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
1131	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
1132	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
1133	ProductGroupID  string   `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
1134	Name            *string  `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
1135	Quantity        *string  `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
1136	AisleID         *string  `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
1137	Selected        *bool    `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
1138}
1139
1140// ChangeShoppingListOutput is the structured output for change_shopping_list.
1141type ChangeShoppingListOutput struct {
1142	Cleared                 bool     `json:"cleared,omitempty"`
1143	AddedCount              int      `json:"added_count,omitempty"`
1144	Ingredients             []string `json:"ingredients,omitempty"`
1145	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
1146	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
1147	UpdatedProductGroupID   string   `json:"updated_product_group_id,omitempty"`
1148}