Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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
Expand Down
88 changes: 88 additions & 0 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 16 additions & 3 deletions internal/client/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading