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