1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
2//
3// SPDX-License-Identifier: LicenseRef-MutuaL-1.2
4
5package mcp
6
7import (
8 "context"
9 "fmt"
10 "regexp"
11 "strings"
12
13 sdk "github.com/modelcontextprotocol/go-sdk/mcp"
14)
15
16const uuidPattern = `^[ \t\r\n]*[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}[ \t\r\n]*$`
17
18const (
19 uuidLength = len("00000000-0000-0000-0000-000000000000")
20 resolveRecipePageLimit = 30
21 maxResolveRecipePages = 100
22)
23
24var uuidRegex = regexp.MustCompile(uuidPattern)
25
26func validateRecipeID(recipeID string) error { return validateUUID("recipe_id", recipeID) }
27
28func validateProductGroupID(productGroupID string) error {
29 return validateUUID("product_group_id", productGroupID)
30}
31
32func validateUUID(field, value string) error {
33 if !uuidRegex.MatchString(strings.TrimSpace(value)) {
34 return fmt.Errorf("%s must be a UUID", field)
35 }
36
37 return nil
38}
39
40func validateUUIDPrefix(prefix string) error {
41 prefix = strings.TrimSpace(prefix)
42 if prefix == "" {
43 return fmt.Errorf("prefix is required")
44 }
45 if len(prefix) > uuidLength {
46 return fmt.Errorf("prefix must be a UUID prefix")
47 }
48 for index, char := range prefix {
49 switch index {
50 case 8, 13, 18, 23:
51 if char != '-' {
52 return fmt.Errorf("prefix must be a UUID prefix")
53 }
54 default:
55 if !isHex(char) {
56 return fmt.Errorf("prefix must be a UUID prefix")
57 }
58 }
59 }
60
61 return nil
62}
63
64func isHex(char rune) bool {
65 return char >= '0' && char <= '9' || char >= 'a' && char <= 'f' || char >= 'A' && char <= 'F'
66}
67
68func (s *Server) callResolveRecipeIDTool(
69 ctx context.Context,
70 _ *sdk.CallToolRequest,
71 arguments ResolveRecipeIDArguments,
72) (*sdk.CallToolResult, ResolveRecipeIDOutput, error) {
73 prefix := strings.TrimSpace(arguments.Prefix)
74 if err := validateUUIDPrefix(prefix); err != nil {
75 return nil, ResolveRecipeIDOutput{}, err
76 }
77
78 matches, err := s.resolveRecipeIDMatches(ctx, strings.ToLower(prefix))
79 if err != nil {
80 return nil, ResolveRecipeIDOutput{}, err
81 }
82
83 output := ResolveRecipeIDOutput{Recipes: matches}
84 if len(matches) > 1 {
85 output.Warning = "Multiple saved recipes match this UUID prefix. Use the full recipe_id before mutating or deleting a recipe."
86 }
87
88 return newToolResult(formatResolveRecipeID(prefix, output), output), output, nil
89}
90
91func (s *Server) resolveRecipeIDMatches(ctx context.Context, prefix string) ([]RecipeSummary, error) {
92 matches := []RecipeSummary{}
93 for page := 1; page <= maxResolveRecipePages+1; page++ {
94 recipes, err := s.backend.ListRecipes(ctx, page, resolveRecipePageLimit)
95 if err != nil {
96 return nil, err
97 }
98 if page > maxResolveRecipePages {
99 if len(recipes) > 0 {
100 return nil, fmt.Errorf(
101 "too many saved recipes to resolve recipe_id prefix; narrow the prefix or use the full recipe_id",
102 )
103 }
104
105 return matches, nil
106 }
107 for _, recipe := range recipes {
108 if strings.HasPrefix(strings.ToLower(recipe.ID), prefix) {
109 matches = append(matches, RecipeSummary{ID: recipe.ID, Title: recipe.Title})
110 }
111 }
112 if len(recipes) < resolveRecipePageLimit {
113 return matches, nil
114 }
115 }
116
117 return matches, nil
118}
119
120func resolveRecipeIDTool() *sdk.Tool {
121 openWorld := true
122 return &sdk.Tool{
123 Name: resolveRecipeIDToolName,
124 Title: "Resolve recipe ID",
125 Description: "Resolve a UUID prefix to matching saved Cooked recipe IDs and titles. Use this when a recipe ID was copied partially or needs disambiguation.",
126 Annotations: &sdk.ToolAnnotations{
127 Title: "Resolve recipe ID",
128 ReadOnlyHint: true,
129 OpenWorldHint: &openWorld,
130 },
131 }
132}
133
134func formatResolveRecipeID(prefix string, output ResolveRecipeIDOutput) string {
135 if len(output.Recipes) == 0 {
136 return fmt.Sprintf("No saved recipes matched UUID prefix %q.", prefix)
137 }
138
139 var builder strings.Builder
140 fmt.Fprintf(&builder, "Saved recipes matching UUID prefix %q:\n", prefix)
141 for _, recipe := range output.Recipes {
142 builder.WriteString("- ")
143 builder.WriteString(recipe.Title)
144 builder.WriteString(" (id: ")
145 builder.WriteString(recipe.ID)
146 builder.WriteString(")\n")
147 }
148 if output.Warning != "" {
149 builder.WriteByte('\n')
150 builder.WriteString(output.Warning)
151 }
152
153 return strings.TrimRight(builder.String(), "\n")
154}
155
156// ResolveRecipeIDArguments contains resolve_recipe_id tool arguments.
157type ResolveRecipeIDArguments struct {
158 Prefix string `json:"prefix" jsonschema:"UUID prefix to resolve against saved recipe IDs."`
159}
160
161// ResolveRecipeIDOutput is the structured output for resolve_recipe_id.
162type ResolveRecipeIDOutput struct {
163 Recipes []RecipeSummary `json:"recipes,omitempty"`
164 Warning string `json:"warning,omitempty"`
165}