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 "raw_text":
197		return s.callRawTextSaveRecipeTool(ctx, arguments)
198	case "prepared":
199		return s.callPreparedSaveRecipeTool(ctx, arguments)
200	case "":
201		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
202	default:
203		return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
204	}
205}
206
207func (s *Server) callRawTextSaveRecipeTool(
208	ctx context.Context,
209	arguments SaveRecipeArguments,
210) (*sdk.CallToolResult, SaveRecipeOutput, error) {
211	title := strings.TrimSpace(arguments.Title)
212	if title == "" {
213		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
214	}
215	if strings.TrimSpace(arguments.Text) == "" {
216		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
217	}
218
219	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
220	if err != nil {
221		return nil, SaveRecipeOutput{}, err
222	}
223	saveTitle := strings.TrimSpace(preview.Title)
224	if saveTitle == "" {
225		saveTitle = title
226	}
227	if strings.TrimSpace(preview.Markdown) == "" {
228		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
229	}
230	if preview.Portions < 1 {
231		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
232	}
233
234	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
235}
236
237func (s *Server) callPreparedSaveRecipeTool(
238	ctx context.Context,
239	arguments SaveRecipeArguments,
240) (*sdk.CallToolResult, SaveRecipeOutput, error) {
241	title := strings.TrimSpace(arguments.Title)
242	if title == "" {
243		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
244	}
245	if strings.TrimSpace(arguments.Markdown) == "" {
246		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
247	}
248	if arguments.Portions < 1 {
249		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
250	}
251
252	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
253}
254
255func (s *Server) savePreparedRecipe(
256	ctx context.Context,
257	title, markdown string,
258	portions int,
259) (*sdk.CallToolResult, SaveRecipeOutput, error) {
260	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
261	if err != nil {
262		return nil, SaveRecipeOutput{}, err
263	}
264	if strings.TrimSpace(recipeID) == "" {
265		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
266	}
267
268	output := SaveRecipeOutput{RecipeID: recipeID}
269
270	return &sdk.CallToolResult{
271		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
272	}, output, nil
273}
274
275func readTool() *sdk.Tool {
276	openWorld := true
277	return &sdk.Tool{
278		Name:        readToolName,
279		Title:       "Read Cooked data",
280		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.",
281		Annotations: &sdk.ToolAnnotations{
282			Title:         "Read Cooked data",
283			ReadOnlyHint:  true,
284			OpenWorldHint: &openWorld,
285		},
286	}
287}
288
289func previewRecipeTextTool() *sdk.Tool {
290	openWorld := true
291	return &sdk.Tool{
292		Name:        previewRecipeTextToolName,
293		Title:       "Preview recipe text",
294		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
295		Annotations: &sdk.ToolAnnotations{
296			Title:         "Preview recipe text",
297			ReadOnlyHint:  true,
298			OpenWorldHint: &openWorld,
299		},
300	}
301}
302
303func saveRecipeTool() *sdk.Tool {
304	openWorld := true
305	return &sdk.Tool{
306		Name:        saveRecipeToolName,
307		Title:       "Save recipe",
308		Description: "Save a recipe. This slice supports source raw_text with title and text, and source prepared with title, markdown, and portions. Other source values are reserved for later slices.",
309		Annotations: &sdk.ToolAnnotations{
310			Title:         "Save recipe",
311			OpenWorldHint: &openWorld,
312		},
313	}
314}
315
316func normalizeRecipePage(page, limit int) (int, int) {
317	if page < 1 {
318		page = 1
319	}
320	if limit < 1 {
321		limit = 10
322	}
323	if limit > 30 {
324		limit = 30
325	}
326
327	return page, limit
328}
329
330func formatShoppingList(shoppingList cooked.ShoppingList) string {
331	if len(shoppingList.Aisles) == 0 {
332		return "Shopping list is empty."
333	}
334
335	var builder strings.Builder
336	builder.WriteString("Shopping list:\n")
337	for _, aisle := range shoppingList.Aisles {
338		builder.WriteString("- ")
339		builder.WriteString(aisle.Name)
340		builder.WriteString(":")
341		if len(aisle.ProductGroups) == 0 {
342			builder.WriteString(" no items\n")
343			continue
344		}
345		builder.WriteByte('\n')
346
347		for _, product := range aisle.ProductGroups {
348			builder.WriteString("  - ")
349			builder.WriteString(product.Name)
350			if product.Quantity != "" {
351				builder.WriteString(" — ")
352				builder.WriteString(product.Quantity)
353			}
354			builder.WriteString(" (id: ")
355			builder.WriteString(product.ID)
356			builder.WriteString(")")
357			if product.Selected {
358				builder.WriteString(" selected")
359			}
360			builder.WriteByte('\n')
361		}
362	}
363
364	return strings.TrimRight(builder.String(), "\n")
365}
366
367func formatRecipes(recipes []cooked.RecipeCard) string {
368	if len(recipes) == 0 {
369		return "No saved recipes found."
370	}
371
372	var builder strings.Builder
373	builder.WriteString("Saved recipes:\n")
374	for _, recipe := range recipes {
375		builder.WriteString("- ")
376		builder.WriteString(recipe.Title)
377		builder.WriteString(" (id: ")
378		builder.WriteString(recipe.ID)
379		builder.WriteString(")\n")
380	}
381
382	return strings.TrimRight(builder.String(), "\n")
383}
384
385func formatRecipe(recipe RecipeDetail) string {
386	title := recipe.Title
387	if title == "" {
388		title = "(untitled recipe)"
389	}
390
391	var builder strings.Builder
392	builder.WriteString("Recipe: ")
393	builder.WriteString(title)
394	builder.WriteString(" (id: ")
395	builder.WriteString(recipe.ID)
396	builder.WriteString(")\n")
397	if recipe.Owner != "" {
398		builder.WriteString("Owner: ")
399		builder.WriteString(recipe.Owner)
400		builder.WriteByte('\n')
401	}
402	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
403	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
404
405	content := strings.TrimSpace(recipe.Content)
406	if content == "" {
407		builder.WriteString("\nNo recipe content returned.")
408	} else {
409		builder.WriteString("\n")
410		builder.WriteString(content)
411	}
412
413	return strings.TrimRight(builder.String(), "\n")
414}
415
416func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
417	title := preview.Title
418	if title == "" {
419		title = "(untitled preview)"
420	}
421
422	var builder strings.Builder
423	builder.WriteString("Recipe text preview: ")
424	builder.WriteString(title)
425	builder.WriteByte('\n')
426	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
427
428	markdown := strings.TrimSpace(preview.Markdown)
429	if markdown == "" {
430		builder.WriteString("\nNo preview markdown returned.")
431	} else {
432		builder.WriteString("\n")
433		builder.WriteString(markdown)
434	}
435
436	return strings.TrimRight(builder.String(), "\n")
437}
438
439func formatRecipeSave(output SaveRecipeOutput) string {
440	return "Saved recipe (id: " + output.RecipeID + ")."
441}
442
443func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
444	summaries := make([]RecipeSummary, 0, len(recipes))
445	for _, recipe := range recipes {
446		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
447	}
448
449	return summaries
450}
451
452// ReadArguments contains read tool arguments.
453type ReadArguments struct {
454	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
455	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
456	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
457	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
458	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
459}
460
461// ReadOutput is the structured output for the current read tool slice.
462type ReadOutput struct {
463	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
464	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
465	Recipes             []RecipeSummary `json:"recipes,omitempty"`
466	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
467}
468
469// RecipeSummary is the MCP-safe saved recipe summary.
470type RecipeSummary struct {
471	ID    string `json:"id"`
472	Title string `json:"title"`
473}
474
475// RecipeDetail is the MCP-safe single recipe output.
476type RecipeDetail struct {
477	ID             string `json:"id"`
478	Title          string `json:"title"`
479	Owner          string `json:"owner"`
480	EditPermission bool   `json:"edit_permission"`
481	Content        string `json:"content"`
482	Portions       int    `json:"portions"`
483}
484
485// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
486type PreviewRecipeTextArguments struct {
487	Title string `json:"title" jsonschema:"Recipe title for the preview."`
488	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
489}
490
491// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
492type PreviewRecipeTextOutput struct {
493	Title    string `json:"title"`
494	Markdown string `json:"markdown"`
495	Portions int    `json:"portions"`
496}
497
498// SaveRecipeArguments contains save_recipe tool arguments.
499type SaveRecipeArguments struct {
500	Source   string `json:"source"             jsonschema:"Recipe save source. Supported now: raw_text and prepared."`
501	Title    string `json:"title,omitempty"    jsonschema:"Recipe title for raw_text or prepared saves."`
502	Text     string `json:"text,omitempty"     jsonschema:"Raw recipe text for raw_text saves."`
503	Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves."`
504	Portions int    `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves."`
505}
506
507// SaveRecipeOutput is the structured output for save_recipe.
508type SaveRecipeOutput struct {
509	RecipeID string `json:"recipe_id,omitempty"`
510}