server.go

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