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