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	UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
 35}
 36
 37// Server exposes Cooked as an MCP server.
 38type Server struct {
 39	backend Backend
 40	sdk     *sdk.Server
 41}
 42
 43// NewServer returns an MCP server for a Cooked backend.
 44func NewServer(backend Backend, version string) *Server {
 45	server := &Server{backend: backend}
 46	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 47	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 48	sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
 49	sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
 50
 51	return server
 52}
 53
 54// RunStdio serves MCP over the official SDK stdio transport.
 55func (s *Server) RunStdio(ctx context.Context) error {
 56	return s.sdk.Run(ctx, &sdk.StdioTransport{})
 57}
 58
 59// CallReadTool invokes the read tool without asserting MCP wire presentation.
 60func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
 61	_, output, err := s.callReadTool(ctx, nil, arguments)
 62	if err != nil {
 63		return ReadOutput{}, err
 64	}
 65
 66	return output, nil
 67}
 68
 69func (s *Server) callReadTool(
 70	ctx context.Context,
 71	_ *sdk.CallToolRequest,
 72	arguments ReadArguments,
 73) (*sdk.CallToolResult, ReadOutput, error) {
 74	switch arguments.Target {
 75	case "shopping_list":
 76		return s.callShoppingListReadTool(ctx)
 77	case "recipe":
 78		return s.callRecipeReadTool(ctx, arguments.RecipeID)
 79	case "recipes":
 80		return s.callRecipesReadTool(ctx, arguments)
 81	default:
 82		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
 83	}
 84}
 85
 86func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
 87	shoppingList, err := s.backend.ReadShoppingList(ctx)
 88	if err != nil {
 89		return nil, ReadOutput{}, err
 90	}
 91
 92	output := ReadOutput{
 93		Aisles:              shoppingList.Aisles,
 94		ShoppingListRecipes: shoppingList.Recipes,
 95	}
 96
 97	return &sdk.CallToolResult{
 98		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
 99	}, output, nil
100}
101
102func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
103	recipeID := strings.TrimSpace(rawRecipeID)
104	if recipeID == "" {
105		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
106	}
107
108	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
109	if err != nil {
110		return nil, ReadOutput{}, err
111	}
112	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
113	if err != nil {
114		return nil, ReadOutput{}, err
115	}
116
117	recipe := RecipeDetail{
118		ID:             recipeID,
119		Title:          metadata.Title,
120		Owner:          metadata.Owner,
121		EditPermission: metadata.EditPermission,
122		Content:        content.Content,
123		Portions:       content.Portions,
124	}
125	output := ReadOutput{Recipe: &recipe}
126
127	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
128}
129
130func (s *Server) callRecipesReadTool(
131	ctx context.Context,
132	arguments ReadArguments,
133) (*sdk.CallToolResult, ReadOutput, error) {
134	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
135	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
136	if err != nil {
137		return nil, ReadOutput{}, err
138	}
139
140	output := ReadOutput{Recipes: recipeSummaries(recipes)}
141
142	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
143}
144
145func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
146	if query == "" {
147		return s.backend.ListRecipes(ctx, page, limit)
148	}
149
150	recipes, err := s.backend.SearchRecipes(ctx, query, page)
151	if err != nil {
152		return nil, err
153	}
154	if len(recipes) > limit {
155		recipes = recipes[:limit]
156	}
157
158	return recipes, nil
159}
160
161func (s *Server) callPreviewRecipeTextTool(
162	ctx context.Context,
163	_ *sdk.CallToolRequest,
164	arguments PreviewRecipeTextArguments,
165) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
166	title := strings.TrimSpace(arguments.Title)
167	if title == "" {
168		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
169	}
170	if strings.TrimSpace(arguments.Text) == "" {
171		return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
172	}
173
174	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
175	if err != nil {
176		return nil, PreviewRecipeTextOutput{}, err
177	}
178
179	output := PreviewRecipeTextOutput{
180		Title:    preview.Title,
181		Markdown: preview.Markdown,
182		Portions: preview.Portions,
183	}
184
185	return &sdk.CallToolResult{
186		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
187	}, output, nil
188}
189
190func (s *Server) callSaveRecipeTool(
191	ctx context.Context,
192	_ *sdk.CallToolRequest,
193	arguments SaveRecipeArguments,
194) (*sdk.CallToolResult, SaveRecipeOutput, error) {
195	source := strings.TrimSpace(arguments.Source)
196	switch source {
197	case "raw_text":
198		return s.callRawTextSaveRecipeTool(ctx, arguments)
199	case "prepared":
200		return s.callPreparedSaveRecipeTool(ctx, arguments)
201	case "existing":
202		return s.callExistingSaveRecipeTool(ctx, arguments)
203	case "":
204		return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
205	default:
206		return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
207	}
208}
209
210func (s *Server) callRawTextSaveRecipeTool(
211	ctx context.Context,
212	arguments SaveRecipeArguments,
213) (*sdk.CallToolResult, SaveRecipeOutput, error) {
214	title := strings.TrimSpace(arguments.Title)
215	if title == "" {
216		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
217	}
218	if strings.TrimSpace(arguments.Text) == "" {
219		return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
220	}
221
222	preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
223	if err != nil {
224		return nil, SaveRecipeOutput{}, err
225	}
226	saveTitle := strings.TrimSpace(preview.Title)
227	if saveTitle == "" {
228		saveTitle = title
229	}
230	if strings.TrimSpace(preview.Markdown) == "" {
231		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
232	}
233	if preview.Portions < 1 {
234		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
235	}
236
237	return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
238}
239
240func (s *Server) callPreparedSaveRecipeTool(
241	ctx context.Context,
242	arguments SaveRecipeArguments,
243) (*sdk.CallToolResult, SaveRecipeOutput, error) {
244	title := strings.TrimSpace(arguments.Title)
245	if title == "" {
246		return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
247	}
248	if strings.TrimSpace(arguments.Markdown) == "" {
249		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
250	}
251	if arguments.Portions < 1 {
252		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
253	}
254
255	return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
256}
257
258func (s *Server) callExistingSaveRecipeTool(
259	ctx context.Context,
260	arguments SaveRecipeArguments,
261) (*sdk.CallToolResult, SaveRecipeOutput, error) {
262	recipeID := strings.TrimSpace(arguments.RecipeID)
263	if recipeID == "" {
264		return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
265	}
266	if strings.TrimSpace(arguments.Markdown) == "" {
267		return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
268	}
269	if arguments.Portions < 1 {
270		return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
271	}
272
273	if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
274		return nil, SaveRecipeOutput{}, err
275	}
276
277	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
278}
279
280func (s *Server) savePreparedRecipe(
281	ctx context.Context,
282	title, markdown string,
283	portions int,
284) (*sdk.CallToolResult, SaveRecipeOutput, error) {
285	recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
286	if err != nil {
287		return nil, SaveRecipeOutput{}, err
288	}
289	if strings.TrimSpace(recipeID) == "" {
290		return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
291	}
292
293	return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
294}
295
296func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
297	output := SaveRecipeOutput{RecipeID: recipeID}
298
299	return &sdk.CallToolResult{
300		Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
301	}
302}
303
304func readTool() *sdk.Tool {
305	openWorld := true
306	return &sdk.Tool{
307		Name:        readToolName,
308		Title:       "Read Cooked data",
309		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.",
310		Annotations: &sdk.ToolAnnotations{
311			Title:         "Read Cooked data",
312			ReadOnlyHint:  true,
313			OpenWorldHint: &openWorld,
314		},
315	}
316}
317
318func previewRecipeTextTool() *sdk.Tool {
319	openWorld := true
320	return &sdk.Tool{
321		Name:        previewRecipeTextToolName,
322		Title:       "Preview recipe text",
323		Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
324		Annotations: &sdk.ToolAnnotations{
325			Title:         "Preview recipe text",
326			ReadOnlyHint:  true,
327			OpenWorldHint: &openWorld,
328		},
329	}
330}
331
332func saveRecipeTool() *sdk.Tool {
333	openWorld := true
334	return &sdk.Tool{
335		Name:        saveRecipeToolName,
336		Title:       "Save recipe",
337		Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
338		Annotations: &sdk.ToolAnnotations{
339			Title:         "Save recipe",
340			OpenWorldHint: &openWorld,
341		},
342	}
343}
344
345func normalizeRecipePage(page, limit int) (int, int) {
346	if page < 1 {
347		page = 1
348	}
349	if limit < 1 {
350		limit = 10
351	}
352	if limit > 30 {
353		limit = 30
354	}
355
356	return page, limit
357}
358
359func formatShoppingList(shoppingList cooked.ShoppingList) string {
360	if len(shoppingList.Aisles) == 0 {
361		return "Shopping list is empty."
362	}
363
364	var builder strings.Builder
365	builder.WriteString("Shopping list:\n")
366	for _, aisle := range shoppingList.Aisles {
367		builder.WriteString("- ")
368		builder.WriteString(aisle.Name)
369		builder.WriteString(":")
370		if len(aisle.ProductGroups) == 0 {
371			builder.WriteString(" no items\n")
372			continue
373		}
374		builder.WriteByte('\n')
375
376		for _, product := range aisle.ProductGroups {
377			builder.WriteString("  - ")
378			builder.WriteString(product.Name)
379			if product.Quantity != "" {
380				builder.WriteString(" — ")
381				builder.WriteString(product.Quantity)
382			}
383			builder.WriteString(" (id: ")
384			builder.WriteString(product.ID)
385			builder.WriteString(")")
386			if product.Selected {
387				builder.WriteString(" selected")
388			}
389			builder.WriteByte('\n')
390		}
391	}
392
393	return strings.TrimRight(builder.String(), "\n")
394}
395
396func formatRecipes(recipes []cooked.RecipeCard) string {
397	if len(recipes) == 0 {
398		return "No saved recipes found."
399	}
400
401	var builder strings.Builder
402	builder.WriteString("Saved recipes:\n")
403	for _, recipe := range recipes {
404		builder.WriteString("- ")
405		builder.WriteString(recipe.Title)
406		builder.WriteString(" (id: ")
407		builder.WriteString(recipe.ID)
408		builder.WriteString(")\n")
409	}
410
411	return strings.TrimRight(builder.String(), "\n")
412}
413
414func formatRecipe(recipe RecipeDetail) string {
415	title := recipe.Title
416	if title == "" {
417		title = "(untitled recipe)"
418	}
419
420	var builder strings.Builder
421	builder.WriteString("Recipe: ")
422	builder.WriteString(title)
423	builder.WriteString(" (id: ")
424	builder.WriteString(recipe.ID)
425	builder.WriteString(")\n")
426	if recipe.Owner != "" {
427		builder.WriteString("Owner: ")
428		builder.WriteString(recipe.Owner)
429		builder.WriteByte('\n')
430	}
431	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
432	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
433
434	content := strings.TrimSpace(recipe.Content)
435	if content == "" {
436		builder.WriteString("\nNo recipe content returned.")
437	} else {
438		builder.WriteString("\n")
439		builder.WriteString(content)
440	}
441
442	return strings.TrimRight(builder.String(), "\n")
443}
444
445func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
446	title := preview.Title
447	if title == "" {
448		title = "(untitled preview)"
449	}
450
451	var builder strings.Builder
452	builder.WriteString("Recipe text preview: ")
453	builder.WriteString(title)
454	builder.WriteByte('\n')
455	fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
456
457	markdown := strings.TrimSpace(preview.Markdown)
458	if markdown == "" {
459		builder.WriteString("\nNo preview markdown returned.")
460	} else {
461		builder.WriteString("\n")
462		builder.WriteString(markdown)
463	}
464
465	return strings.TrimRight(builder.String(), "\n")
466}
467
468func formatRecipeSave(output SaveRecipeOutput) string {
469	return "Saved recipe (id: " + output.RecipeID + ")."
470}
471
472func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
473	summaries := make([]RecipeSummary, 0, len(recipes))
474	for _, recipe := range recipes {
475		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
476	}
477
478	return summaries
479}
480
481// ReadArguments contains read tool arguments.
482type ReadArguments struct {
483	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
484	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
485	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
486	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
487	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
488}
489
490// ReadOutput is the structured output for the current read tool slice.
491type ReadOutput struct {
492	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
493	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
494	Recipes             []RecipeSummary `json:"recipes,omitempty"`
495	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
496}
497
498// RecipeSummary is the MCP-safe saved recipe summary.
499type RecipeSummary struct {
500	ID    string `json:"id"`
501	Title string `json:"title"`
502}
503
504// RecipeDetail is the MCP-safe single recipe output.
505type RecipeDetail struct {
506	ID             string `json:"id"`
507	Title          string `json:"title"`
508	Owner          string `json:"owner"`
509	EditPermission bool   `json:"edit_permission"`
510	Content        string `json:"content"`
511	Portions       int    `json:"portions"`
512}
513
514// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
515type PreviewRecipeTextArguments struct {
516	Title string `json:"title" jsonschema:"Recipe title for the preview."`
517	Text  string `json:"text"  jsonschema:"Raw recipe text to preview without saving."`
518}
519
520// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
521type PreviewRecipeTextOutput struct {
522	Title    string `json:"title"`
523	Markdown string `json:"markdown"`
524	Portions int    `json:"portions"`
525}
526
527// SaveRecipeArguments contains save_recipe tool arguments.
528type SaveRecipeArguments struct {
529	Source   string `json:"source"              jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
530	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
531	Title    string `json:"title,omitempty"     jsonschema:"Recipe title for raw_text or prepared saves."`
532	Text     string `json:"text,omitempty"      jsonschema:"Raw recipe text for raw_text saves."`
533	Markdown string `json:"markdown,omitempty"  jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
534	Portions int    `json:"portions,omitempty"  jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
535}
536
537// SaveRecipeOutput is the structured output for save_recipe.
538type SaveRecipeOutput struct {
539	RecipeID string `json:"recipe_id,omitempty"`
540}