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 previewRecipeTextToolName = "preview_recipe_text"
22 saveRecipeToolName = "save_recipe"
23 deleteRecipeToolName = "delete_recipe"
24 changeShoppingListToolName = "change_shopping_list"
25)
26
27// Backend provides the Cooked operations exposed as MCP tools.
28type Backend interface {
29 ReadShoppingList(ctx context.Context) (cooked.ShoppingList, error)
30 ListRecipes(ctx context.Context, page, limit int) ([]cooked.RecipeCard, error)
31 SearchRecipes(ctx context.Context, query string, page int) ([]cooked.RecipeCard, error)
32 ReadRecipeMetadata(ctx context.Context, recipeID string) (cooked.RecipeMetadata, error)
33 ReadRecipeContent(ctx context.Context, recipeID string) (cooked.RecipeContent, error)
34 PreviewRecipeText(ctx context.Context, title, text string) (cooked.RecipeTextPreview, error)
35 SavePreparedRecipe(ctx context.Context, title, markdown string, portions int) (string, error)
36 UpdateRecipeContent(ctx context.Context, recipeID, markdown string, portions int) error
37 DeleteRecipe(ctx context.Context, recipeID string) error
38 ClearShoppingList(ctx context.Context) error
39}
40
41// Server exposes Cooked as an MCP server.
42type Server struct {
43 backend Backend
44 sdk *sdk.Server
45}
46
47// NewServer returns an MCP server for a Cooked backend.
48func NewServer(backend Backend, version string) *Server {
49 server := &Server{backend: backend}
50 server.sdk = sdk.NewServer(&sdk.Implementation{Name: serverName, Title: serverName, Version: version}, nil)
51 sdk.AddTool(server.sdk, readTool(), server.callReadTool)
52 sdk.AddTool(server.sdk, previewRecipeTextTool(), server.callPreviewRecipeTextTool)
53 sdk.AddTool(server.sdk, saveRecipeTool(), server.callSaveRecipeTool)
54 sdk.AddTool(server.sdk, deleteRecipeTool(), server.callDeleteRecipeTool)
55 sdk.AddTool(server.sdk, changeShoppingListTool(), server.callChangeShoppingListTool)
56
57 return server
58}
59
60// RunStdio serves MCP over the official SDK stdio transport.
61func (s *Server) RunStdio(ctx context.Context) error {
62 return s.sdk.Run(ctx, &sdk.StdioTransport{})
63}
64
65// CallReadTool invokes the read tool without asserting MCP wire presentation.
66func (s *Server) CallReadTool(ctx context.Context, arguments ReadArguments) (ReadOutput, error) {
67 _, output, err := s.callReadTool(ctx, nil, arguments)
68 if err != nil {
69 return ReadOutput{}, err
70 }
71
72 return output, nil
73}
74
75func (s *Server) callReadTool(
76 ctx context.Context,
77 _ *sdk.CallToolRequest,
78 arguments ReadArguments,
79) (*sdk.CallToolResult, ReadOutput, error) {
80 switch arguments.Target {
81 case "shopping_list":
82 return s.callShoppingListReadTool(ctx)
83 case "recipe":
84 return s.callRecipeReadTool(ctx, arguments.RecipeID)
85 case "recipes":
86 return s.callRecipesReadTool(ctx, arguments)
87 default:
88 return nil, ReadOutput{}, fmt.Errorf("unsupported read target %q in this slice", arguments.Target)
89 }
90}
91
92func (s *Server) callShoppingListReadTool(ctx context.Context) (*sdk.CallToolResult, ReadOutput, error) {
93 shoppingList, err := s.backend.ReadShoppingList(ctx)
94 if err != nil {
95 return nil, ReadOutput{}, err
96 }
97
98 output := ReadOutput{
99 Aisles: shoppingList.Aisles,
100 ShoppingListRecipes: shoppingList.Recipes,
101 }
102
103 return &sdk.CallToolResult{
104 Content: []sdk.Content{&sdk.TextContent{Text: formatShoppingList(shoppingList)}},
105 }, output, nil
106}
107
108func (s *Server) callRecipeReadTool(ctx context.Context, rawRecipeID string) (*sdk.CallToolResult, ReadOutput, error) {
109 recipeID := strings.TrimSpace(rawRecipeID)
110 if recipeID == "" {
111 return nil, ReadOutput{}, fmt.Errorf("recipe_id is required when target is recipe")
112 }
113
114 metadata, err := s.backend.ReadRecipeMetadata(ctx, recipeID)
115 if err != nil {
116 return nil, ReadOutput{}, err
117 }
118 content, err := s.backend.ReadRecipeContent(ctx, recipeID)
119 if err != nil {
120 return nil, ReadOutput{}, err
121 }
122
123 recipe := RecipeDetail{
124 ID: recipeID,
125 Title: metadata.Title,
126 Owner: metadata.Owner,
127 EditPermission: metadata.EditPermission,
128 Content: content.Content,
129 Portions: content.Portions,
130 }
131 output := ReadOutput{Recipe: &recipe}
132
133 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipe(recipe)}}}, output, nil
134}
135
136func (s *Server) callRecipesReadTool(
137 ctx context.Context,
138 arguments ReadArguments,
139) (*sdk.CallToolResult, ReadOutput, error) {
140 page, limit := normalizeRecipePage(arguments.Page, arguments.Limit)
141 recipes, err := s.readRecipeCards(ctx, strings.TrimSpace(arguments.Query), page, limit)
142 if err != nil {
143 return nil, ReadOutput{}, err
144 }
145
146 output := ReadOutput{Recipes: recipeSummaries(recipes)}
147
148 return &sdk.CallToolResult{Content: []sdk.Content{&sdk.TextContent{Text: formatRecipes(recipes)}}}, output, nil
149}
150
151func (s *Server) readRecipeCards(ctx context.Context, query string, page, limit int) ([]cooked.RecipeCard, error) {
152 if query == "" {
153 return s.backend.ListRecipes(ctx, page, limit)
154 }
155
156 recipes, err := s.backend.SearchRecipes(ctx, query, page)
157 if err != nil {
158 return nil, err
159 }
160 if len(recipes) > limit {
161 recipes = recipes[:limit]
162 }
163
164 return recipes, nil
165}
166
167func (s *Server) callPreviewRecipeTextTool(
168 ctx context.Context,
169 _ *sdk.CallToolRequest,
170 arguments PreviewRecipeTextArguments,
171) (*sdk.CallToolResult, PreviewRecipeTextOutput, error) {
172 title := strings.TrimSpace(arguments.Title)
173 if title == "" {
174 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("title is required")
175 }
176 if strings.TrimSpace(arguments.Text) == "" {
177 return nil, PreviewRecipeTextOutput{}, fmt.Errorf("text is required")
178 }
179
180 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
181 if err != nil {
182 return nil, PreviewRecipeTextOutput{}, err
183 }
184
185 output := PreviewRecipeTextOutput{
186 Title: preview.Title,
187 Markdown: preview.Markdown,
188 Portions: preview.Portions,
189 }
190
191 return &sdk.CallToolResult{
192 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeTextPreview(output)}},
193 }, output, nil
194}
195
196func (s *Server) callSaveRecipeTool(
197 ctx context.Context,
198 _ *sdk.CallToolRequest,
199 arguments SaveRecipeArguments,
200) (*sdk.CallToolResult, SaveRecipeOutput, error) {
201 source := strings.TrimSpace(arguments.Source)
202 switch source {
203 case "raw_text":
204 return s.callRawTextSaveRecipeTool(ctx, arguments)
205 case "prepared":
206 return s.callPreparedSaveRecipeTool(ctx, arguments)
207 case "existing":
208 return s.callExistingSaveRecipeTool(ctx, arguments)
209 case "":
210 return nil, SaveRecipeOutput{}, fmt.Errorf("source is required")
211 default:
212 return nil, SaveRecipeOutput{}, fmt.Errorf("unsupported save_recipe source %q in this slice", source)
213 }
214}
215
216func (s *Server) callRawTextSaveRecipeTool(
217 ctx context.Context,
218 arguments SaveRecipeArguments,
219) (*sdk.CallToolResult, SaveRecipeOutput, error) {
220 title := strings.TrimSpace(arguments.Title)
221 if title == "" {
222 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is raw_text")
223 }
224 if strings.TrimSpace(arguments.Text) == "" {
225 return nil, SaveRecipeOutput{}, fmt.Errorf("text is required when source is raw_text")
226 }
227
228 preview, err := s.backend.PreviewRecipeText(ctx, title, arguments.Text)
229 if err != nil {
230 return nil, SaveRecipeOutput{}, err
231 }
232 saveTitle := strings.TrimSpace(preview.Title)
233 if saveTitle == "" {
234 saveTitle = title
235 }
236 if strings.TrimSpace(preview.Markdown) == "" {
237 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing markdown")
238 }
239 if preview.Portions < 1 {
240 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe text preview response missing portions")
241 }
242
243 return s.savePreparedRecipe(ctx, saveTitle, preview.Markdown, preview.Portions)
244}
245
246func (s *Server) callPreparedSaveRecipeTool(
247 ctx context.Context,
248 arguments SaveRecipeArguments,
249) (*sdk.CallToolResult, SaveRecipeOutput, error) {
250 title := strings.TrimSpace(arguments.Title)
251 if title == "" {
252 return nil, SaveRecipeOutput{}, fmt.Errorf("title is required when source is prepared")
253 }
254 if strings.TrimSpace(arguments.Markdown) == "" {
255 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is prepared")
256 }
257 if arguments.Portions < 1 {
258 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is prepared")
259 }
260
261 return s.savePreparedRecipe(ctx, title, arguments.Markdown, arguments.Portions)
262}
263
264func (s *Server) callExistingSaveRecipeTool(
265 ctx context.Context,
266 arguments SaveRecipeArguments,
267) (*sdk.CallToolResult, SaveRecipeOutput, error) {
268 recipeID := strings.TrimSpace(arguments.RecipeID)
269 if recipeID == "" {
270 return nil, SaveRecipeOutput{}, fmt.Errorf("recipe_id is required when source is existing")
271 }
272 if strings.TrimSpace(arguments.Markdown) == "" {
273 return nil, SaveRecipeOutput{}, fmt.Errorf("markdown is required when source is existing")
274 }
275 if arguments.Portions < 1 {
276 return nil, SaveRecipeOutput{}, fmt.Errorf("portions is required when source is existing")
277 }
278
279 if err := s.backend.UpdateRecipeContent(ctx, recipeID, arguments.Markdown, arguments.Portions); err != nil {
280 return nil, SaveRecipeOutput{}, err
281 }
282
283 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
284}
285
286func (s *Server) savePreparedRecipe(
287 ctx context.Context,
288 title, markdown string,
289 portions int,
290) (*sdk.CallToolResult, SaveRecipeOutput, error) {
291 recipeID, err := s.backend.SavePreparedRecipe(ctx, title, markdown, portions)
292 if err != nil {
293 return nil, SaveRecipeOutput{}, err
294 }
295 if strings.TrimSpace(recipeID) == "" {
296 return nil, SaveRecipeOutput{}, fmt.Errorf("save recipe response missing recipe ID")
297 }
298
299 return newRecipeSaveResult(recipeID), SaveRecipeOutput{RecipeID: recipeID}, nil
300}
301
302func newRecipeSaveResult(recipeID string) *sdk.CallToolResult {
303 output := SaveRecipeOutput{RecipeID: recipeID}
304
305 return &sdk.CallToolResult{
306 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeSave(output)}},
307 }
308}
309
310func (s *Server) callDeleteRecipeTool(
311 ctx context.Context,
312 _ *sdk.CallToolRequest,
313 arguments DeleteRecipeArguments,
314) (*sdk.CallToolResult, DeleteRecipeOutput, error) {
315 recipeID := strings.TrimSpace(arguments.RecipeID)
316 if recipeID == "" {
317 return nil, DeleteRecipeOutput{}, fmt.Errorf("recipe_id is required")
318 }
319
320 if err := s.backend.DeleteRecipe(ctx, recipeID); err != nil {
321 return nil, DeleteRecipeOutput{}, err
322 }
323
324 output := DeleteRecipeOutput{RecipeID: recipeID}
325
326 return &sdk.CallToolResult{
327 Content: []sdk.Content{&sdk.TextContent{Text: formatRecipeDelete(output)}},
328 }, output, nil
329}
330
331func (s *Server) callChangeShoppingListTool(
332 ctx context.Context,
333 _ *sdk.CallToolRequest,
334 arguments ChangeShoppingListArguments,
335) (*sdk.CallToolResult, ChangeShoppingListOutput, error) {
336 action := strings.TrimSpace(arguments.Action)
337 switch action {
338 case "clear":
339 if err := s.backend.ClearShoppingList(ctx); err != nil {
340 return nil, ChangeShoppingListOutput{}, err
341 }
342
343 output := ChangeShoppingListOutput{Cleared: true}
344
345 return &sdk.CallToolResult{
346 Content: []sdk.Content{&sdk.TextContent{Text: "Cleared shopping list."}},
347 }, output, nil
348 case "":
349 return nil, ChangeShoppingListOutput{}, fmt.Errorf("action is required")
350 default:
351 return nil, ChangeShoppingListOutput{}, fmt.Errorf(
352 "unsupported change_shopping_list action %q in this slice",
353 action,
354 )
355 }
356}
357
358func readTool() *sdk.Tool {
359 openWorld := true
360 return &sdk.Tool{
361 Name: readToolName,
362 Title: "Read Cooked data",
363 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.",
364 Annotations: &sdk.ToolAnnotations{
365 Title: "Read Cooked data",
366 ReadOnlyHint: true,
367 OpenWorldHint: &openWorld,
368 },
369 }
370}
371
372func previewRecipeTextTool() *sdk.Tool {
373 openWorld := true
374 return &sdk.Tool{
375 Name: previewRecipeTextToolName,
376 Title: "Preview recipe text",
377 Description: "Preview raw recipe text as Cooked recipe markdown and portions without saving or updating a recipe.",
378 Annotations: &sdk.ToolAnnotations{
379 Title: "Preview recipe text",
380 ReadOnlyHint: true,
381 OpenWorldHint: &openWorld,
382 },
383 }
384}
385
386func saveRecipeTool() *sdk.Tool {
387 openWorld := true
388 return &sdk.Tool{
389 Name: saveRecipeToolName,
390 Title: "Save recipe",
391 Description: "Save a recipe. This slice supports source raw_text with title and text, source prepared with title, markdown, and portions, and source existing with recipe_id, markdown, and portions. Other source values are reserved for later slices.",
392 Annotations: &sdk.ToolAnnotations{
393 Title: "Save recipe",
394 OpenWorldHint: &openWorld,
395 },
396 }
397}
398
399func deleteRecipeTool() *sdk.Tool {
400 destructive := true
401 openWorld := true
402 return &sdk.Tool{
403 Name: deleteRecipeToolName,
404 Title: "Delete recipe",
405 Description: "Delete a saved Cooked recipe by recipe_id. This is destructive.",
406 Annotations: &sdk.ToolAnnotations{
407 Title: "Delete recipe",
408 DestructiveHint: &destructive,
409 OpenWorldHint: &openWorld,
410 },
411 }
412}
413
414func changeShoppingListTool() *sdk.Tool {
415 destructive := true
416 openWorld := true
417 return &sdk.Tool{
418 Name: changeShoppingListToolName,
419 Title: "Change shopping list",
420 Description: "Change the Cooked shopping list. This slice supports only action clear, which is destructive and deletes all shopping-list items. Other actions are reserved for later slices.",
421 Annotations: &sdk.ToolAnnotations{
422 Title: "Change shopping list",
423 DestructiveHint: &destructive,
424 OpenWorldHint: &openWorld,
425 },
426 }
427}
428
429func normalizeRecipePage(page, limit int) (int, int) {
430 if page < 1 {
431 page = 1
432 }
433 if limit < 1 {
434 limit = 10
435 }
436 if limit > 30 {
437 limit = 30
438 }
439
440 return page, limit
441}
442
443func formatShoppingList(shoppingList cooked.ShoppingList) string {
444 if len(shoppingList.Aisles) == 0 {
445 return "Shopping list is empty."
446 }
447
448 var builder strings.Builder
449 builder.WriteString("Shopping list:\n")
450 for _, aisle := range shoppingList.Aisles {
451 builder.WriteString("- ")
452 builder.WriteString(aisle.Name)
453 builder.WriteString(":")
454 if len(aisle.ProductGroups) == 0 {
455 builder.WriteString(" no items\n")
456 continue
457 }
458 builder.WriteByte('\n')
459
460 for _, product := range aisle.ProductGroups {
461 builder.WriteString(" - ")
462 builder.WriteString(product.Name)
463 if product.Quantity != "" {
464 builder.WriteString(" — ")
465 builder.WriteString(product.Quantity)
466 }
467 builder.WriteString(" (id: ")
468 builder.WriteString(product.ID)
469 builder.WriteString(")")
470 if product.Selected {
471 builder.WriteString(" selected")
472 }
473 builder.WriteByte('\n')
474 }
475 }
476
477 return strings.TrimRight(builder.String(), "\n")
478}
479
480func formatRecipes(recipes []cooked.RecipeCard) string {
481 if len(recipes) == 0 {
482 return "No saved recipes found."
483 }
484
485 var builder strings.Builder
486 builder.WriteString("Saved recipes:\n")
487 for _, recipe := range recipes {
488 builder.WriteString("- ")
489 builder.WriteString(recipe.Title)
490 builder.WriteString(" (id: ")
491 builder.WriteString(recipe.ID)
492 builder.WriteString(")\n")
493 }
494
495 return strings.TrimRight(builder.String(), "\n")
496}
497
498func formatRecipe(recipe RecipeDetail) string {
499 title := recipe.Title
500 if title == "" {
501 title = "(untitled recipe)"
502 }
503
504 var builder strings.Builder
505 builder.WriteString("Recipe: ")
506 builder.WriteString(title)
507 builder.WriteString(" (id: ")
508 builder.WriteString(recipe.ID)
509 builder.WriteString(")\n")
510 if recipe.Owner != "" {
511 builder.WriteString("Owner: ")
512 builder.WriteString(recipe.Owner)
513 builder.WriteByte('\n')
514 }
515 fmt.Fprintf(&builder, "Edit permission: %t\n", recipe.EditPermission)
516 fmt.Fprintf(&builder, "Portions: %d\n", recipe.Portions)
517
518 content := strings.TrimSpace(recipe.Content)
519 if content == "" {
520 builder.WriteString("\nNo recipe content returned.")
521 } else {
522 builder.WriteString("\n")
523 builder.WriteString(content)
524 }
525
526 return strings.TrimRight(builder.String(), "\n")
527}
528
529func formatRecipeTextPreview(preview PreviewRecipeTextOutput) string {
530 title := preview.Title
531 if title == "" {
532 title = "(untitled preview)"
533 }
534
535 var builder strings.Builder
536 builder.WriteString("Recipe text preview: ")
537 builder.WriteString(title)
538 builder.WriteByte('\n')
539 fmt.Fprintf(&builder, "Portions: %d\n", preview.Portions)
540
541 markdown := strings.TrimSpace(preview.Markdown)
542 if markdown == "" {
543 builder.WriteString("\nNo preview markdown returned.")
544 } else {
545 builder.WriteString("\n")
546 builder.WriteString(markdown)
547 }
548
549 return strings.TrimRight(builder.String(), "\n")
550}
551
552func formatRecipeSave(output SaveRecipeOutput) string {
553 return "Saved recipe (id: " + output.RecipeID + ")."
554}
555
556func formatRecipeDelete(output DeleteRecipeOutput) string {
557 return "Deleted recipe (id: " + output.RecipeID + ")."
558}
559
560func recipeSummaries(recipes []cooked.RecipeCard) []RecipeSummary {
561 summaries := make([]RecipeSummary, 0, len(recipes))
562 for _, recipe := range recipes {
563 summaries = append(summaries, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
564 }
565
566 return summaries
567}
568
569// ReadArguments contains read tool arguments.
570type ReadArguments struct {
571 Target string `json:"target" jsonschema:"Cooked object to read. Use shopping_list, recipes, or recipe."`
572 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for target recipe."`
573 Query string `json:"query,omitempty" jsonschema:"Search query for target recipes. Omit to list saved recipes."`
574 Page int `json:"page,omitempty" jsonschema:"Page number for target recipes. Defaults to 1."`
575 Limit int `json:"limit,omitempty" jsonschema:"Maximum recipe cards for target recipes. Defaults to 10 and caps at 30."`
576}
577
578// ReadOutput is the structured output for the current read tool slice.
579type ReadOutput struct {
580 Aisles []cooked.Aisle `json:"aisles,omitempty"`
581 ShoppingListRecipes []string `json:"shopping_list_recipes,omitempty"`
582 Recipes []RecipeSummary `json:"recipes,omitempty"`
583 Recipe *RecipeDetail `json:"recipe,omitempty"`
584}
585
586// RecipeSummary is the MCP-safe saved recipe summary.
587type RecipeSummary struct {
588 ID string `json:"id"`
589 Title string `json:"title"`
590}
591
592// RecipeDetail is the MCP-safe single recipe output.
593type RecipeDetail struct {
594 ID string `json:"id"`
595 Title string `json:"title"`
596 Owner string `json:"owner"`
597 EditPermission bool `json:"edit_permission"`
598 Content string `json:"content"`
599 Portions int `json:"portions"`
600}
601
602// PreviewRecipeTextArguments contains preview_recipe_text tool arguments.
603type PreviewRecipeTextArguments struct {
604 Title string `json:"title" jsonschema:"Recipe title for the preview."`
605 Text string `json:"text" jsonschema:"Raw recipe text to preview without saving."`
606}
607
608// PreviewRecipeTextOutput is the structured output for preview_recipe_text.
609type PreviewRecipeTextOutput struct {
610 Title string `json:"title"`
611 Markdown string `json:"markdown"`
612 Portions int `json:"portions"`
613}
614
615// SaveRecipeArguments contains save_recipe tool arguments.
616type SaveRecipeArguments struct {
617 Source string `json:"source" jsonschema:"Recipe save source. Supported now: raw_text, prepared, and existing."`
618 RecipeID string `json:"recipe_id,omitempty" jsonschema:"Recipe ID for existing recipe updates."`
619 Title string `json:"title,omitempty" jsonschema:"Recipe title for raw_text or prepared saves."`
620 Text string `json:"text,omitempty" jsonschema:"Raw recipe text for raw_text saves."`
621 Markdown string `json:"markdown,omitempty" jsonschema:"Recipe markdown for prepared saves or existing recipe updates."`
622 Portions int `json:"portions,omitempty" jsonschema:"Recipe portions for prepared saves or existing recipe updates."`
623}
624
625// SaveRecipeOutput is the structured output for save_recipe.
626type SaveRecipeOutput struct {
627 RecipeID string `json:"recipe_id,omitempty"`
628}
629
630// DeleteRecipeArguments contains delete_recipe tool arguments.
631type DeleteRecipeArguments struct {
632 RecipeID string `json:"recipe_id" jsonschema:"Recipe ID to delete."`
633}
634
635// DeleteRecipeOutput is the structured output for delete_recipe.
636type DeleteRecipeOutput struct {
637 RecipeID string `json:"recipe_id"`
638}
639
640// ChangeShoppingListArguments contains change_shopping_list tool arguments.
641type ChangeShoppingListArguments struct {
642 Action string `json:"action" jsonschema:"Shopping-list action. Supported now: clear."`
643}
644
645// ChangeShoppingListOutput is the structured output for change_shopping_list.
646type ChangeShoppingListOutput struct {
647 Cleared bool `json:"cleared,omitempty"`
648}