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	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
 167	if err != nil {
 168		return nil, ReadOutput{}, err
 169	}
 170
 171	output := ReadOutput{Recipes: recipeSummaries(recipes)}
 172
 173	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
 174}
 175
 176func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
 177	if query == "" {
 178		return s.backend.ListRecipes(ctx, page, limit)
 179	}
 180
 181	recipes, err := s.backend.SearchRecipes(ctx, query, page)
 182	if err != nil {
 183		return nil, err
 184	}
 185	if len(recipes) > limit {
 186		recipes = recipes[:limit]
 187	}
 188
 189	return recipes, nil
 190}
 191
 192func (s *Server) callPreviewRecipeTextTool(
 193	ctx context.Context,
 194	_ *sdk.CallToolRequest,
 195	arguments PreviewRecipeTextArguments,
 196) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
 197	title := strings.TrimSpace(arguments.Title)
 198	if title == "" {
 199		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
 200	}
 201	if strings.TrimSpace(arguments.Text) == "" {
 202		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
 203	}
 204
 205	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
 206	if err != nil {
 207		return nil, PreviewRecipeTextOutput{}, err
 208	}
 209
 210	output := PreviewRecipeTextOutput{
 211		Title:    preview.Title,
 212		Markdown: preview.Markdown,
 213		Portions: preview.Portions,
 214	}
 215
 216	return &sdk.CallToolResult{
 217		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
 218	}, output, nil
 219}
 220
 221func (s *Server) callSaveRecipeTool(
 222	ctx context.Context,
 223	_ *sdk.CallToolRequest,
 224	arguments SaveRecipeArguments,
 225) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 226	source := strings.TrimSpace(arguments.Source)
 227	switch source {
 228	case "raw_text":
 229		return s.callRawTextSaveRecipeTool(ctx, arguments)
 230	case "prepared":
 231		return s.callPreparedSaveRecipeTool(ctx, arguments)
 232	case "url":
 233		return s.callURLSaveRecipeTool(ctx, arguments)
 234	case "draft":
 235		return s.callDraftSaveRecipeTool(ctx, arguments)
 236	case "existing":
 237		return s.callExistingSaveRecipeTool(ctx, arguments)
 238	case "":
 239		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
 240	default:
 241		return nil, SaveRecipeOutput{}, fmt.Errorf(
 242			"unsupported save_recipe source %q (supported: raw_text, prepared, url, draft, existing)",
 243			source,
 244		)
 245	}
 246}
 247
 248func (s *Server) callRawTextSaveRecipeTool(
 249	ctx context.Context,
 250	arguments SaveRecipeArguments,
 251) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 252	title := strings.TrimSpace(arguments.Title)
 253	if title == "" {
 254		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
 255	}
 256	if strings.TrimSpace(arguments.Text) == "" {
 257		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
 258	}
 259
 260	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
 261	if err != nil {
 262		return nil, SaveRecipeOutput{}, err
 263	}
 264	saveTitle := strings.TrimSpace(preview.Title)
 265	if saveTitle == "" {
 266		saveTitle = title
 267	}
 268	if strings.TrimSpace(preview.Markdown) == "" {
 269		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
 270	}
 271	if preview.Portions < 1 {
 272		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
 273	}
 274
 275	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
 276}
 277
 278func (s *Server) callPreparedSaveRecipeTool(
 279	ctx context.Context,
 280	arguments SaveRecipeArguments,
 281) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 282	title := strings.TrimSpace(arguments.Title)
 283	if title == "" {
 284		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
 285	}
 286	if err := validateSaveRecipeContent("prepared", arguments.Markdown, arguments.Portions); err != nil {
 287		return nil, SaveRecipeOutput{}, err
 288	}
 289
 290	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
 291}
 292
 293func (s *Server) callURLSaveRecipeTool(
 294	ctx context.Context,
 295	arguments SaveRecipeArguments,
 296) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 297	recipeURL := strings.TrimSpace(arguments.URL)
 298	if recipeURL == "" {
 299		return nil, SaveRecipeOutput{}, fmt.Errorf("url is required when source is url")
 300	}
 301
 302	imported, err := s.backend.ImportRecipeURL(ctx, recipeURL)
 303	if err != nil {
 304		return nil, SaveRecipeOutput{}, err
 305	}
 306
 307	recipeID := strings.TrimSpace(imported.RecipeID)
 308	if recipeID != "" {
 309		return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
 310	}
 311
 312	draftID := strings.TrimSpace(imported.DraftID)
 313	if draftID == "" {
 314		return nil, SaveRecipeOutput{}, fmt.Errorf("import recipe URL response missing recipe or draft ID")
 315	}
 316
 317	metadata, content, err := s.readRecipeParts(ctx, draftID)
 318	if err != nil {
 319		return nil, SaveRecipeOutput{}, err
 320	}
 321
 322	output := SaveRecipeOutput{
 323		DraftID:  draftID,
 324		Title:    metadata.Title,
 325		Markdown: content.Content,
 326		Portions: content.Portions,
 327	}
 328
 329	return &sdk.CallToolResult{
 330		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeURLImportDraft(output)}},
 331	}, output, nil
 332}
 333
 334func (s *Server) callDraftSaveRecipeTool(
 335	ctx context.Context,
 336	arguments SaveRecipeArguments,
 337) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 338	draftID := strings.TrimSpace(arguments.DraftID)
 339	if draftID == "" {
 340		return nil, SaveRecipeOutput{}, fmt.Errorf("draft_id is required when source is draft")
 341	}
 342	if err := validateSaveRecipeContent("draft", arguments.Markdown, arguments.Portions); err != nil {
 343		return nil, SaveRecipeOutput{}, err
 344	}
 345
 346	recipeID, err := s.backend.SaveRecipeDraft(ctx, draftID, arguments.Markdown, arguments.Portions)
 347	if err != nil {
 348		return nil, SaveRecipeOutput{}, err
 349	}
 350	if strings.TrimSpace(recipeID) == "" {
 351		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe draft response missing recipe ID")
 352	}
 353
 354	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
 355}
 356
 357func (s *Server) callExistingSaveRecipeTool(
 358	ctx context.Context,
 359	arguments SaveRecipeArguments,
 360) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 361	recipeID := strings.TrimSpace(arguments.RecipeID)
 362	if recipeID == "" {
 363		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
 364	}
 365	if err := validateSaveRecipeContent("existing", arguments.Markdown, arguments.Portions); err != nil {
 366		return nil, SaveRecipeOutput{}, err
 367	}
 368
 369	if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
 370		return nil, SaveRecipeOutput{}, err
 371	}
 372
 373	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
 374}
 375
 376func validateSaveRecipeContent(source, markdown string, portions int) error {
 377	if strings.TrimSpace(markdown) == "" {
 378		return fmt.Errorf("markdown is required when source is %s", source)
 379	}
 380	if portions < 1 {
 381		return fmt.Errorf("portions is required when source is %s", source)
 382	}
 383
 384	return nil
 385}
 386
 387func (s *Server) savePreparedRecipe(
 388	ctx context.Context,
 389	title, markdown string,
 390	portions int,
 391) (*sdk.CallToolResult, SaveRecipeOutput, error) {
 392	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
 393	if err != nil {
 394		return nil, SaveRecipeOutput{}, err
 395	}
 396	if strings.TrimSpace(recipeID) == "" {
 397		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
 398	}
 399
 400	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
 401}
 402
 403func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
 404	output := SaveRecipeOutput{RecipeID: recipeID}
 405
 406	return &sdk.CallToolResult{
 407		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
 408	}
 409}
 410
 411func (s *Server) callDeleteRecipeTool(
 412	ctx context.Context,
 413	_ *sdk.CallToolRequest,
 414	arguments DeleteRecipeArguments,
 415) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
 416	recipeID := strings.TrimSpace(arguments.RecipeID)
 417	if recipeID == "" {
 418		return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
 419	}
 420
 421	if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
 422		return nil, DeleteRecipeOutput{}, err
 423	}
 424
 425	output := DeleteRecipeOutput{RecipeID: recipeID}
 426
 427	return &sdk.CallToolResult{
 428		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
 429	}, output, nil
 430}
 431
 432func (s *Server) callChangeShoppingListTool(
 433	ctx context.Context,
 434	_ *sdk.CallToolRequest,
 435	arguments ChangeShoppingListArguments,
 436) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 437	action := strings.TrimSpace(arguments.Action)
 438	switch action {
 439	case "add":
 440		return s.addShoppingListIngredients(ctx, arguments)
 441	case "clear":
 442		return s.clearShoppingList(ctx)
 443	case "remove":
 444		return s.removeShoppingListProductGroups(ctx, arguments)
 445	case "replace_selection":
 446		return s.replaceShoppingListSelection(ctx, arguments)
 447	case "add_selection":
 448		return s.addShoppingListSelection(ctx, arguments)
 449	case "remove_selection":
 450		return s.removeShoppingListSelection(ctx, arguments)
 451	case "update_item":
 452		return s.updateShoppingListProductGroup(ctx, arguments)
 453	case "":
 454		return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
 455	default:
 456		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 457			"unsupported change_shopping_list action %q (supported: add, update_item, replace_selection, add_selection, remove_selection, remove, clear)",
 458			action,
 459		)
 460	}
 461}
 462
 463func (s *Server) addShoppingListIngredients(
 464	ctx context.Context,
 465	arguments ChangeShoppingListArguments,
 466) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 467	if strings.TrimSpace(arguments.Ingredients) == "" {
 468		return nil, ChangeShoppingListOutput{}, fmt.Errorf("ingredients is required when action is add")
 469	}
 470
 471	result, err := s.backend.AddShoppingListIngredients(
 472		ctx,
 473		arguments.Ingredients,
 474		strings.TrimSpace(arguments.RecipeID),
 475	)
 476	if err != nil {
 477		return nil, ChangeShoppingListOutput{}, err
 478	}
 479
 480	output := ChangeShoppingListOutput{AddedCount: result.AddedCount, Ingredients: result.Ingredients}
 481
 482	return &sdk.CallToolResult{
 483		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
 484			"Added %d shopping-list ingredients.",
 485			result.AddedCount,
 486		)}},
 487	}, output, nil
 488}
 489
 490func (s *Server) clearShoppingList(ctx context.Context) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 491	if err := s.backend.ClearShoppingList(ctx); err != nil {
 492		return nil, ChangeShoppingListOutput{}, err
 493	}
 494
 495	output := ChangeShoppingListOutput{Cleared: true}
 496
 497	return &sdk.CallToolResult{
 498		Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
 499	}, output, nil
 500}
 501
 502func (s *Server) removeShoppingListProductGroups(
 503	ctx context.Context,
 504	arguments ChangeShoppingListArguments,
 505) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 506	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 507	if len(ids) == 0 {
 508		return nil, ChangeShoppingListOutput{}, fmt.Errorf("product_group_ids is required when action is remove")
 509	}
 510
 511	if err := s.backend.RemoveShoppingListProductGroups(ctx, ids); err != nil {
 512		return nil, ChangeShoppingListOutput{}, err
 513	}
 514
 515	output := ChangeShoppingListOutput{RemovedProductGroupIDs: ids}
 516
 517	return &sdk.CallToolResult{
 518		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
 519			"Removed %d shopping-list product groups.",
 520			len(ids),
 521		)}},
 522	}, output, nil
 523}
 524
 525func (s *Server) replaceShoppingListSelection(
 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 replace_selection",
 533		)
 534	}
 535
 536	return s.setShoppingListSelection(ctx, ids)
 537}
 538
 539func (s *Server) addShoppingListSelection(
 540	ctx context.Context,
 541	arguments ChangeShoppingListArguments,
 542) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 543	ids := normalizeProductGroupIDs(arguments.ProductGroupIDs)
 544	if len(ids) == 0 {
 545		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 546			"product_group_ids is required when action is add_selection",
 547		)
 548	}
 549
 550	shoppingList, err := s.backend.ReadShoppingList(ctx)
 551	if err != nil {
 552		return nil, ChangeShoppingListOutput{}, err
 553	}
 554
 555	return s.setShoppingListSelection(
 556		ctx,
 557		appendMissingProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 558	)
 559}
 560
 561func (s *Server) removeShoppingListSelection(
 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 remove_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		subtractProductGroupIDs(selectedProductGroupIDs(shoppingList), ids),
 580	)
 581}
 582
 583func (s *Server) setShoppingListSelection(
 584	ctx context.Context,
 585	ids []string,
 586) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 587	if err := s.backend.ReplaceShoppingListSelection(ctx, ids); err != nil {
 588		return nil, ChangeShoppingListOutput{}, err
 589	}
 590
 591	output := ChangeShoppingListOutput{SelectedProductGroupIDs: ids}
 592
 593	return &sdk.CallToolResult{
 594		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
 595			"Selected %d shopping-list product groups.",
 596			len(ids),
 597		)}},
 598	}, output, nil
 599}
 600
 601func (s *Server) updateShoppingListProductGroup(
 602	ctx context.Context,
 603	arguments ChangeShoppingListArguments,
 604) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
 605	productGroupID := strings.TrimSpace(arguments.ProductGroupID)
 606	if productGroupID == "" {
 607		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 608			"product_group_id is required when action is update_item",
 609		)
 610	}
 611
 612	shoppingList, err := s.backend.ReadShoppingList(ctx)
 613	if err != nil {
 614		return nil, ChangeShoppingListOutput{}, err
 615	}
 616
 617	product, aisleID, ok := findShoppingListProductGroup(shoppingList, productGroupID)
 618	if !ok {
 619		return nil, ChangeShoppingListOutput{}, fmt.Errorf(
 620			"product_group_id %q was not found in the current shopping list",
 621			productGroupID,
 622		)
 623	}
 624
 625	update := cooked.ShoppingListProductGroupUpdate{
 626		Name:     product.Name,
 627		Quantity: product.Quantity,
 628		AisleID:  aisleID,
 629		Selected: product.Selected,
 630	}
 631	if arguments.Name != nil {
 632		update.Name = *arguments.Name
 633	}
 634	if arguments.Quantity != nil {
 635		update.Quantity = *arguments.Quantity
 636	}
 637	if arguments.AisleID != nil {
 638		update.AisleID = strings.TrimSpace(*arguments.AisleID)
 639	}
 640	if arguments.Selected != nil {
 641		update.Selected = *arguments.Selected
 642	}
 643
 644	if err := s.backend.UpdateShoppingListProductGroup(ctx, productGroupID, update); err != nil {
 645		return nil, ChangeShoppingListOutput{}, err
 646	}
 647
 648	output := ChangeShoppingListOutput{UpdatedProductGroupID: productGroupID}
 649
 650	return &sdk.CallToolResult{
 651		Content: []sdk.Content{&sdk.TextContent{Text: fmt.Sprintf(
 652			"Updated shopping-list product group (id: %s).",
 653			productGroupID,
 654		)}},
 655	}, output, nil
 656}
 657
 658func readTool() *sdk.Tool {
 659	openWorld := true
 660	return &sdk.Tool{
 661		Name:        readToolName,
 662		Title:       "Read Cooked data",
 663		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.",
 664		Annotations: &sdk.ToolAnnotations{
 665			Title:         "Read Cooked data",
 666			ReadOnlyHint:  true,
 667			OpenWorldHint: &openWorld,
 668		},
 669	}
 670}
 671
 672func previewRecipeTextTool() *sdk.Tool {
 673	openWorld := true
 674	return &sdk.Tool{
 675		Name:        previewRecipeTextToolName,
 676		Title:       "Preview recipe text",
 677		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
 678		Annotations: &sdk.ToolAnnotations{
 679			Title:         "Preview recipe text",
 680			ReadOnlyHint:  true,
 681			OpenWorldHint: &openWorld,
 682		},
 683	}
 684}
 685
 686func saveRecipeTool() *sdk.Tool {
 687	openWorld := true
 688	return &sdk.Tool{
 689		Name:        saveRecipeToolName,
 690		Title:       "Save recipe",
 691		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.",
 692		Annotations: &sdk.ToolAnnotations{
 693			Title:         "Save recipe",
 694			OpenWorldHint: &openWorld,
 695		},
 696	}
 697}
 698
 699func deleteRecipeTool() *sdk.Tool {
 700	destructive := true
 701	openWorld := true
 702	return &sdk.Tool{
 703		Name:        deleteRecipeToolName,
 704		Title:       "Delete recipe",
 705		Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
 706		Annotations: &sdk.ToolAnnotations{
 707			Title:           "Delete recipe",
 708			DestructiveHint: &destructive,
 709			OpenWorldHint:   &openWorld,
 710		},
 711	}
 712}
 713
 714func changeShoppingListTool() *sdk.Tool {
 715	destructive := true
 716	openWorld := true
 717	return &sdk.Tool{
 718		Name:        changeShoppingListToolName,
 719		Title:       "Change shopping list",
 720		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.",
 721		Annotations: &sdk.ToolAnnotations{
 722			Title:           "Change shopping list",
 723			DestructiveHint: &destructive,
 724			OpenWorldHint:   &openWorld,
 725		},
 726	}
 727}
 728
 729func normalizeRecipePage(page, limit int) (int, int) {
 730	if page < 1 {
 731		page = 1
 732	}
 733	if limit < 1 {
 734		limit = 10
 735	}
 736	if limit > 30 {
 737		limit = 30
 738	}
 739
 740	return page, limit
 741}
 742
 743func normalizeProductGroupIDs(ids []string) []string {
 744	normalized := make([]string, 0, len(ids))
 745	for _, id := range ids {
 746		id = strings.TrimSpace(id)
 747		if id != "" {
 748			normalized = append(normalized, id)
 749		}
 750	}
 751
 752	return normalized
 753}
 754
 755func selectedProductGroupIDs(shoppingList cooked.ShoppingList) []string {
 756	var ids []string
 757	for _, aisle := range shoppingList.Aisles {
 758		for _, product := range aisle.ProductGroups {
 759			if product.Selected {
 760				ids = append(ids, product.ID)
 761			}
 762		}
 763	}
 764
 765	return ids
 766}
 767
 768func appendMissingProductGroupIDs(existing, additions []string) []string {
 769	seen := make(map[string]struct{}, len(existing)+len(additions))
 770	result := make([]string, 0, len(existing)+len(additions))
 771
 772	for _, id := range existing {
 773		if _, ok := seen[id]; ok {
 774			continue
 775		}
 776		seen[id] = struct{}{}
 777		result = append(result, id)
 778	}
 779
 780	for _, id := range additions {
 781		if _, ok := seen[id]; ok {
 782			continue
 783		}
 784		seen[id] = struct{}{}
 785		result = append(result, id)
 786	}
 787
 788	return result
 789}
 790
 791func subtractProductGroupIDs(existing, removals []string) []string {
 792	removed := make(map[string]struct{}, len(removals))
 793	for _, id := range removals {
 794		removed[id] = struct{}{}
 795	}
 796
 797	seen := make(map[string]struct{}, len(existing))
 798	result := make([]string, 0, len(existing))
 799	for _, id := range existing {
 800		if _, ok := removed[id]; ok {
 801			continue
 802		}
 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 findShoppingListProductGroup(
 814	shoppingList cooked.ShoppingList,
 815	productGroupID string,
 816) (cooked.ProductGroup, string, bool) {
 817	for _, aisle := range shoppingList.Aisles {
 818		for _, product := range aisle.ProductGroups {
 819			if product.ID == productGroupID {
 820				return product, aisle.ID, true
 821			}
 822		}
 823	}
 824
 825	return cooked.ProductGroup{}, "", false
 826}
 827
 828func formatShoppingList(shoppingList cooked.ShoppingList) string {
 829	if len(shoppingList.Aisles) == 0 {
 830		return "Shopping list is empty."
 831	}
 832
 833	var builder strings.Builder
 834	builder.WriteString("Shopping list:\n")
 835	for _, aisle := range shoppingList.Aisles {
 836		builder.WriteString("- ")
 837		builder.WriteString(aisle.Name)
 838		builder.WriteString(":")
 839		if len(aisle.ProductGroups) == 0 {
 840			builder.WriteString(" no items\n")
 841			continue
 842		}
 843		builder.WriteByte('\n')
 844
 845		for _, product := range aisle.ProductGroups {
 846			builder.WriteString("  - ")
 847			builder.WriteString(product.Name)
 848			if product.Quantity != "" {
 849				builder.WriteString(" — ")
 850				builder.WriteString(product.Quantity)
 851			}
 852			builder.WriteString(" (id: ")
 853			builder.WriteString(product.ID)
 854			builder.WriteString(")")
 855			if product.Selected {
 856				builder.WriteString(" selected")
 857			}
 858			builder.WriteByte('\n')
 859		}
 860	}
 861
 862	return strings.TrimRight(builder.String(), "\n")
 863}
 864
 865func formatRecipes(recipes []cooked.RecipeCard) string {
 866	if len(recipes) == 0 {
 867		return "No saved recipes found."
 868	}
 869
 870	var builder strings.Builder
 871	builder.WriteString("Saved recipes:\n")
 872	for _, recipe := range recipes {
 873		builder.WriteString("- ")
 874		builder.WriteString(recipe.Title)
 875		builder.WriteString(" (id: ")
 876		builder.WriteString(recipe.ID)
 877		builder.WriteString(")\n")
 878	}
 879
 880	return strings.TrimRight(builder.String(), "\n")
 881}
 882
 883func formatRecipe(recipe RecipeDetail) string {
 884	title := recipe.Title
 885	if title == "" {
 886		title = "(untitled recipe)"
 887	}
 888
 889	var builder strings.Builder
 890	builder.WriteString("Recipe: ")
 891	builder.WriteString(title)
 892	builder.WriteString(" (id: ")
 893	builder.WriteString(recipe.ID)
 894	builder.WriteString(")\n")
 895	if recipe.Owner != "" {
 896		builder.WriteString("Owner: ")
 897		builder.WriteString(recipe.Owner)
 898		builder.WriteByte('\n')
 899	}
 900	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
 901	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
 902
 903	content := strings.TrimSpace(recipe.Content)
 904	if content == "" {
 905		builder.WriteString("\nNo recipe content returned.")
 906	} else {
 907		builder.WriteString("\n")
 908		builder.WriteString(content)
 909	}
 910
 911	return strings.TrimRight(builder.String(), "\n")
 912}
 913
 914func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
 915	title := preview.Title
 916	if title == "" {
 917		title = "(untitled preview)"
 918	}
 919
 920	var builder strings.Builder
 921	builder.WriteString("Recipe text preview: ")
 922	builder.WriteString(title)
 923	builder.WriteByte('\n')
 924	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
 925
 926	markdown := strings.TrimSpace(preview.Markdown)
 927	if markdown == "" {
 928		builder.WriteString("\nNo preview markdown returned.")
 929	} else {
 930		builder.WriteString("\n")
 931		builder.WriteString(markdown)
 932	}
 933
 934	return strings.TrimRight(builder.String(), "\n")
 935}
 936
 937func formatRecipeSave(output SaveRecipeOutput) string {
 938	return "Saved recipe (id: " + output.RecipeID + ")."
 939}
 940
 941func formatRecipeURLImportDraft(output SaveRecipeOutput) string {
 942	title := output.Title
 943	if strings.TrimSpace(title) == "" {
 944		title = "(untitled draft)"
 945	}
 946
 947	var builder strings.Builder
 948	builder.WriteString("Imported recipe URL as draft: ")
 949	builder.WriteString(title)
 950	builder.WriteString(" (draft_id: ")
 951	builder.WriteString(output.DraftID)
 952	builder.WriteString("). Review the markdown and portions before saving.")
 953	if output.Portions > 0 {
 954		fmt.Fprintf(&builder, "\nPortions: %d", output.Portions)
 955	}
 956	if strings.TrimSpace(output.Markdown) != "" {
 957		builder.WriteString("\n\n")
 958		builder.WriteString(output.Markdown)
 959	}
 960
 961	return strings.TrimRight(builder.String(), "\n")
 962}
 963
 964func formatRecipeDelete(output DeleteRecipeOutput) string {
 965	return "Deleted recipe (id: " + output.RecipeID + ")."
 966}
 967
 968func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
 969	summaries := make([]RecipeSummary, 0, len(recipes))
 970	for _, recipe := range recipes {
 971		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
 972	}
 973
 974	return summaries
 975}
 976
 977// ReadArguments contains read tool arguments.
 978type ReadArguments struct {
 979	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
 980	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
 981	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
 982	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
 983	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
 984}
 985
 986// ReadOutput is the structured output for the current read tool slice.
 987type ReadOutput struct {
 988	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
 989	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
 990	Recipes             []RecipeSummary `json:"recipes,omitempty"`
 991	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
 992}
 993
 994// RecipeSummary is the MCP-safe saved recipe summary.
 995type RecipeSummary struct {
 996	ID    string `json:"id"`
 997	Title string `json:"title"`
 998}
 999
1000// RecipeDetail is the MCP-safe single recipe output.
1001type RecipeDetail struct {
1002	ID             string `json:"id"`
1003	Title          string `json:"title"`
1004	Owner          string `json:"owner"`
1005	EditPermission bool   `json:"edit_permission"`
1006	Content        string `json:"content"`
1007	Portions       int    `json:"portions"`
1008}
1009
1010// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
1011type PreviewRecipeTextArguments struct {
1012	Title string `json:"title" jsonschema:"Recipe title for the preview."`
1013	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
1014}
1015
1016// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
1017type PreviewRecipeTextOutput struct {
1018	Title    string `json:"title"`
1019	Markdown string `json:"markdown"`
1020	Portions int    `json:"portions"`
1021}
1022
1023// SaveRecipeArguments contains save_recipe tool arguments.
1024type SaveRecipeArguments struct {
1025	Source   string `json:"source"              jsonschema:"Recipe save source: raw_text, prepared, url, draft, or existing."`
1026	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
1027	DraftID  string `json:"draft_id,omitempty"  jsonschema:"Draft ID for reviewed URL import draft saves."`
1028	Title    string `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
1029	Text     string `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
1030	URL      string `json:"url,omitempty"       jsonschema:"Recipe URL for url imports."`
1031	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves, reviewed URL import drafts, or existing recipe updates."`
1032	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves, reviewed URL import drafts, or existing recipe updates."`
1033}
1034
1035// SaveRecipeOutput is the structured output for save_recipe.
1036type SaveRecipeOutput struct {
1037	RecipeID string `json:"recipe_id,omitempty"`
1038	DraftID  string `json:"draft_id,omitempty"`
1039	Title    string `json:"title,omitempty"`
1040	Markdown string `json:"markdown,omitempty"`
1041	Portions int    `json:"portions,omitempty"`
1042}
1043
1044// DeleteRecipeArguments contains delete_recipe tool arguments.
1045type DeleteRecipeArguments struct {
1046	RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
1047}
1048
1049// DeleteRecipeOutput is the structured output for delete_recipe.
1050type DeleteRecipeOutput struct {
1051	RecipeID string `json:"recipe_id"`
1052}
1053
1054// ChangeShoppingListArguments contains change_shopping_list tool arguments.
1055type ChangeShoppingListArguments struct {
1056	Action          string   `json:"action"                      jsonschema:"Shopping-list action: add, remove, replace_selection, add_selection, remove_selection, update_item, or clear."`
1057	Ingredients     string   `json:"ingredients,omitempty"       jsonschema:"Newline-separated ingredients for action add."`
1058	RecipeID        string   `json:"recipe_id,omitempty"         jsonschema:"Optional saved recipe ID or import draft ID for action add."`
1059	ProductGroupIDs []string `json:"product_group_ids,omitempty" jsonschema:"Product group IDs for action remove, replace_selection, add_selection, or remove_selection."`
1060	ProductGroupID  string   `json:"product_group_id,omitempty"  jsonschema:"Product group ID for action update_item."`
1061	Name            *string  `json:"name,omitempty"              jsonschema:"Replacement product name for action update_item."`
1062	Quantity        *string  `json:"quantity,omitempty"          jsonschema:"Replacement quantity for action update_item."`
1063	AisleID         *string  `json:"aisle_id,omitempty"          jsonschema:"Replacement aisle ID for action update_item."`
1064	Selected        *bool    `json:"selected,omitempty"          jsonschema:"Replacement selected state for action update_item."`
1065}
1066
1067// ChangeShoppingListOutput is the structured output for change_shopping_list.
1068type ChangeShoppingListOutput struct {
1069	Cleared                 bool     `json:"cleared,omitempty"`
1070	AddedCount              int      `json:"added_count,omitempty"`
1071	Ingredients             []string `json:"ingredients,omitempty"`
1072	RemovedProductGroupIDs  []string `json:"removed_product_group_ids,omitempty"`
1073	SelectedProductGroupIDs []string `json:"selected_product_group_ids,omitempty"`
1074	UpdatedProductGroupID   string   `json:"updated_product_group_id,omitempty"`
1075}