1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5package mcp
6
7import (
8 "context"
9 "reflect"
10 "slices"
11 "strings"
12 "testing"
13)
14
15// tools.READ_TOOL.1 tools.SCHEMA.4 tools.RESPONSES.6
16func TestCallToolACIDToolsReadTool1RejectsUnsupportedTargetBeforeCallingCooked(t *testing.T) {
17 backend := &fakeBackend{}
18 server := NewServer(backend, "test")
19
20 _, err := server.CallReadTool(context.Background(), ReadArguments{Target: "meal_plan"})
21 if err == nil {
22 t.Fatal("CallReadTool() error = nil, want unsupported target error")
23 }
24 if err.Error() != `unsupported read target "meal_plan" (supported: shopping_list, recipes, recipe, example_recipes)` {
25 t.Fatalf("CallReadTool() error = %q", err.Error())
26 }
27 if backend.readShoppingListCalls != 0 || backend.metadataCalls != 0 || backend.contentCalls != 0 {
28 t.Fatalf(
29 "backend calls = shopping list %d metadata %d content %d, want none",
30 backend.readShoppingListCalls,
31 backend.metadataCalls,
32 backend.contentCalls,
33 )
34 }
35}
36
37// recipes.SAVE.10 tools.SCHEMA.4 tools.RESPONSES.6
38func TestCallToolACIDRecipesSave10RejectsUnsupportedSourceBeforeCallingCooked(t *testing.T) {
39 backend := &fakeBackend{}
40 server := NewServer(backend, "test")
41
42 _, _, err := server.callSaveRecipeTool(context.Background(), nil, SaveRecipeArguments{Source: "unsupported"})
43 if err == nil {
44 t.Fatal("callSaveRecipeTool() error = nil, want unsupported source error")
45 }
46 if err.Error() != `unsupported save_recipe source "unsupported" (supported: raw_text, prepared, url, draft, existing)` {
47 t.Fatalf("callSaveRecipeTool() error = %q", err.Error())
48 }
49 if backend.saveCalls != 0 || backend.draftSaveCalls != 0 {
50 t.Fatalf("save calls = prepared %d draft %d, want none", backend.saveCalls, backend.draftSaveCalls)
51 }
52}
53
54// tools.SCHEMA.3 tools.SCHEMA.5 tools.SAVE_RECIPE_TOOL.1 tools.CHANGE_SHOPPING_LIST_TOOL.1
55func TestToolDescriptionsACIDToolsSchema3And5DescribeCurrentLimits(t *testing.T) {
56 read := readTool()
57 for _, want := range []string{
58 "shopping_list",
59 "recipes",
60 "recipe with recipe_id",
61 "example_recipes",
62 "You MUST check the examples before saving anything",
63 "particular Markdown dialect",
64 "Recipe results omit images and thumbnails",
65 } {
66 if !strings.Contains(read.Description, want) {
67 t.Fatalf("read description missing %q: %q", want, read.Description)
68 }
69 }
70
71 save := saveRecipeTool()
72 for _, want := range []string{
73 "raw text",
74 "prepared markdown",
75 "URL import",
76 "reviewed URL-import draft",
77 "existing recipe update",
78 "URL imports will return a draft_id for review",
79 } {
80 if !strings.Contains(save.Description, want) {
81 t.Fatalf("save_recipe description missing %q: %q", want, save.Description)
82 }
83 }
84
85 shopping := changeShoppingListTool()
86 for _, want := range []string{
87 "add",
88 "update_item",
89 "replace_selection",
90 "add_selection",
91 "remove_selection",
92 "remove",
93 "clear",
94 "Remove and clear are destructive",
95 "put quantities before names",
96 } {
97 if !strings.Contains(shopping.Description, want) {
98 t.Fatalf("change_shopping_list description missing %q: %q", want, shopping.Description)
99 }
100 }
101 if strings.Contains(shopping.Description, "Simple read-output lines") {
102 t.Fatalf("change_shopping_list description includes removed read-output guidance: %q", shopping.Description)
103 }
104}
105
106// tools.SCHEMA.6
107func TestChangeShoppingListToolACIDToolsSchema6ExposesProductGroupIDsAsOptionalNonNullableStringArray(t *testing.T) {
108 schema := changeShoppingListInputSchema()
109 property, ok := schema.Properties["product_group_ids"]
110 if !ok {
111 t.Fatal("product_group_ids schema missing")
112 }
113 if slices.Contains(schema.Required, "product_group_ids") {
114 t.Fatalf("required fields = %#v, want product_group_ids optional", schema.Required)
115 }
116 if property.Type != "array" {
117 t.Fatalf("product_group_ids type = %q, want array", property.Type)
118 }
119 if len(property.Types) != 0 {
120 t.Fatalf("product_group_ids types = %#v, want none", property.Types)
121 }
122 if len(property.AnyOf) != 0 {
123 t.Fatalf("product_group_ids anyOf = %#v, want none", property.AnyOf)
124 }
125 if property.Items == nil || property.Items.Type != "string" {
126 t.Fatalf("product_group_ids items = %#v, want string items", property.Items)
127 }
128
129 resolved, err := schema.Resolve(nil)
130 if err != nil {
131 t.Fatalf("Resolve() error = %v", err)
132 }
133 if err := resolved.Validate(map[string]any{"action": "clear"}); err != nil {
134 t.Fatalf("Validate() with omitted product_group_ids error = %v", err)
135 }
136 if err := resolved.Validate(map[string]any{"action": "clear", "product_group_ids": nil}); err == nil {
137 t.Fatal("Validate() with null product_group_ids error = nil, want rejection")
138 }
139}
140
141// tools.READ_TOOL.1 tools.SAVE_RECIPE_TOOL.3 tools.SAVE_RECIPE_TOOL.5 tools.SAVE_RECIPE_TOOL.6
142func TestArgumentDescriptionsACIDToolsReadTool1AndSaveRecipeTool3And5And6PointToExampleRecipes(t *testing.T) {
143 readTarget, ok := reflect.TypeFor[ReadArguments]().FieldByName("Target")
144 if !ok {
145 t.Fatal("ReadArguments.Target field missing")
146 }
147 if !strings.Contains(string(readTarget.Tag), "example_recipes") {
148 t.Fatalf("ReadArguments.Target tag = %q, want example_recipes", readTarget.Tag)
149 }
150
151 saveMarkdown, ok := reflect.TypeFor[SaveRecipeArguments]().FieldByName("Markdown")
152 if !ok {
153 t.Fatal("SaveRecipeArguments.Markdown field missing")
154 }
155 for _, want := range []string{"example_recipes", "Cooked's markdown dialect"} {
156 if !strings.Contains(string(saveMarkdown.Tag), want) {
157 t.Fatalf("SaveRecipeArguments.Markdown tag missing %q: %q", want, saveMarkdown.Tag)
158 }
159 }
160}