tinyrelayfile
tinyrelay/internal/mcp/tools.go/

internal/mcp/tools.go raw
1package mcp
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "math"
9 "net/http"
10 "regexp"
11 "sort"
12 "strconv"
13 "strings"
14 "unicode/utf8"
15)
16
17// Tool is one entry in a server's tool table.
18type Tool struct {
19 Name string `json:"name"`
20 Title string `json:"title,omitempty"`
21 Description string `json:"description"`
22 InputSchema map[string]any `json:"inputSchema"`
23 OutputSchema map[string]any `json:"outputSchema,omitempty"`
24 Annotations map[string]any `json:"annotations,omitempty"`
25 Handler Handler `json:"-"`
26}
27
28// Handler executes a tool. A returned error is reported to the client as a
29// tool execution error the model can act on; protocol errors are raised by
30// the transport before the handler runs.
31type Handler func(ctx context.Context, call Call) (Result, error)
32
33// Call carries the validated arguments of a tools/call request together with
34// the authenticated actor.
35type Call struct {
36 Actor string
37 Name string
38 Arguments map[string]any
39 Request *http.Request
40}
41
42// String returns a string argument or "".
43func (c Call) String(key string) string {
44 value, _ := c.Arguments[key].(string)
45 return value
46}
47
48// Int returns an integer argument or 0.
49func (c Call) Int(key string) int {
50 value, _ := c.Arguments[key].(float64)
51 return int(value)
52}
53
54// Content is one unstructured content block of a tool result.
55type Content struct {
56 Type string `json:"type"`
57 Text string `json:"text,omitempty"`
58 Data string `json:"data,omitempty"`
59 MimeType string `json:"mimeType,omitempty"`
60}
61
62// Result is what a tool returns. StructuredContent is mirrored into a text
63// block when Content is empty so every client sees the same data.
64type Result struct {
65 Content []Content `json:"content"`
66 StructuredContent any `json:"structuredContent,omitempty"`
67 IsError bool `json:"isError,omitempty"`
68}
69
70// Value builds a successful result from a JSON value.
71func Value(value any) Result {
72 return Result{StructuredContent: value}
73}
74
75// Failure builds a tool execution error. Structured data, when given, helps
76// the client correct the call.
77func Failure(message string, structured any) Result {
78 return Result{Content: []Content{{Type: "text", Text: message}}, StructuredContent: structured, IsError: true}
79}
80
81func (r Result) filled() Result {
82 if len(r.Content) == 0 {
83 text, err := json.Marshal(r.StructuredContent)
84 if err != nil {
85 text = []byte("null")
86 }
87 r.Content = []Content{{Type: "text", Text: string(text)}}
88 }
89 return r
90}
91
92// Registry is an ordered tool table. Tools list in the order they were
93// added so clients can cache the list.
94type Registry struct {
95 tools []Tool
96 index map[string]int
97}
98
99var toolName = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
100
101// Add registers a tool. Names must be unique and header safe.
102func (r *Registry) Add(tool Tool) error {
103 if !toolName.MatchString(tool.Name) {
104 return fmt.Errorf("mcp: invalid tool name %q", tool.Name)
105 }
106 if tool.Handler == nil {
107 return fmt.Errorf("mcp: tool %q has no handler", tool.Name)
108 }
109 if tool.InputSchema == nil {
110 tool.InputSchema = Object(nil)
111 }
112 if r.index == nil {
113 r.index = map[string]int{}
114 }
115 if _, exists := r.index[tool.Name]; exists {
116 return fmt.Errorf("mcp: duplicate tool %q", tool.Name)
117 }
118 r.index[tool.Name] = len(r.tools)
119 r.tools = append(r.tools, tool)
120 return nil
121}
122
123// Tools returns the table in registration order.
124func (r *Registry) Tools() []Tool {
125 if r == nil {
126 return nil
127 }
128 return append([]Tool(nil), r.tools...)
129}
130
131// Lookup finds a tool by name.
132func (r *Registry) Lookup(name string) (Tool, bool) {
133 if r == nil || r.index == nil {
134 return Tool{}, false
135 }
136 i, ok := r.index[name]
137 if !ok {
138 return Tool{}, false
139 }
140 return r.tools[i], true
141}
142
143// Object builds a closed object schema. Properties are sorted when listed so
144// the schema serializes deterministically.
145func Object(properties map[string]any, required ...string) map[string]any {
146 if properties == nil {
147 properties = map[string]any{}
148 }
149 schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false}
150 if len(required) > 0 {
151 schema["required"] = required
152 }
153 return schema
154}
155
156// Validate checks a value against the subset of JSON Schema 2020-12 the tool
157// tables use: type, properties, required, additionalProperties, enum, not,
158// pattern, minimum, maximum, minLength, maxLength, minProperties and items.
159func Validate(schema map[string]any, value any) error {
160 return validate(schema, value, "")
161}
162
163func validate(schema map[string]any, value any, path string) error {
164 where := func() string {
165 if path == "" {
166 return "argument"
167 }
168 return path
169 }
170 if typ, ok := schema["type"].(string); ok {
171 if err := checkType(typ, value); err != nil {
172 return fmt.Errorf("%s %s", where(), err)
173 }
174 }
175 if options := enumOptions(schema["enum"]); len(options) > 0 && !contains(options, scalar(value)) {
176 return fmt.Errorf("%s must be one of %s", where(), strings.Join(options, ", "))
177 }
178 if excluded, ok := schema["not"].(map[string]any); ok {
179 if validate(excluded, value, path) == nil {
180 return fmt.Errorf("%s matches an excluded value", where())
181 }
182 }
183 if pattern, ok := schema["pattern"].(string); ok {
184 text, _ := value.(string)
185 if re, err := regexp.Compile(pattern); err == nil && !re.MatchString(text) {
186 return fmt.Errorf("%s does not match %s", where(), pattern)
187 }
188 }
189 if text, ok := value.(string); ok {
190 if min, ok := number(schema["minLength"]); ok && float64(utf8.RuneCountInString(text)) < min {
191 return fmt.Errorf("%s must not be empty", where())
192 }
193 if max, ok := number(schema["maxLength"]); ok && float64(utf8.RuneCountInString(text)) > max {
194 return fmt.Errorf("%s is longer than %d characters", where(), int(max))
195 }
196 }
197 if n, ok := value.(float64); ok {
198 if min, ok := number(schema["minimum"]); ok && n < min {
199 return fmt.Errorf("%s must be at least %v", where(), min)
200 }
201 if max, ok := number(schema["maximum"]); ok && n > max {
202 return fmt.Errorf("%s must be at most %v", where(), max)
203 }
204 }
205 if object, ok := value.(map[string]any); ok {
206 properties, _ := schema["properties"].(map[string]any)
207 for _, key := range required(schema) {
208 if _, present := object[key]; !present {
209 return fmt.Errorf("%s is required", join(path, key))
210 }
211 }
212 if min, ok := number(schema["minProperties"]); ok && float64(len(object)) < min {
213 return fmt.Errorf("%s must not be empty", where())
214 }
215 keys := make([]string, 0, len(object))
216 for key := range object {
217 keys = append(keys, key)
218 }
219 sort.Strings(keys)
220 for _, key := range keys {
221 property, known := properties[key].(map[string]any)
222 if !known {
223 if closed, _ := schema["additionalProperties"].(bool); schema["additionalProperties"] != nil && !closed {
224 return fmt.Errorf("%s is not a known argument", join(path, key))
225 }
226 continue
227 }
228 if object[key] == nil {
229 continue
230 }
231 if err := validate(property, object[key], join(path, key)); err != nil {
232 return err
233 }
234 }
235 }
236 if list, ok := value.([]any); ok {
237 if items, ok := schema["items"].(map[string]any); ok {
238 for i, item := range list {
239 if err := validate(items, item, fmt.Sprintf("%s[%d]", where(), i)); err != nil {
240 return err
241 }
242 }
243 }
244 }
245 return nil
246}
247
248func checkType(typ string, value any) error {
249 switch typ {
250 case "string":
251 if _, ok := value.(string); !ok {
252 return errors.New("must be a string")
253 }
254 case "integer":
255 n, ok := value.(float64)
256 if !ok || n != math.Trunc(n) {
257 return errors.New("must be an integer")
258 }
259 case "number":
260 if _, ok := value.(float64); !ok {
261 return errors.New("must be a number")
262 }
263 case "boolean":
264 if _, ok := value.(bool); !ok {
265 return errors.New("must be true or false")
266 }
267 case "object":
268 if _, ok := value.(map[string]any); !ok {
269 return errors.New("must be an object")
270 }
271 case "array":
272 if _, ok := value.([]any); !ok {
273 return errors.New("must be an array")
274 }
275 }
276 return nil
277}
278
279func required(schema map[string]any) []string {
280 switch list := schema["required"].(type) {
281 case []string:
282 return list
283 case []any:
284 out := make([]string, 0, len(list))
285 for _, item := range list {
286 if text, ok := item.(string); ok {
287 out = append(out, text)
288 }
289 }
290 return out
291 }
292 return nil
293}
294
295// enumOptions renders enum members as strings so integer and string enums
296// compare the same way.
297func enumOptions(value any) []string {
298 switch list := value.(type) {
299 case []string:
300 return list
301 case []int:
302 out := make([]string, 0, len(list))
303 for _, item := range list {
304 out = append(out, strconv.Itoa(item))
305 }
306 return out
307 case []any:
308 out := make([]string, 0, len(list))
309 for _, item := range list {
310 out = append(out, scalar(item))
311 }
312 return out
313 }
314 return nil
315}
316
317func scalar(value any) string {
318 switch v := value.(type) {
319 case string:
320 return v
321 case float64:
322 return strconv.FormatFloat(v, 'f', -1, 64)
323 case int:
324 return strconv.Itoa(v)
325 case bool:
326 return strconv.FormatBool(v)
327 }
328 return ""
329}
330
331func number(value any) (float64, bool) {
332 switch n := value.(type) {
333 case int:
334 return float64(n), true
335 case int64:
336 return float64(n), true
337 case float64:
338 return n, true
339 }
340 return 0, false
341}
342
343func join(path, key string) string {
344 if path == "" {
345 return key
346 }
347 return path + "." + key
348}
349
350func contains(values []string, want string) bool {
351 for _, value := range values {
352 if value == want {
353 return true
354 }
355 }
356 return false
357}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs