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