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 "prepared":
197 title := strings.TrimSpace(arguments.Title)
198 if title == "" {
199 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
200 }
201 if strings.TrimSpace(arguments.Markdown) == "" {
202 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
203 }
204 if arguments.Portions < 1 {
205 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
206 }
207
208 recipeID, err := s.backend.SavePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
209 if err != nil {
210 return nil, SaveRecipeOutput{}, err
211 }
212 if strings.TrimSpace(recipeID) == "" {
213 return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
214 }
215
216 output := SaveRecipeOutput{RecipeID: recipeID}
217
218 return &sdk.CallToolResult{
219 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
220 }, output, nil
221 case "":
222 return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
223 default:
224 return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
225 }
226}
227
228func readTool() *sdk.Tool {
229 openWorld := true
230 return &sdk.Tool{
231 Name: readToolName,
232 Title: "Read Cooked data",
233 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.",
234 Annotations: &sdk.ToolAnnotations{
235 Title: "Read Cooked data",
236 ReadOnlyHint: true,
237 OpenWorldHint: &openWorld,
238 },
239 }
240}
241
242func previewRecipeTextTool() *sdk.Tool {
243 openWorld := true
244 return &sdk.Tool{
245 Name: previewRecipeTextToolName,
246 Title: "Preview recipe text",
247 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
248 Annotations: &sdk.ToolAnnotations{
249 Title: "Preview recipe text",
250 ReadOnlyHint: true,
251 OpenWorldHint: &openWorld,
252 },
253 }
254}
255
256func saveRecipeTool() *sdk.Tool {
257 openWorld := true
258 return &sdk.Tool{
259 Name: saveRecipeToolName,
260 Title: "Save recipe",
261 Description: "Save a recipe. This slice supports source prepared with title, markdown, and portions. Other source values are reserved for later slices.",
262 Annotations: &sdk.ToolAnnotations{
263 Title: "Save recipe",
264 OpenWorldHint: &openWorld,
265 },
266 }
267}
268
269func normalizeRecipePage(page, limit int) (int, int) {
270 if page < 1 {
271 page = 1
272 }
273 if limit < 1 {
274 limit = 10
275 }
276 if limit > 30 {
277 limit = 30
278 }
279
280 return page, limit
281}
282
283func formatShoppingList(shoppingList cooked.ShoppingList) string {
284 if len(shoppingList.Aisles) == 0 {
285 return "Shopping list is empty."
286 }
287
288 var builder strings.Builder
289 builder.WriteString("Shopping list:\n")
290 for _, aisle := range shoppingList.Aisles {
291 builder.WriteString("- ")
292 builder.WriteString(aisle.Name)
293 builder.WriteString(":")
294 if len(aisle.ProductGroups) == 0 {
295 builder.WriteString(" no items\n")
296 continue
297 }
298 builder.WriteByte('\n')
299
300 for _, product := range aisle.ProductGroups {
301 builder.WriteString(" - ")
302 builder.WriteString(product.Name)
303 if product.Quantity != "" {
304 builder.WriteString(" — ")
305 builder.WriteString(product.Quantity)
306 }
307 builder.WriteString(" (id: ")
308 builder.WriteString(product.ID)
309 builder.WriteString(")")
310 if product.Selected {
311 builder.WriteString(" selected")
312 }
313 builder.WriteByte('\n')
314 }
315 }
316
317 return strings.TrimRight(builder.String(), "\n")
318}
319
320func formatRecipes(recipes []cooked.RecipeCard) string {
321 if len(recipes) == 0 {
322 return "No saved recipes found."
323 }
324
325 var builder strings.Builder
326 builder.WriteString("Saved recipes:\n")
327 for _, recipe := range recipes {
328 builder.WriteString("- ")
329 builder.WriteString(recipe.Title)
330 builder.WriteString(" (id: ")
331 builder.WriteString(recipe.ID)
332 builder.WriteString(")\n")
333 }
334
335 return strings.TrimRight(builder.String(), "\n")
336}
337
338func formatRecipe(recipe RecipeDetail) string {
339 title := recipe.Title
340 if title == "" {
341 title = "(untitled recipe)"
342 }
343
344 var builder strings.Builder
345 builder.WriteString("Recipe: ")
346 builder.WriteString(title)
347 builder.WriteString(" (id: ")
348 builder.WriteString(recipe.ID)
349 builder.WriteString(")\n")
350 if recipe.Owner != "" {
351 builder.WriteString("Owner: ")
352 builder.WriteString(recipe.Owner)
353 builder.WriteByte('\n')
354 }
355 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
356 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
357
358 content := strings.TrimSpace(recipe.Content)
359 if content == "" {
360 builder.WriteString("\nNo recipe content returned.")
361 } else {
362 builder.WriteString("\n")
363 builder.WriteString(content)
364 }
365
366 return strings.TrimRight(builder.String(), "\n")
367}
368
369func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
370 title := preview.Title
371 if title == "" {
372 title = "(untitled preview)"
373 }
374
375 var builder strings.Builder
376 builder.WriteString("Recipe text preview: ")
377 builder.WriteString(title)
378 builder.WriteByte('\n')
379 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
380
381 markdown := strings.TrimSpace(preview.Markdown)
382 if markdown == "" {
383 builder.WriteString("\nNo preview markdown returned.")
384 } else {
385 builder.WriteString("\n")
386 builder.WriteString(markdown)
387 }
388
389 return strings.TrimRight(builder.String(), "\n")
390}
391
392func formatRecipeSave(output SaveRecipeOutput) string {
393 return "Saved recipe (id: " + output.RecipeID + ")."
394}
395
396func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
397 summaries := make([]RecipeSummary, 0, len(recipes))
398 for _, recipe := range recipes {
399 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
400 }
401
402 return summaries
403}
404
405// ReadArguments contains read tool arguments.
406type ReadArguments struct {
407 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
408 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
409 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
410 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
411 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
412}
413
414// ReadOutput is the structured output for the current read tool slice.
415type ReadOutput struct {
416 Aisles []cooked.Aisle `json:"aisles,omitempty"`
417 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
418 Recipes []RecipeSummary `json:"recipes,omitempty"`
419 Recipe *RecipeDetail `json:"recipe,omitempty"`
420}
421
422// RecipeSummary is the MCP-safe saved recipe summary.
423type RecipeSummary struct {
424 ID string `json:"id"`
425 Title string `json:"title"`
426}
427
428// RecipeDetail is the MCP-safe single recipe output.
429type RecipeDetail struct {
430 ID string `json:"id"`
431 Title string `json:"title"`
432 Owner string `json:"owner"`
433 EditPermission bool `json:"edit_permission"`
434 Content string `json:"content"`
435 Portions int `json:"portions"`
436}
437
438// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
439type PreviewRecipeTextArguments struct {
440 Title string `json:"title" jsonschema:"Recipe title for the preview."`
441 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
442}
443
444// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
445type PreviewRecipeTextOutput struct {
446 Title string `json:"title"`
447 Markdown string `json:"markdown"`
448 Portions int `json:"portions"`
449}
450
451// SaveRecipeArguments contains save_recipe tool arguments.
452type SaveRecipeArguments struct {
453 Source string `json:"source" jsonschema:"Recipe save source. Supported now: prepared."`
454 Title string `json:"title,omitempty" jsonschema:"Recipe title for prepared saves."`
455 Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves."`
456 Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves."`
457}
458
459// SaveRecipeOutput is the structured output for save_recipe.
460type SaveRecipeOutput struct {
461 RecipeID string `json:"recipe_id,omitempty"`
462}