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(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
63 switch arguments.Target {
64 case "shopping_list":
65 shoppingList, err := s.backend.ReadShoppingList(ctx)
66 if err != nil {
67 return nil, ReadOutput{}, err
68 }
69
70 output := ReadOutput{
71 Aisles: shoppingList.Aisles,
72 ShoppingListRecipes: shoppingList.Recipes,
73 }
74
75 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
76 case "recipe":
77 recipeID := strings.TrimSpace(arguments.RecipeID)
78 if recipeID == "" {
79 return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
80 }
81
82 metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
83 if err != nil {
84 return nil, ReadOutput{}, err
85 }
86 content, err := s.backend.ReadRecipeContent(ctx, recipeID)
87 if err != nil {
88 return nil, ReadOutput{}, err
89 }
90
91 recipe := RecipeDetail{
92 ID: recipeID,
93 Title: metadata.Title,
94 Owner: metadata.Owner,
95 EditPermission: metadata.EditPermission,
96 Content: content.Content,
97 Portions: content.Portions,
98 }
99 output := ReadOutput{Recipe: &recipe}
100
101 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
102 case "recipes":
103 page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
104 query := strings.TrimSpace(arguments.Query)
105
106 var (
107 recipes []cooked.RecipeCard
108 err error
109 )
110 if query == "" {
111 recipes, err = s.backend.ListRecipes(ctx, page, limit)
112 } else {
113 recipes, err = s.backend.SearchRecipes(ctx, query, page)
114 if len(recipes) > limit {
115 recipes = recipes[:limit]
116 }
117 }
118 if err != nil {
119 return nil, ReadOutput{}, err
120 }
121
122 output := ReadOutput{Recipes: recipeSummaries(recipes)}
123
124 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
125 default:
126 return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
127 }
128}
129
130func readTool() *sdk.Tool {
131 openWorld := true
132 return &sdk.Tool{
133 Name: readToolName,
134 Title: "Read Cooked data",
135 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.",
136 Annotations: &sdk.ToolAnnotations{
137 Title: "Read Cooked data",
138 ReadOnlyHint: true,
139 OpenWorldHint: &openWorld,
140 },
141 }
142}
143
144func normalizeRecipePage(page, limit int) (int, int) {
145 if page < 1 {
146 page = 1
147 }
148 if limit < 1 {
149 limit = 10
150 }
151 if limit > 30 {
152 limit = 30
153 }
154
155 return page, limit
156}
157func formatShoppingList(shoppingList cooked.ShoppingList) string {
158 if len(shoppingList.Aisles) == 0 {
159 return "Shopping list is empty."
160 }
161
162 var builder strings.Builder
163 builder.WriteString("Shopping list:\n")
164 for _, aisle := range shoppingList.Aisles {
165 builder.WriteString("- ")
166 builder.WriteString(aisle.Name)
167 builder.WriteString(":")
168 if len(aisle.ProductGroups) == 0 {
169 builder.WriteString(" no items\n")
170 continue
171 }
172 builder.WriteByte('\n')
173
174 for _, product := range aisle.ProductGroups {
175 builder.WriteString(" - ")
176 builder.WriteString(product.Name)
177 if product.Quantity != "" {
178 builder.WriteString(" — ")
179 builder.WriteString(product.Quantity)
180 }
181 builder.WriteString(" (id: ")
182 builder.WriteString(product.ID)
183 builder.WriteString(")")
184 if product.Selected {
185 builder.WriteString(" selected")
186 }
187 builder.WriteByte('\n')
188 }
189 }
190
191 return strings.TrimRight(builder.String(), "\n")
192}
193
194func formatRecipes(recipes []cooked.RecipeCard) string {
195 if len(recipes) == 0 {
196 return "No saved recipes found."
197 }
198
199 var builder strings.Builder
200 builder.WriteString("Saved recipes:\n")
201 for _, recipe := range recipes {
202 builder.WriteString("- ")
203 builder.WriteString(recipe.Title)
204 builder.WriteString(" (id: ")
205 builder.WriteString(recipe.ID)
206 builder.WriteString(")\n")
207 }
208
209 return strings.TrimRight(builder.String(), "\n")
210}
211
212func formatRecipe(recipe RecipeDetail) string {
213 title := recipe.Title
214 if title == "" {
215 title = "(untitled recipe)"
216 }
217
218 var builder strings.Builder
219 builder.WriteString("Recipe: ")
220 builder.WriteString(title)
221 builder.WriteString(" (id: ")
222 builder.WriteString(recipe.ID)
223 builder.WriteString(")\n")
224 if recipe.Owner != "" {
225 builder.WriteString("Owner: ")
226 builder.WriteString(recipe.Owner)
227 builder.WriteByte('\n')
228 }
229 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
230 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
231
232 content := strings.TrimSpace(recipe.Content)
233 if content == "" {
234 builder.WriteString("\nNo recipe content returned.")
235 } else {
236 builder.WriteString("\n")
237 builder.WriteString(content)
238 }
239
240 return strings.TrimRight(builder.String(), "\n")
241}
242
243func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
244 summaries := make([]RecipeSummary, 0, len(recipes))
245 for _, recipe := range recipes {
246 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
247 }
248
249 return summaries
250}
251
252// ReadArguments contains read tool arguments.
253type ReadArguments struct {
254 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
255 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
256 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
257 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
258 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
259}
260
261// ReadOutput is the structured output for the current read tool slice.
262type ReadOutput struct {
263 Aisles []cooked.Aisle `json:"aisles,omitempty"`
264 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
265 Recipes []RecipeSummary `json:"recipes,omitempty"`
266 Recipe *RecipeDetail `json:"recipe,omitempty"`
267}
268
269// RecipeSummary is the MCP-safe saved recipe summary.
270type RecipeSummary struct {
271 ID string `json:"id"`
272 Title string `json:"title"`
273}
274
275// RecipeDetail is the MCP-safe single recipe output.
276type RecipeDetail struct {
277 ID string `json:"id"`
278 Title string `json:"title"`
279 Owner string `json:"owner"`
280 EditPermission bool `json:"edit_permission"`
281 Content string `json:"content"`
282 Portions int `json:"portions"`
283}