diff --git a/cmd/mcp.go b/cmd/mcp.go index f5e1e88..855a642 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -116,6 +116,11 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error { if mcpToolsListVerbose { for _, tool := range tools { fmt.Printf("=== %s ===\n", tool.Name) + fmt.Printf("Category: %s\n", toolCategoryLabel(tool.Annotations)) + if tool.Annotations != nil { + annJSON, _ := json.MarshalIndent(tool.Annotations, "", " ") + fmt.Printf("Annotations:\n%s\n", string(annJSON)) + } fmt.Printf("Description:\n%s\n", tool.Description) if len(tool.InputSchema) > 0 { var schema interface{} @@ -128,13 +133,36 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error { } } else { for _, tool := range tools { - fmt.Printf("%-40s %s\n", tool.Name, truncate(tool.Description, 80)) + fmt.Printf("%-7s %-40s %s\n", + "["+toolCategoryLabel(tool.Annotations)+"]", + tool.Name, + truncate(tool.Description, 80)) } } return nil } +// toolCategoryLabel returns a short tag describing a tool's behavior based on +// its MCP annotations. Categories follow the MCP hint semantics; empty +// annotations fall back to "?" because the spec defaults are pessimistic +// (write + destructive) and we want that to be visible, not silent. +func toolCategoryLabel(a *client.ToolAnnotations) string { + if a == nil { + return "?" + } + if a.ReadOnlyHint != nil && *a.ReadOnlyHint { + return "read" + } + if a.DestructiveHint != nil && *a.DestructiveHint { + return "del" + } + if a.IdempotentHint != nil && *a.IdempotentHint { + return "idem" + } + return "write" +} + func runMCPToolsCall(cmd *cobra.Command, args []string) error { if mcpJSON { cmd.SilenceUsage = true diff --git a/cmd/mcp_test.go b/cmd/mcp_test.go index ef11e0e..9f5cf58 100644 --- a/cmd/mcp_test.go +++ b/cmd/mcp_test.go @@ -164,6 +164,94 @@ func TestSimilarTools(t *testing.T) { } } +func TestToolCategoryLabel(t *testing.T) { + tr := true + fa := false + + tests := []struct { + name string + a *client.ToolAnnotations + want string + }{ + {"nil annotations -> ?", nil, "?"}, + {"readOnly", &client.ToolAnnotations{ReadOnlyHint: &tr}, "read"}, + {"destructive (readOnly false)", &client.ToolAnnotations{ + ReadOnlyHint: &fa, DestructiveHint: &tr, IdempotentHint: &fa, + }, "del"}, + {"idempotent mutation", &client.ToolAnnotations{ + ReadOnlyHint: &fa, DestructiveHint: &fa, IdempotentHint: &tr, + }, "idem"}, + {"additive write", &client.ToolAnnotations{ + ReadOnlyHint: &fa, DestructiveHint: &fa, IdempotentHint: &fa, + }, "write"}, + {"empty annotations object (all hints absent) -> write", &client.ToolAnnotations{}, "write"}, + {"readOnly wins over destructive flag", &client.ToolAnnotations{ + ReadOnlyHint: &tr, DestructiveHint: &tr, + }, "read"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := toolCategoryLabel(tt.a); got != tt.want { + t.Errorf("toolCategoryLabel = %q, want %q", got, tt.want) + } + }) + } +} + +func TestToolDefinition_AnnotationsRoundTrip(t *testing.T) { + // Pretend we got this from a tools/list response on the wire and ensure + // it survives a marshal/unmarshal cycle — the cache writes back via + // json.Marshal, so we need annotations to persist. + raw := []byte(`{ + "name": "get_snap_campaigns", + "description": "Read campaigns", + "inputSchema": {"type":"object","properties":{}}, + "annotations": {"readOnlyHint": true, "openWorldHint": false} + }`) + + var td client.ToolDefinition + if err := json.Unmarshal(raw, &td); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if td.Annotations == nil { + t.Fatalf("annotations were dropped") + } + if td.Annotations.ReadOnlyHint == nil || *td.Annotations.ReadOnlyHint != true { + t.Errorf("ReadOnlyHint = %v, want *true", td.Annotations.ReadOnlyHint) + } + if td.Annotations.OpenWorldHint == nil || *td.Annotations.OpenWorldHint != false { + t.Errorf("OpenWorldHint = %v, want *false", td.Annotations.OpenWorldHint) + } + + // Marshal back and verify pointers serialize as bare booleans, not the + // stringly-typed shape Go can fall into with custom MarshalJSON. + out, err := json.Marshal(td) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if !json.Valid(out) { + t.Fatalf("re-marshaled JSON is invalid: %s", out) + } + var back map[string]interface{} + if err := json.Unmarshal(out, &back); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + ann, ok := back["annotations"].(map[string]interface{}) + if !ok { + t.Fatalf("annotations missing after round trip: %s", out) + } + if ann["readOnlyHint"] != true { + t.Errorf("round-tripped readOnlyHint = %v, want true", ann["readOnlyHint"]) + } + if ann["openWorldHint"] != false { + t.Errorf("round-tripped openWorldHint = %v, want false", ann["openWorldHint"]) + } + // Fields we did not set must not appear at all (omitempty respected). + if _, present := ann["destructiveHint"]; present { + t.Errorf("destructiveHint should be omitted when unset") + } +} + func TestEmitJSONError_InJSONMode(t *testing.T) { prev := mcpJSON mcpJSON = true diff --git a/internal/client/mcp.go b/internal/client/mcp.go index 5da0ad5..48dad18 100644 --- a/internal/client/mcp.go +++ b/internal/client/mcp.go @@ -57,11 +57,24 @@ func (e *jsonRPCError) Error() string { return fmt.Sprintf("MCP error %d: %s", e.Code, e.Message) } +// ToolAnnotations carries the MCP tool behavior hints (spec 2025-06-18 / 2025-11-25). +// All fields are HINTS that clients use for UX (confirmation prompts, grouping) +// not enforcement. Spec defaults when unset: readOnlyHint=false, +// destructiveHint=true, idempotentHint=false, openWorldHint=true. +type ToolAnnotations struct { + Title string `json:"title,omitempty"` + ReadOnlyHint *bool `json:"readOnlyHint,omitempty"` + DestructiveHint *bool `json:"destructiveHint,omitempty"` + IdempotentHint *bool `json:"idempotentHint,omitempty"` + OpenWorldHint *bool `json:"openWorldHint,omitempty"` +} + // ToolDefinition represents a tool from tools/list. type ToolDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema json.RawMessage `json:"inputSchema"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` + Annotations *ToolAnnotations `json:"annotations,omitempty"` } // ToolsListResult is the result of tools/list.