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