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(
 63	ctx context.Context,
 64	_ *sdk.CallToolRequest,
 65	arguments ReadArguments,
 66) (*sdk.CallToolResult, ReadOutput, error) {
 67	switch arguments.Target {
 68	case "shopping_list":
 69		return s.callShoppingListReadTool(ctx)
 70	case "recipe":
 71		return s.callRecipeReadTool(ctx, arguments.RecipeID)
 72	case "recipes":
 73		return s.callRecipesReadTool(ctx, arguments)
 74	default:
 75		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
 76	}
 77}
 78
 79func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
 80	shoppingList, err := s.backend.ReadShoppingList(ctx)
 81	if err != nil {
 82		return nil, ReadOutput{}, err
 83	}
 84
 85	output := ReadOutput{
 86		Aisles:              shoppingList.Aisles,
 87		ShoppingListRecipes: shoppingList.Recipes,
 88	}
 89
 90	return &sdk.CallToolResult{
 91		Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
 92	}, output, nil
 93}
 94
 95func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
 96	recipeID := strings.TrimSpace(rawRecipeID)
 97	if recipeID == "" {
 98		return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
 99	}
100
101	metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
102	if err != nil {
103		return nil, ReadOutput{}, err
104	}
105	content, err := s.backend.ReadRecipeContent(ctx, recipeID)
106	if err != nil {
107		return nil, ReadOutput{}, err
108	}
109
110	recipe := RecipeDetail{
111		ID:             recipeID,
112		Title:          metadata.Title,
113		Owner:          metadata.Owner,
114		EditPermission: metadata.EditPermission,
115		Content:        content.Content,
116		Portions:       content.Portions,
117	}
118	output := ReadOutput{Recipe: &recipe}
119
120	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
121}
122
123func (s *Server) callRecipesReadTool(
124	ctx context.Context,
125	arguments ReadArguments,
126) (*sdk.CallToolResult, ReadOutput, error) {
127	page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
128	recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
129	if err != nil {
130		return nil, ReadOutput{}, err
131	}
132
133	output := ReadOutput{Recipes: recipeSummaries(recipes)}
134
135	return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
136}
137
138func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
139	if query == "" {
140		return s.backend.ListRecipes(ctx, page, limit)
141	}
142
143	recipes, err := s.backend.SearchRecipes(ctx, query, page)
144	if err != nil {
145		return nil, err
146	}
147	if len(recipes) > limit {
148		recipes = recipes[:limit]
149	}
150
151	return recipes, nil
152}
153
154func readTool() *sdk.Tool {
155	openWorld := true
156	return &sdk.Tool{
157		Name:        readToolName,
158		Title:       "Read Cooked data",
159		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.",
160		Annotations: &sdk.ToolAnnotations{
161			Title:         "Read Cooked data",
162			ReadOnlyHint:  true,
163			OpenWorldHint: &openWorld,
164		},
165	}
166}
167
168func normalizeRecipePage(page, limit int) (int, int) {
169	if page < 1 {
170		page = 1
171	}
172	if limit < 1 {
173		limit = 10
174	}
175	if limit > 30 {
176		limit = 30
177	}
178
179	return page, limit
180}
181
182func formatShoppingList(shoppingList cooked.ShoppingList) string {
183	if len(shoppingList.Aisles) == 0 {
184		return "Shopping list is empty."
185	}
186
187	var builder strings.Builder
188	builder.WriteString("Shopping list:\n")
189	for _, aisle := range shoppingList.Aisles {
190		builder.WriteString("- ")
191		builder.WriteString(aisle.Name)
192		builder.WriteString(":")
193		if len(aisle.ProductGroups) == 0 {
194			builder.WriteString(" no items\n")
195			continue
196		}
197		builder.WriteByte('\n')
198
199		for _, product := range aisle.ProductGroups {
200			builder.WriteString("  - ")
201			builder.WriteString(product.Name)
202			if product.Quantity != "" {
203				builder.WriteString(" — ")
204				builder.WriteString(product.Quantity)
205			}
206			builder.WriteString(" (id: ")
207			builder.WriteString(product.ID)
208			builder.WriteString(")")
209			if product.Selected {
210				builder.WriteString(" selected")
211			}
212			builder.WriteByte('\n')
213		}
214	}
215
216	return strings.TrimRight(builder.String(), "\n")
217}
218
219func formatRecipes(recipes []cooked.RecipeCard) string {
220	if len(recipes) == 0 {
221		return "No saved recipes found."
222	}
223
224	var builder strings.Builder
225	builder.WriteString("Saved recipes:\n")
226	for _, recipe := range recipes {
227		builder.WriteString("- ")
228		builder.WriteString(recipe.Title)
229		builder.WriteString(" (id: ")
230		builder.WriteString(recipe.ID)
231		builder.WriteString(")\n")
232	}
233
234	return strings.TrimRight(builder.String(), "\n")
235}
236
237func formatRecipe(recipe RecipeDetail) string {
238	title := recipe.Title
239	if title == "" {
240		title = "(untitled recipe)"
241	}
242
243	var builder strings.Builder
244	builder.WriteString("Recipe: ")
245	builder.WriteString(title)
246	builder.WriteString(" (id: ")
247	builder.WriteString(recipe.ID)
248	builder.WriteString(")\n")
249	if recipe.Owner != "" {
250		builder.WriteString("Owner: ")
251		builder.WriteString(recipe.Owner)
252		builder.WriteByte('\n')
253	}
254	fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
255	fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
256
257	content := strings.TrimSpace(recipe.Content)
258	if content == "" {
259		builder.WriteString("\nNo recipe content returned.")
260	} else {
261		builder.WriteString("\n")
262		builder.WriteString(content)
263	}
264
265	return strings.TrimRight(builder.String(), "\n")
266}
267
268func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
269	summaries := make([]RecipeSummary, 0, len(recipes))
270	for _, recipe := range recipes {
271		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
272	}
273
274	return summaries
275}
276
277// ReadArguments contains read tool arguments.
278type ReadArguments struct {
279	Target   string `json:"target"              jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
280	RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
281	Query    string `json:"query,omitempty"     jsonschema:"Search query for target recipes. Omit to list saved recipes."`
282	Page     int    `json:"page,omitempty"      jsonschema:"Page number for target recipes. Defaults to 1."`
283	Limit    int    `json:"limit,omitempty"     jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
284}
285
286// ReadOutput is the structured output for the current read tool slice.
287type ReadOutput struct {
288	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
289	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
290	Recipes             []RecipeSummary `json:"recipes,omitempty"`
291	Recipe              *RecipeDetail   `json:"recipe,omitempty"`
292}
293
294// RecipeSummary is the MCP-safe saved recipe summary.
295type RecipeSummary struct {
296	ID    string `json:"id"`
297	Title string `json:"title"`
298}
299
300// RecipeDetail is the MCP-safe single recipe output.
301type RecipeDetail struct {
302	ID             string `json:"id"`
303	Title          string `json:"title"`
304	Owner          string `json:"owner"`
305	EditPermission bool   `json:"edit_permission"`
306	Content        string `json:"content"`
307	Portions       int    `json:"portions"`
308}