package mcp import ( "context" "encoding/json" "fmt" "strings" ) // Tool is one MCP tool: its advertised schema plus the Invoke func that parses // arguments, does the work, and returns a JSON-encodable payload. Invoke's error // is surfaced to the client as a tool error (isError: true), not a protocol // error. The context carries request scope (deadline, cancellation, trace span); // it flows from the HTTP request through to Invoke. type Tool struct { Name string `json:"name"` Description string `json:"description"` InputSchema json.RawMessage `json:"inputSchema"` // Annotations are optional hints about the tool's behaviour; nil means // unannotated (clients should then assume the safest interpretation). Annotations *ToolAnnotations `json:"annotations,omitempty"` Invoke func(ctx context.Context, args json.RawMessage) (any, error) `json:"-"` } // Text wraps a payload as a successful tools/call result: one JSON text block. func Text(v any) any { b, err := json.Marshal(v) if err != nil { return ErrorResult(err) } return map[string]any{ "content": []map[string]any{{"type": "text", "text": string(b)}}, } } // ErrorResult wraps an operational error as an isError tools/call result (a tool // failure the model can read, not a JSON-RPC protocol error). func ErrorResult(err error) any { return map[string]any{ "isError": true, "content": []map[string]any{{"type": "text", "text": err.Error()}}, } } // ToolAnnotations is the MCP tool-annotations subset that matters here: // hints clients (and our own agent loop) use to treat calls differently — // e.g. auto-run reads, confirm destructive writes. Both fields are always // serialized so absence never falls back to the spec's dangerous defaults. type ToolAnnotations struct { ReadOnlyHint bool `json:"readOnlyHint"` DestructiveHint bool `json:"destructiveHint"` } // Shared annotation presets. var ( // ReadOnly: pure queries — safe to run without confirmation. ReadOnly = &ToolAnnotations{ReadOnlyHint: true} // Mutable: writes state but is not destructive (create/update/register). Mutable = &ToolAnnotations{} // Destructive: deletes, reversals, bulk overwrites. Destructive = &ToolAnnotations{DestructiveHint: true} ) // AnnotateByPrefix fills in Annotations for tools that don't carry one yet, // using the repo-wide tool naming convention: // // get_*/list_*/search_*/find_*/suggest_*/describe_* → ReadOnly // delete_*/remove_*/reverse_*/import_*/purge_* → Destructive // export_*/calculate_*/verify_*/preview_* → ReadOnly (pure computation) // // Tools that set Annotations explicitly are left untouched, so a package can // override individual cases after calling this. func AnnotateByPrefix(tools []Tool) { for i := range tools { if tools[i].Annotations != nil { continue } name := strings.ToLower(tools[i].Name) switch { case hasAny(name, "get_", "list_", "search_", "find_", "suggest_", "export_", "calculate_", "verify_", "preview_"): tools[i].Annotations = ReadOnly case hasAny(name, "delete_", "remove_", "reverse_", "import_", "purge_"): tools[i].Annotations = Destructive default: tools[i].Annotations = Mutable } } } func hasAny(name string, prefixes ...string) bool { for _, p := range prefixes { if strings.HasPrefix(name, p) { return true } } return false } // Decode unmarshals tool arguments into v, treating empty/null args as a no-op // (zero value). Returns a wrapped error suitable for returning from Invoke. func Decode(args json.RawMessage, v any) error { if len(args) == 0 || string(args) == "null" { return nil } if err := json.Unmarshal(args, v); err != nil { return fmt.Errorf("invalid arguments: %w", err) } return nil }