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)
 22
 23// Backend provides the Cooked operations exposed as MCP tools.
 24type Backend interface {
 25	ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
 26	ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
 27	SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
 28	ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
 29	ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
 30}
 31
 32// Server exposes Cooked as an MCP server.
 33type Server struct {
 34	backend Backend
 35	sdk     *sdk.Server
 36}
 37
 38// NewServer returns an MCP server for a Cooked backend.
 39func NewServer(backend Backend, version string) *Server {
 40	server := &Server{backend: backend}
 41	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 42	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 43
 44	return server
 45}
 46
 47// RunStdio serves MCP over the official SDK stdio transport.
 48func (s *Server) RunStdio(ctx context.Context) error {
 49	return s.sdk.Run(ctx, &sdk.StdioTransport{})
 50}
 51
 52// CallReadTool invokes the read tool without asserting MCP wire presentation.
 53func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
 54	_, output, err := s.callReadTool(ctx, nil, arguments)
 55	if err != nil {
 56		return ReadOutput{}, err
 57	}
 58
 59	return output, nil
 60}
 61
 62func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
 63	switch arguments.Target {
 64	case "shopping_list":
 65		shoppingList, err := s.backend.ReadShoppingList(ctx)
 66		if err != nil {
 67			return nil, ReadOutput{}, err
 68		}
 69
 70		output := ReadOutput{
 71			Aisles:              shoppingList.Aisles,
 72			ShoppingListRecipes: shoppingList.Recipes,
 73		}
 74
 75		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
 76	case "recipe":
 77		recipeID := strings.TrimSpace(arguments.RecipeID)
 78		if recipeID == "" {
 79			return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
 80		}
 81
 82		metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
 83		if err != nil {
 84			return nil, ReadOutput{}, err
 85		}
 86		content, err := s.backend.ReadRecipeContent(ctx, recipeID)
 87		if err != nil {
 88			return nil, ReadOutput{}, err
 89		}
 90
 91		recipe := RecipeDetail{
 92			ID:             recipeID,
 93			Title:          metadata.Title,
 94			Owner:          metadata.Owner,
 95			EditPermission: metadata.EditPermission,
 96			Content:        content.Content,
 97			Portions:       content.Portions,
 98		}
 99		output := ReadOutput{Recipe: &recipe}
100
101		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
102	case "recipes":
103		page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
104		query := strings.TrimSpace(arguments.Query)
105
106		var (
107			recipes []cooked.RecipeCard
108			err     error
109		)
110		if query == "" {
111			recipes, err = s.backend.ListRecipes(ctx, page, limit)
112		} else {
113			recipes, err = s.backend.SearchRecipes(ctx, query, page)
114			if len(recipes) > limit {
115				recipes = recipes[:limit]
116			}
117		}
118		if err != nil {
119			return nil, ReadOutput{}, err
120		}
121
122		output := ReadOutput{Recipes: recipeSummaries(recipes)}
123
124		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
125	default:
126		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
127	}
128}
129
130func readTool() *sdk.Tool {
131	openWorld := true
132	return &sdk.Tool{
133		Name:        readToolName,
134		Title:       "Read Cooked data",
135		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.",
136		Annotations: &sdk.ToolAnnotations{
137			Title:         "Read Cooked data",
138			ReadOnlyHint:  true,
139			OpenWorldHint: &openWorld,
140		},
141	}
142}
143
144func normalizeRecipePage(page, limit int) (int, int) {
145	if page < 1 {
146		page = 1
147	}
148	if limit < 1 {
149		limit = 10
150	}
151	if limit > 30 {
152		limit = 30
153	}
154
155	return page, limit
156}
157func formatShoppingList(shoppingList cooked.ShoppingList) string {
158	if len(shoppingList.Aisles) == 0 {
159		return "Shopping list is empty."
160	}
161
162	var builder strings.Builder
163	builder.WriteString("Shopping list:\n")
164	for _, aisle := range shoppingList.Aisles {
165		builder.WriteString("- ")
166		builder.WriteString(aisle.Name)
167		builder.WriteString(":")
168		if len(aisle.ProductGroups) == 0 {
169			builder.WriteString(" no items\n")
170			continue
171		}
172		builder.WriteByte('\n')
173
174		for _, product := range aisle.ProductGroups {
175			builder.WriteString("  - ")
176			builder.WriteString(product.Name)
177			if product.Quantity != "" {
178				builder.WriteString(" — ")
179				builder.WriteString(product.Quantity)
180			}
181			builder.WriteString(" (id: ")
182			builder.WriteString(product.ID)
183			builder.WriteString(")")
184			if product.Selected {
185				builder.WriteString(" selected")
186			}
187			builder.WriteByte('\n')
188		}
189	}
190
191	return strings.TrimRight(builder.String(), "\n")
192}
193
194func formatRecipes(recipes []cooked.RecipeCard) string {
195	if len(recipes) == 0 {
196		return "No saved recipes found."
197	}
198
199	var builder strings.Builder
200	builder.WriteString("Saved recipes:\n")
201	for _, recipe := range recipes {
202		builder.WriteString("- ")
203		builder.WriteString(recipe.Title)
204		builder.WriteString(" (id: ")
205		builder.WriteString(recipe.ID)
206		builder.WriteString(")\n")
207	}
208
209	return strings.TrimRight(builder.String(), "\n")
210}
211
212func formatRecipe(recipe RecipeDetail) string {
213	title := recipe.Title
214	if title == "" {
215		title = "(untitled recipe)"
216	}
217
218	var builder strings.Builder
219	builder.WriteString("Recipe: ")
220	builder.WriteString(title)
221	builder.WriteString(" (id: ")
222	builder.WriteString(recipe.ID)
223	builder.WriteString(")\n")
224	if recipe.Owner != "" {
225		builder.WriteString("Owner: ")
226		builder.WriteString(recipe.Owner)
227		builder.WriteByte('\n')
228	}
229	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
230	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
231
232	content := strings.TrimSpace(recipe.Content)
233	if content == "" {
234		builder.WriteString("\nNo recipe content returned.")
235	} else {
236		builder.WriteString("\n")
237		builder.WriteString(content)
238	}
239
240	return strings.TrimRight(builder.String(), "\n")
241}
242
243func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
244	summaries := make([]RecipeSummary, 0, len(recipes))
245	for _, recipe := range recipes {
246		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
247	}
248
249	return summaries
250}
251
252// ReadArguments contains read tool arguments.
253type ReadArguments struct {
254	Target   string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
255	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
256	Query    string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
257	Page     int    `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
258	Limit    int    `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
259}
260
261// ReadOutput is the structured output for the current read tool slice.
262type ReadOutput struct {
263	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
264	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
265	Recipes             []RecipeSummary `json:"recipes,omitempty"`
266	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
267}
268
269// RecipeSummary is the MCP-safe saved recipe summary.
270type RecipeSummary struct {
271	ID    string `json:"id"`
272	Title string `json:"title"`
273}
274
275// RecipeDetail is the MCP-safe single recipe output.
276type RecipeDetail struct {
277	ID             string `json:"id"`
278	Title          string `json:"title"`
279	Owner          string `json:"owner"`
280	EditPermission bool   `json:"edit_permission"`
281	Content        string `json:"content"`
282	Portions       int    `json:"portions"`
283}