server.go

  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}