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