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)
25
26// Backend provides the Cooked operations exposed as MCP tools.
27type Backend interface {
28 ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
29 ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
30 SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
31 ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
32 ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
33 PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
34 SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error)
35 UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
36 DeleteRecipe(ctx context.Context, recipeID string) error
37}
38
39// Server exposes Cooked as an MCP server.
40type Server struct {
41 backend Backend
42 sdk *sdk.Server
43}
44
45// NewServer returns an MCP server for a Cooked backend.
46func NewServer(backend Backend, version string) *Server {
47 server := &Server{backend: backend}
48 server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
49 sdk.AddTool(server.sdk, readTool(), server.callReadTool)
50 sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
51 sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
52 sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
53
54 return server
55}
56
57// RunStdio serves MCP over the official SDK stdio transport.
58func (s *Server) RunStdio(ctx context.Context) error {
59 return s.sdk.Run(ctx, &sdk.StdioTransport{})
60}
61
62// CallReadTool invokes the read tool without asserting MCP wire presentation.
63func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
64 _, output, err := s.callReadTool(ctx, nil, arguments)
65 if err != nil {
66 return ReadOutput{}, err
67 }
68
69 return output, nil
70}
71
72func (s *Server) callReadTool(
73 ctx context.Context,
74 _ *sdk.CallToolRequest,
75 arguments ReadArguments,
76) (*sdk.CallToolResult, ReadOutput, error) {
77 switch arguments.Target {
78 case "shopping_list":
79 return s.callShoppingListReadTool(ctx)
80 case "recipe":
81 return s.callRecipeReadTool(ctx, arguments.RecipeID)
82 case "recipes":
83 return s.callRecipesReadTool(ctx, arguments)
84 default:
85 return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
86 }
87}
88
89func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
90 shoppingList, err := s.backend.ReadShoppingList(ctx)
91 if err != nil {
92 return nil, ReadOutput{}, err
93 }
94
95 output := ReadOutput{
96 Aisles: shoppingList.Aisles,
97 ShoppingListRecipes: shoppingList.Recipes,
98 }
99
100 return &sdk.CallToolResult{
101 Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
102 }, output, nil
103}
104
105func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
106 recipeID := strings.TrimSpace(rawRecipeID)
107 if recipeID == "" {
108 return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
109 }
110
111 metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
112 if err != nil {
113 return nil, ReadOutput{}, err
114 }
115 content, err := s.backend.ReadRecipeContent(ctx, recipeID)
116 if err != nil {
117 return nil, ReadOutput{}, err
118 }
119
120 recipe := RecipeDetail{
121 ID: recipeID,
122 Title: metadata.Title,
123 Owner: metadata.Owner,
124 EditPermission: metadata.EditPermission,
125 Content: content.Content,
126 Portions: content.Portions,
127 }
128 output := ReadOutput{Recipe: &recipe}
129
130 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
131}
132
133func (s *Server) callRecipesReadTool(
134 ctx context.Context,
135 arguments ReadArguments,
136) (*sdk.CallToolResult, ReadOutput, error) {
137 page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
138 recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
139 if err != nil {
140 return nil, ReadOutput{}, err
141 }
142
143 output := ReadOutput{Recipes: recipeSummaries(recipes)}
144
145 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
146}
147
148func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
149 if query == "" {
150 return s.backend.ListRecipes(ctx, page, limit)
151 }
152
153 recipes, err := s.backend.SearchRecipes(ctx, query, page)
154 if err != nil {
155 return nil, err
156 }
157 if len(recipes) > limit {
158 recipes = recipes[:limit]
159 }
160
161 return recipes, nil
162}
163
164func (s *Server) callPreviewRecipeTextTool(
165 ctx context.Context,
166 _ *sdk.CallToolRequest,
167 arguments PreviewRecipeTextArguments,
168) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
169 title := strings.TrimSpace(arguments.Title)
170 if title == "" {
171 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
172 }
173 if strings.TrimSpace(arguments.Text) == "" {
174 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
175 }
176
177 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
178 if err != nil {
179 return nil, PreviewRecipeTextOutput{}, err
180 }
181
182 output := PreviewRecipeTextOutput{
183 Title: preview.Title,
184 Markdown: preview.Markdown,
185 Portions: preview.Portions,
186 }
187
188 return &sdk.CallToolResult{
189 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
190 }, output, nil
191}
192
193func (s *Server) callSaveRecipeTool(
194 ctx context.Context,
195 _ *sdk.CallToolRequest,
196 arguments SaveRecipeArguments,
197) (*sdk.CallToolResult, SaveRecipeOutput, error) {
198 source := strings.TrimSpace(arguments.Source)
199 switch source {
200 case "raw_text":
201 return s.callRawTextSaveRecipeTool(ctx, arguments)
202 case "prepared":
203 return s.callPreparedSaveRecipeTool(ctx, arguments)
204 case "existing":
205 return s.callExistingSaveRecipeTool(ctx, arguments)
206 case "":
207 return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
208 default:
209 return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
210 }
211}
212
213func (s *Server) callRawTextSaveRecipeTool(
214 ctx context.Context,
215 arguments SaveRecipeArguments,
216) (*sdk.CallToolResult, SaveRecipeOutput, error) {
217 title := strings.TrimSpace(arguments.Title)
218 if title == "" {
219 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
220 }
221 if strings.TrimSpace(arguments.Text) == "" {
222 return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
223 }
224
225 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
226 if err != nil {
227 return nil, SaveRecipeOutput{}, err
228 }
229 saveTitle := strings.TrimSpace(preview.Title)
230 if saveTitle == "" {
231 saveTitle = title
232 }
233 if strings.TrimSpace(preview.Markdown) == "" {
234 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
235 }
236 if preview.Portions < 1 {
237 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
238 }
239
240 return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
241}
242
243func (s *Server) callPreparedSaveRecipeTool(
244 ctx context.Context,
245 arguments SaveRecipeArguments,
246) (*sdk.CallToolResult, SaveRecipeOutput, error) {
247 title := strings.TrimSpace(arguments.Title)
248 if title == "" {
249 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
250 }
251 if strings.TrimSpace(arguments.Markdown) == "" {
252 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
253 }
254 if arguments.Portions < 1 {
255 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
256 }
257
258 return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
259}
260
261func (s *Server) callExistingSaveRecipeTool(
262 ctx context.Context,
263 arguments SaveRecipeArguments,
264) (*sdk.CallToolResult, SaveRecipeOutput, error) {
265 recipeID := strings.TrimSpace(arguments.RecipeID)
266 if recipeID == "" {
267 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
268 }
269 if strings.TrimSpace(arguments.Markdown) == "" {
270 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
271 }
272 if arguments.Portions < 1 {
273 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
274 }
275
276 if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
277 return nil, SaveRecipeOutput{}, err
278 }
279
280 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
281}
282
283func (s *Server) savePreparedRecipe(
284 ctx context.Context,
285 title, markdown string,
286 portions int,
287) (*sdk.CallToolResult, SaveRecipeOutput, error) {
288 recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
289 if err != nil {
290 return nil, SaveRecipeOutput{}, err
291 }
292 if strings.TrimSpace(recipeID) == "" {
293 return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
294 }
295
296 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
297}
298
299func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
300 output := SaveRecipeOutput{RecipeID: recipeID}
301
302 return &sdk.CallToolResult{
303 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
304 }
305}
306
307func (s *Server) callDeleteRecipeTool(
308 ctx context.Context,
309 _ *sdk.CallToolRequest,
310 arguments DeleteRecipeArguments,
311) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
312 recipeID := strings.TrimSpace(arguments.RecipeID)
313 if recipeID == "" {
314 return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
315 }
316
317 if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
318 return nil, DeleteRecipeOutput{}, err
319 }
320
321 output := DeleteRecipeOutput{RecipeID: recipeID}
322
323 return &sdk.CallToolResult{
324 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
325 }, output, nil
326}
327
328func readTool() *sdk.Tool {
329 openWorld := true
330 return &sdk.Tool{
331 Name: readToolName,
332 Title: "Read Cooked data",
333 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.",
334 Annotations: &sdk.ToolAnnotations{
335 Title: "Read Cooked data",
336 ReadOnlyHint: true,
337 OpenWorldHint: &openWorld,
338 },
339 }
340}
341
342func previewRecipeTextTool() *sdk.Tool {
343 openWorld := true
344 return &sdk.Tool{
345 Name: previewRecipeTextToolName,
346 Title: "Preview recipe text",
347 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
348 Annotations: &sdk.ToolAnnotations{
349 Title: "Preview recipe text",
350 ReadOnlyHint: true,
351 OpenWorldHint: &openWorld,
352 },
353 }
354}
355
356func saveRecipeTool() *sdk.Tool {
357 openWorld := true
358 return &sdk.Tool{
359 Name: saveRecipeToolName,
360 Title: "Save recipe",
361 Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
362 Annotations: &sdk.ToolAnnotations{
363 Title: "Save recipe",
364 OpenWorldHint: &openWorld,
365 },
366 }
367}
368
369func deleteRecipeTool() *sdk.Tool {
370 destructive := true
371 openWorld := true
372 return &sdk.Tool{
373 Name: deleteRecipeToolName,
374 Title: "Delete recipe",
375 Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
376 Annotations: &sdk.ToolAnnotations{
377 Title: "Delete recipe",
378 DestructiveHint: &destructive,
379 OpenWorldHint: &openWorld,
380 },
381 }
382}
383
384func normalizeRecipePage(page, limit int) (int, int) {
385 if page < 1 {
386 page = 1
387 }
388 if limit < 1 {
389 limit = 10
390 }
391 if limit > 30 {
392 limit = 30
393 }
394
395 return page, limit
396}
397
398func formatShoppingList(shoppingList cooked.ShoppingList) string {
399 if len(shoppingList.Aisles) == 0 {
400 return "Shopping list is empty."
401 }
402
403 var builder strings.Builder
404 builder.WriteString("Shopping list:\n")
405 for _, aisle := range shoppingList.Aisles {
406 builder.WriteString("- ")
407 builder.WriteString(aisle.Name)
408 builder.WriteString(":")
409 if len(aisle.ProductGroups) == 0 {
410 builder.WriteString(" no items\n")
411 continue
412 }
413 builder.WriteByte('\n')
414
415 for _, product := range aisle.ProductGroups {
416 builder.WriteString(" - ")
417 builder.WriteString(product.Name)
418 if product.Quantity != "" {
419 builder.WriteString(" — ")
420 builder.WriteString(product.Quantity)
421 }
422 builder.WriteString(" (id: ")
423 builder.WriteString(product.ID)
424 builder.WriteString(")")
425 if product.Selected {
426 builder.WriteString(" selected")
427 }
428 builder.WriteByte('\n')
429 }
430 }
431
432 return strings.TrimRight(builder.String(), "\n")
433}
434
435func formatRecipes(recipes []cooked.RecipeCard) string {
436 if len(recipes) == 0 {
437 return "No saved recipes found."
438 }
439
440 var builder strings.Builder
441 builder.WriteString("Saved recipes:\n")
442 for _, recipe := range recipes {
443 builder.WriteString("- ")
444 builder.WriteString(recipe.Title)
445 builder.WriteString(" (id: ")
446 builder.WriteString(recipe.ID)
447 builder.WriteString(")\n")
448 }
449
450 return strings.TrimRight(builder.String(), "\n")
451}
452
453func formatRecipe(recipe RecipeDetail) string {
454 title := recipe.Title
455 if title == "" {
456 title = "(untitled recipe)"
457 }
458
459 var builder strings.Builder
460 builder.WriteString("Recipe: ")
461 builder.WriteString(title)
462 builder.WriteString(" (id: ")
463 builder.WriteString(recipe.ID)
464 builder.WriteString(")\n")
465 if recipe.Owner != "" {
466 builder.WriteString("Owner: ")
467 builder.WriteString(recipe.Owner)
468 builder.WriteByte('\n')
469 }
470 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
471 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
472
473 content := strings.TrimSpace(recipe.Content)
474 if content == "" {
475 builder.WriteString("\nNo recipe content returned.")
476 } else {
477 builder.WriteString("\n")
478 builder.WriteString(content)
479 }
480
481 return strings.TrimRight(builder.String(), "\n")
482}
483
484func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
485 title := preview.Title
486 if title == "" {
487 title = "(untitled preview)"
488 }
489
490 var builder strings.Builder
491 builder.WriteString("Recipe text preview: ")
492 builder.WriteString(title)
493 builder.WriteByte('\n')
494 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
495
496 markdown := strings.TrimSpace(preview.Markdown)
497 if markdown == "" {
498 builder.WriteString("\nNo preview markdown returned.")
499 } else {
500 builder.WriteString("\n")
501 builder.WriteString(markdown)
502 }
503
504 return strings.TrimRight(builder.String(), "\n")
505}
506
507func formatRecipeSave(output SaveRecipeOutput) string {
508 return "Saved recipe (id: " + output.RecipeID + ")."
509}
510
511func formatRecipeDelete(output DeleteRecipeOutput) string {
512 return "Deleted recipe (id: " + output.RecipeID + ")."
513}
514
515func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
516 summaries := make([]RecipeSummary, 0, len(recipes))
517 for _, recipe := range recipes {
518 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
519 }
520
521 return summaries
522}
523
524// ReadArguments contains read tool arguments.
525type ReadArguments struct {
526 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
527 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
528 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
529 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
530 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
531}
532
533// ReadOutput is the structured output for the current read tool slice.
534type ReadOutput struct {
535 Aisles []cooked.Aisle `json:"aisles,omitempty"`
536 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
537 Recipes []RecipeSummary `json:"recipes,omitempty"`
538 Recipe *RecipeDetail `json:"recipe,omitempty"`
539}
540
541// RecipeSummary is the MCP-safe saved recipe summary.
542type RecipeSummary struct {
543 ID string `json:"id"`
544 Title string `json:"title"`
545}
546
547// RecipeDetail is the MCP-safe single recipe output.
548type RecipeDetail struct {
549 ID string `json:"id"`
550 Title string `json:"title"`
551 Owner string `json:"owner"`
552 EditPermission bool `json:"edit_permission"`
553 Content string `json:"content"`
554 Portions int `json:"portions"`
555}
556
557// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
558type PreviewRecipeTextArguments struct {
559 Title string `json:"title" jsonschema:"Recipe title for the preview."`
560 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
561}
562
563// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
564type PreviewRecipeTextOutput struct {
565 Title string `json:"title"`
566 Markdown string `json:"markdown"`
567 Portions int `json:"portions"`
568}
569
570// SaveRecipeArguments contains save_recipe tool arguments.
571type SaveRecipeArguments struct {
572 Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
573 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
574 Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
575 Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
576 Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
577 Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
578}
579
580// SaveRecipeOutput is the structured output for save_recipe.
581type SaveRecipeOutput struct {
582 RecipeID string `json:"recipe_id,omitempty"`
583}
584
585// DeleteRecipeArguments contains delete_recipe tool arguments.
586type DeleteRecipeArguments struct {
587 RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
588}
589
590// DeleteRecipeOutput is the structured output for delete_recipe.
591type DeleteRecipeOutput struct {
592 RecipeID string `json:"recipe_id"`
593}