Files
platform/mcp/tool.go
T
2026-08-22 22:33:18 +02:00

113 lines
3.7 KiB
Go

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
}