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)
23
24// Backend provides the Cooked operations exposed as MCP tools.
25type Backend interface {
26 ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
27 ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
28 SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
29 ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
30 ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
31 PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
32}
33
34// Server exposes Cooked as an MCP server.
35type Server struct {
36 backend Backend
37 sdk *sdk.Server
38}
39
40// NewServer returns an MCP server for a Cooked backend.
41func NewServer(backend Backend, version string) *Server {
42 server := &Server{backend: backend}
43 server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
44 sdk.AddTool(server.sdk, readTool(), server.callReadTool)
45 sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
46
47 return server
48}
49
50// RunStdio serves MCP over the official SDK stdio transport.
51func (s *Server) RunStdio(ctx context.Context) error {
52 return s.sdk.Run(ctx, &sdk.StdioTransport{})
53}
54
55// CallReadTool invokes the read tool without asserting MCP wire presentation.
56func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
57 _, output, err := s.callReadTool(ctx, nil, arguments)
58 if err != nil {
59 return ReadOutput{}, err
60 }
61
62 return output, nil
63}
64
65func (s *Server) callReadTool(
66 ctx context.Context,
67 _ *sdk.CallToolRequest,
68 arguments ReadArguments,
69) (*sdk.CallToolResult, ReadOutput, error) {
70 switch arguments.Target {
71 case "shopping_list":
72 return s.callShoppingListReadTool(ctx)
73 case "recipe":
74 return s.callRecipeReadTool(ctx, arguments.RecipeID)
75 case "recipes":
76 return s.callRecipesReadTool(ctx, arguments)
77 default:
78 return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
79 }
80}
81
82func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
83 shoppingList, err := s.backend.ReadShoppingList(ctx)
84 if err != nil {
85 return nil, ReadOutput{}, err
86 }
87
88 output := ReadOutput{
89 Aisles: shoppingList.Aisles,
90 ShoppingListRecipes: shoppingList.Recipes,
91 }
92
93 return &sdk.CallToolResult{
94 Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
95 }, output, nil
96}
97
98func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
99 recipeID := strings.TrimSpace(rawRecipeID)
100 if recipeID == "" {
101 return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
102 }
103
104 metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
105 if err != nil {
106 return nil, ReadOutput{}, err
107 }
108 content, err := s.backend.ReadRecipeContent(ctx, recipeID)
109 if err != nil {
110 return nil, ReadOutput{}, err
111 }
112
113 recipe := RecipeDetail{
114 ID: recipeID,
115 Title: metadata.Title,
116 Owner: metadata.Owner,
117 EditPermission: metadata.EditPermission,
118 Content: content.Content,
119 Portions: content.Portions,
120 }
121 output := ReadOutput{Recipe: &recipe}
122
123 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
124}
125
126func (s *Server) callRecipesReadTool(
127 ctx context.Context,
128 arguments ReadArguments,
129) (*sdk.CallToolResult, ReadOutput, error) {
130 page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
131 recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
132 if err != nil {
133 return nil, ReadOutput{}, err
134 }
135
136 output := ReadOutput{Recipes: recipeSummaries(recipes)}
137
138 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
139}
140
141func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
142 if query == "" {
143 return s.backend.ListRecipes(ctx, page, limit)
144 }
145
146 recipes, err := s.backend.SearchRecipes(ctx, query, page)
147 if err != nil {
148 return nil, err
149 }
150 if len(recipes) > limit {
151 recipes = recipes[:limit]
152 }
153
154 return recipes, nil
155}
156
157func (s *Server) callPreviewRecipeTextTool(
158 ctx context.Context,
159 _ *sdk.CallToolRequest,
160 arguments PreviewRecipeTextArguments,
161) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
162 title := strings.TrimSpace(arguments.Title)
163 if title == "" {
164 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
165 }
166 if strings.TrimSpace(arguments.Text) == "" {
167 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
168 }
169
170 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
171 if err != nil {
172 return nil, PreviewRecipeTextOutput{}, err
173 }
174
175 output := PreviewRecipeTextOutput{
176 Title: preview.Title,
177 Markdown: preview.Markdown,
178 Portions: preview.Portions,
179 }
180
181 return &sdk.CallToolResult{
182 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
183 }, output, nil
184}
185
186func readTool() *sdk.Tool {
187 openWorld := true
188 return &sdk.Tool{
189 Name: readToolName,
190 Title: "Read Cooked data",
191 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.",
192 Annotations: &sdk.ToolAnnotations{
193 Title: "Read Cooked data",
194 ReadOnlyHint: true,
195 OpenWorldHint: &openWorld,
196 },
197 }
198}
199
200func previewRecipeTextTool() *sdk.Tool {
201 openWorld := true
202 return &sdk.Tool{
203 Name: previewRecipeTextToolName,
204 Title: "Preview recipe text",
205 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
206 Annotations: &sdk.ToolAnnotations{
207 Title: "Preview recipe text",
208 ReadOnlyHint: true,
209 OpenWorldHint: &openWorld,
210 },
211 }
212}
213
214func normalizeRecipePage(page, limit int) (int, int) {
215 if page < 1 {
216 page = 1
217 }
218 if limit < 1 {
219 limit = 10
220 }
221 if limit > 30 {
222 limit = 30
223 }
224
225 return page, limit
226}
227
228func formatShoppingList(shoppingList cooked.ShoppingList) string {
229 if len(shoppingList.Aisles) == 0 {
230 return "Shopping list is empty."
231 }
232
233 var builder strings.Builder
234 builder.WriteString("Shopping list:\n")
235 for _, aisle := range shoppingList.Aisles {
236 builder.WriteString("- ")
237 builder.WriteString(aisle.Name)
238 builder.WriteString(":")
239 if len(aisle.ProductGroups) == 0 {
240 builder.WriteString(" no items\n")
241 continue
242 }
243 builder.WriteByte('\n')
244
245 for _, product := range aisle.ProductGroups {
246 builder.WriteString(" - ")
247 builder.WriteString(product.Name)
248 if product.Quantity != "" {
249 builder.WriteString(" — ")
250 builder.WriteString(product.Quantity)
251 }
252 builder.WriteString(" (id: ")
253 builder.WriteString(product.ID)
254 builder.WriteString(")")
255 if product.Selected {
256 builder.WriteString(" selected")
257 }
258 builder.WriteByte('\n')
259 }
260 }
261
262 return strings.TrimRight(builder.String(), "\n")
263}
264
265func formatRecipes(recipes []cooked.RecipeCard) string {
266 if len(recipes) == 0 {
267 return "No saved recipes found."
268 }
269
270 var builder strings.Builder
271 builder.WriteString("Saved recipes:\n")
272 for _, recipe := range recipes {
273 builder.WriteString("- ")
274 builder.WriteString(recipe.Title)
275 builder.WriteString(" (id: ")
276 builder.WriteString(recipe.ID)
277 builder.WriteString(")\n")
278 }
279
280 return strings.TrimRight(builder.String(), "\n")
281}
282
283func formatRecipe(recipe RecipeDetail) string {
284 title := recipe.Title
285 if title == "" {
286 title = "(untitled recipe)"
287 }
288
289 var builder strings.Builder
290 builder.WriteString("Recipe: ")
291 builder.WriteString(title)
292 builder.WriteString(" (id: ")
293 builder.WriteString(recipe.ID)
294 builder.WriteString(")\n")
295 if recipe.Owner != "" {
296 builder.WriteString("Owner: ")
297 builder.WriteString(recipe.Owner)
298 builder.WriteByte('\n')
299 }
300 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
301 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
302
303 content := strings.TrimSpace(recipe.Content)
304 if content == "" {
305 builder.WriteString("\nNo recipe content returned.")
306 } else {
307 builder.WriteString("\n")
308 builder.WriteString(content)
309 }
310
311 return strings.TrimRight(builder.String(), "\n")
312}
313
314func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
315 title := preview.Title
316 if title == "" {
317 title = "(untitled preview)"
318 }
319
320 var builder strings.Builder
321 builder.WriteString("Recipe text preview: ")
322 builder.WriteString(title)
323 builder.WriteByte('\n')
324 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
325
326 markdown := strings.TrimSpace(preview.Markdown)
327 if markdown == "" {
328 builder.WriteString("\nNo preview markdown returned.")
329 } else {
330 builder.WriteString("\n")
331 builder.WriteString(markdown)
332 }
333
334 return strings.TrimRight(builder.String(), "\n")
335}
336
337func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
338 summaries := make([]RecipeSummary, 0, len(recipes))
339 for _, recipe := range recipes {
340 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
341 }
342
343 return summaries
344}
345
346// ReadArguments contains read tool arguments.
347type ReadArguments struct {
348 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
349 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
350 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
351 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
352 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
353}
354
355// ReadOutput is the structured output for the current read tool slice.
356type ReadOutput struct {
357 Aisles []cooked.Aisle `json:"aisles,omitempty"`
358 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
359 Recipes []RecipeSummary `json:"recipes,omitempty"`
360 Recipe *RecipeDetail `json:"recipe,omitempty"`
361}
362
363// RecipeSummary is the MCP-safe saved recipe summary.
364type RecipeSummary struct {
365 ID string `json:"id"`
366 Title string `json:"title"`
367}
368
369// RecipeDetail is the MCP-safe single recipe output.
370type RecipeDetail struct {
371 ID string `json:"id"`
372 Title string `json:"title"`
373 Owner string `json:"owner"`
374 EditPermission bool `json:"edit_permission"`
375 Content string `json:"content"`
376 Portions int `json:"portions"`
377}
378
379// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
380type PreviewRecipeTextArguments struct {
381 Title string `json:"title" jsonschema:"Recipe title for the preview."`
382 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
383}
384
385// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
386type PreviewRecipeTextOutput struct {
387 Title string `json:"title"`
388 Markdown string `json:"markdown"`
389 Portions int `json:"portions"`
390}