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}