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}
 29
 30// Server exposes Cooked as an MCP server.
 31type Server struct {
 32	backend Backend
 33	sdk     *sdk.Server
 34}
 35
 36// NewServer returns an MCP server for a Cooked backend.
 37func NewServer(backend Backend, version string) *Server {
 38	server := &Server{backend: backend}
 39	server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
 40	sdk.AddTool(server.sdk, readTool(), server.callReadTool)
 41
 42	return server
 43}
 44
 45// RunStdio serves MCP over the official SDK stdio transport.
 46func (s *Server) RunStdio(ctx context.Context) error {
 47	return s.sdk.Run(ctx, &sdk.StdioTransport{})
 48}
 49
 50// CallReadTool invokes the read tool without asserting MCP wire presentation.
 51func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
 52	_, output, err := s.callReadTool(ctx, nil, arguments)
 53	if err != nil {
 54		return ReadOutput{}, err
 55	}
 56
 57	return output, nil
 58}
 59
 60func (s *Server) callReadTool(ctx context.Context, _ *sdk.CallToolRequest, arguments ReadArguments) (*sdk.CallToolResult, ReadOutput, error) {
 61	switch arguments.Target {
 62	case "shopping_list":
 63		shoppingList, err := s.backend.ReadShoppingList(ctx)
 64		if err != nil {
 65			return nil, ReadOutput{}, err
 66		}
 67
 68		output := ReadOutput{
 69			Aisles:              shoppingList.Aisles,
 70			ShoppingListRecipes: shoppingList.Recipes,
 71		}
 72
 73		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}}}, output, nil
 74	case "recipes":
 75		page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
 76		query := strings.TrimSpace(arguments.Query)
 77
 78		var (
 79			recipes []cooked.RecipeCard
 80			err     error
 81		)
 82		if query == "" {
 83			recipes, err = s.backend.ListRecipes(ctx, page, limit)
 84		} else {
 85			recipes, err = s.backend.SearchRecipes(ctx, query, page)
 86			if len(recipes) > limit {
 87				recipes = recipes[:limit]
 88			}
 89		}
 90		if err != nil {
 91			return nil, ReadOutput{}, err
 92		}
 93
 94		output := ReadOutput{Recipes: recipeSummaries(recipes)}
 95
 96		return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
 97	default:
 98		return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
 99	}
100}
101
102func readTool() *sdk.Tool {
103	openWorld := true
104	return &sdk.Tool{
105		Name:        readToolName,
106		Title:       "Read Cooked data",
107		Description: "Read Cooked data. Use target shopping_list for the current shopping list. Use target recipes to list saved recipe IDs and titles, or include query to search saved recipes. Recipe results omit thumbnails. This slice does not read a single recipe.",
108		Annotations: &sdk.ToolAnnotations{
109			Title:         "Read Cooked data",
110			ReadOnlyHint:  true,
111			OpenWorldHint: &openWorld,
112		},
113	}
114}
115
116func normalizeRecipePage(page, limit int) (int, int) {
117	if page < 1 {
118		page = 1
119	}
120	if limit < 1 {
121		limit = 10
122	}
123	if limit > 30 {
124		limit = 30
125	}
126
127	return page, limit
128}
129func formatShoppingList(shoppingList cooked.ShoppingList) string {
130	if len(shoppingList.Aisles) == 0 {
131		return "Shopping list is empty."
132	}
133
134	var builder strings.Builder
135	builder.WriteString("Shopping list:\n")
136	for _, aisle := range shoppingList.Aisles {
137		builder.WriteString("- ")
138		builder.WriteString(aisle.Name)
139		builder.WriteString(":")
140		if len(aisle.ProductGroups) == 0 {
141			builder.WriteString(" no items\n")
142			continue
143		}
144		builder.WriteByte('\n')
145
146		for _, product := range aisle.ProductGroups {
147			builder.WriteString("  - ")
148			builder.WriteString(product.Name)
149			if product.Quantity != "" {
150				builder.WriteString(" — ")
151				builder.WriteString(product.Quantity)
152			}
153			builder.WriteString(" (id: ")
154			builder.WriteString(product.ID)
155			builder.WriteString(")")
156			if product.Selected {
157				builder.WriteString(" selected")
158			}
159			builder.WriteByte('\n')
160		}
161	}
162
163	return strings.TrimRight(builder.String(), "\n")
164}
165
166func formatRecipes(recipes []cooked.RecipeCard) string {
167	if len(recipes) == 0 {
168		return "No saved recipes found."
169	}
170
171	var builder strings.Builder
172	builder.WriteString("Saved recipes:\n")
173	for _, recipe := range recipes {
174		builder.WriteString("- ")
175		builder.WriteString(recipe.Title)
176		builder.WriteString(" (id: ")
177		builder.WriteString(recipe.ID)
178		builder.WriteString(")\n")
179	}
180
181	return strings.TrimRight(builder.String(), "\n")
182}
183
184func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
185	summaries := make([]RecipeSummary, 0, len(recipes))
186	for _, recipe := range recipes {
187		summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
188	}
189
190	return summaries
191}
192
193// ReadArguments contains read tool arguments.
194type ReadArguments struct {
195	Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list or recipes."`
196	Query  string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
197	Page   int    `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
198	Limit  int    `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
199}
200
201// ReadOutput is the structured output for the current read tool slice.
202type ReadOutput struct {
203	Aisles              []cooked.Aisle  `json:"aisles,omitempty"`
204	ShoppingListRecipes []string        `json:"shopping_list_recipes,omitempty"`
205	Recipes             []RecipeSummary `json:"recipes,omitempty"`
206}
207
208// RecipeSummary is the MCP-safe saved recipe summary.
209type RecipeSummary struct {
210	ID    string `json:"id"`
211	Title string `json:"title"`
212}