server_ids.go

  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}