clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/tinyagent
tinyrelay / internal / mcp / tools.go
tools.go 9.3K
1package mcp23import (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)1617// 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}2728// Handler executes a tool. A returned error is reported to the client as a29// tool execution error the model can act on; protocol errors are raised by30// the transport before the handler runs.31type Handler func(ctx context.Context, call Call) (Result, error)3233// Call carries the validated arguments of a tools/call request together with34// the authenticated actor.35type Call struct {36 Actor string37 Name string38 Arguments map[string]any39 Request *http.Request40}4142// String returns a string argument or "".43func (c Call) String(key string) string {44 value, _ := c.Arguments[key].(string)45 return value46}4748// 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}5354// 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}6162// Result is what a tool returns. StructuredContent is mirrored into a text63// 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}6970// Value builds a successful result from a JSON value.71func Value(value any) Result {72 return Result{StructuredContent: value}73}7475// Failure builds a tool execution error. Structured data, when given, helps76// 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}8081func (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 r90}9192// Registry is an ordered tool table. Tools list in the order they were93// added so clients can cache the list.94type Registry struct {95 tools []Tool96 index map[string]int97}9899var toolName = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)100101// 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 nil121}122123// Tools returns the table in registration order.124func (r *Registry) Tools() []Tool {125 if r == nil {126 return nil127 }128 return append([]Tool(nil), r.tools...)129}130131// Lookup finds a tool by name.132func (r *Registry) Lookup(name string) (Tool, bool) {133 if r == nil || r.index == nil {134 return Tool{}, false135 }136 i, ok := r.index[name]137 if !ok {138 return Tool{}, false139 }140 return r.tools[i], true141}142143// Object builds a closed object schema. Properties are sorted when listed so144// 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"] = required152 }153 return schema154}155156// Validate checks a value against the subset of JSON Schema 2020-12 the tool157// 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}162163func validate(schema map[string]any, value any, path string) error {164 where := func() string {165 if path == "" {166 return "argument"167 }168 return path169 }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 continue227 }228 if object[key] == nil {229 continue230 }231 if err := validate(property, object[key], join(path, key)); err != nil {232 return err233 }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 err241 }242 }243 }244 }245 return nil246}247248func 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 nil277}278279func required(schema map[string]any) []string {280 switch list := schema["required"].(type) {281 case []string:282 return list283 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 out291 }292 return nil293}294295// enumOptions renders enum members as strings so integer and string enums296// compare the same way.297func enumOptions(value any) []string {298 switch list := value.(type) {299 case []string:300 return list301 case []int:302 out := make([]string, 0, len(list))303 for _, item := range list {304 out = append(out, strconv.Itoa(item))305 }306 return out307 case []any:308 out := make([]string, 0, len(list))309 for _, item := range list {310 out = append(out, scalar(item))311 }312 return out313 }314 return nil315}316317func scalar(value any) string {318 switch v := value.(type) {319 case string:320 return v321 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}330331func number(value any) (float64, bool) {332 switch n := value.(type) {333 case int:334 return float64(n), true335 case int64:336 return float64(n), true337 case float64:338 return n, true339 }340 return 0, false341}342343func join(path, key string) string {344 if path == "" {345 return key346 }347 return path + "." + key348}349350func contains(values []string, want string) bool {351 for _, value := range values {352 if value == want {353 return true354 }355 }356 return false357}