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