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	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}