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