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}