Skip to content

Commit fc9f56b

Browse files
authored
feat(mcp): surface tool annotations in tools-list output (#17)
Servers can now advertise readOnlyHint / destructiveHint / idempotentHint / openWorldHint on each tool (MCP spec 2025-06-18). The CLI now: - Parses and preserves annotations in ToolDefinition (round-trips through the local tools-cache.json). - Prefixes every line of tools-list output with a category tag — [read], [write], [idem], [del], or [?] when a server omits annotations. - Prints the full annotations object in --verbose mode. - Passes annotations through --json mode untouched.
1 parent 671cf7f commit fc9f56b

3 files changed

Lines changed: 133 additions & 4 deletions

File tree

‎cmd/mcp.go‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
116116
if mcpToolsListVerbose {
117117
for _, tool := range tools {
118118
fmt.Printf("=== %s ===\n", tool.Name)
119+
fmt.Printf("Category: %s\n", toolCategoryLabel(tool.Annotations))
120+
if tool.Annotations != nil {
121+
annJSON, _ := json.MarshalIndent(tool.Annotations, "", " ")
122+
fmt.Printf("Annotations:\n%s\n", string(annJSON))
123+
}
119124
fmt.Printf("Description:\n%s\n", tool.Description)
120125
if len(tool.InputSchema) > 0 {
121126
var schema interface{}
@@ -128,13 +133,36 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
128133
}
129134
} else {
130135
for _, tool := range tools {
131-
fmt.Printf("%-40s %s\n", tool.Name, truncate(tool.Description, 80))
136+
fmt.Printf("%-7s %-40s %s\n",
137+
"["+toolCategoryLabel(tool.Annotations)+"]",
138+
tool.Name,
139+
truncate(tool.Description, 80))
132140
}
133141
}
134142

135143
return nil
136144
}
137145

146+
// toolCategoryLabel returns a short tag describing a tool's behavior based on
147+
// its MCP annotations. Categories follow the MCP hint semantics; empty
148+
// annotations fall back to "?" because the spec defaults are pessimistic
149+
// (write + destructive) and we want that to be visible, not silent.
150+
func toolCategoryLabel(a *client.ToolAnnotations) string {
151+
if a == nil {
152+
return "?"
153+
}
154+
if a.ReadOnlyHint != nil && *a.ReadOnlyHint {
155+
return "read"
156+
}
157+
if a.DestructiveHint != nil && *a.DestructiveHint {
158+
return "del"
159+
}
160+
if a.IdempotentHint != nil && *a.IdempotentHint {
161+
return "idem"
162+
}
163+
return "write"
164+
}
165+
138166
func runMCPToolsCall(cmd *cobra.Command, args []string) error {
139167
if mcpJSON {
140168
cmd.SilenceUsage = true

‎cmd/mcp_test.go‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,94 @@ func TestSimilarTools(t *testing.T) {
164164
}
165165
}
166166

167+
func TestToolCategoryLabel(t *testing.T) {
168+
tr := true
169+
fa := false
170+
171+
tests := []struct {
172+
name string
173+
a *client.ToolAnnotations
174+
want string
175+
}{
176+
{"nil annotations -> ?", nil, "?"},
177+
{"readOnly", &client.ToolAnnotations{ReadOnlyHint: &tr}, "read"},
178+
{"destructive (readOnly false)", &client.ToolAnnotations{
179+
ReadOnlyHint: &fa, DestructiveHint: &tr, IdempotentHint: &fa,
180+
}, "del"},
181+
{"idempotent mutation", &client.ToolAnnotations{
182+
ReadOnlyHint: &fa, DestructiveHint: &fa, IdempotentHint: &tr,
183+
}, "idem"},
184+
{"additive write", &client.ToolAnnotations{
185+
ReadOnlyHint: &fa, DestructiveHint: &fa, IdempotentHint: &fa,
186+
}, "write"},
187+
{"empty annotations object (all hints absent) -> write", &client.ToolAnnotations{}, "write"},
188+
{"readOnly wins over destructive flag", &client.ToolAnnotations{
189+
ReadOnlyHint: &tr, DestructiveHint: &tr,
190+
}, "read"},
191+
}
192+
for _, tt := range tests {
193+
t.Run(tt.name, func(t *testing.T) {
194+
if got := toolCategoryLabel(tt.a); got != tt.want {
195+
t.Errorf("toolCategoryLabel = %q, want %q", got, tt.want)
196+
}
197+
})
198+
}
199+
}
200+
201+
func TestToolDefinition_AnnotationsRoundTrip(t *testing.T) {
202+
// Pretend we got this from a tools/list response on the wire and ensure
203+
// it survives a marshal/unmarshal cycle — the cache writes back via
204+
// json.Marshal, so we need annotations to persist.
205+
raw := []byte(`{
206+
"name": "get_snap_campaigns",
207+
"description": "Read campaigns",
208+
"inputSchema": {"type":"object","properties":{}},
209+
"annotations": {"readOnlyHint": true, "openWorldHint": false}
210+
}`)
211+
212+
var td client.ToolDefinition
213+
if err := json.Unmarshal(raw, &td); err != nil {
214+
t.Fatalf("unmarshal: %v", err)
215+
}
216+
if td.Annotations == nil {
217+
t.Fatalf("annotations were dropped")
218+
}
219+
if td.Annotations.ReadOnlyHint == nil || *td.Annotations.ReadOnlyHint != true {
220+
t.Errorf("ReadOnlyHint = %v, want *true", td.Annotations.ReadOnlyHint)
221+
}
222+
if td.Annotations.OpenWorldHint == nil || *td.Annotations.OpenWorldHint != false {
223+
t.Errorf("OpenWorldHint = %v, want *false", td.Annotations.OpenWorldHint)
224+
}
225+
226+
// Marshal back and verify pointers serialize as bare booleans, not the
227+
// stringly-typed shape Go can fall into with custom MarshalJSON.
228+
out, err := json.Marshal(td)
229+
if err != nil {
230+
t.Fatalf("marshal: %v", err)
231+
}
232+
if !json.Valid(out) {
233+
t.Fatalf("re-marshaled JSON is invalid: %s", out)
234+
}
235+
var back map[string]interface{}
236+
if err := json.Unmarshal(out, &back); err != nil {
237+
t.Fatalf("re-unmarshal: %v", err)
238+
}
239+
ann, ok := back["annotations"].(map[string]interface{})
240+
if !ok {
241+
t.Fatalf("annotations missing after round trip: %s", out)
242+
}
243+
if ann["readOnlyHint"] != true {
244+
t.Errorf("round-tripped readOnlyHint = %v, want true", ann["readOnlyHint"])
245+
}
246+
if ann["openWorldHint"] != false {
247+
t.Errorf("round-tripped openWorldHint = %v, want false", ann["openWorldHint"])
248+
}
249+
// Fields we did not set must not appear at all (omitempty respected).
250+
if _, present := ann["destructiveHint"]; present {
251+
t.Errorf("destructiveHint should be omitted when unset")
252+
}
253+
}
254+
167255
func TestEmitJSONError_InJSONMode(t *testing.T) {
168256
prev := mcpJSON
169257
mcpJSON = true

‎internal/client/mcp.go‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,24 @@ func (e *jsonRPCError) Error() string {
5757
return fmt.Sprintf("MCP error %d: %s", e.Code, e.Message)
5858
}
5959

60+
// ToolAnnotations carries the MCP tool behavior hints (spec 2025-06-18 / 2025-11-25).
61+
// All fields are HINTS that clients use for UX (confirmation prompts, grouping)
62+
// not enforcement. Spec defaults when unset: readOnlyHint=false,
63+
// destructiveHint=true, idempotentHint=false, openWorldHint=true.
64+
type ToolAnnotations struct {
65+
Title string `json:"title,omitempty"`
66+
ReadOnlyHint *bool `json:"readOnlyHint,omitempty"`
67+
DestructiveHint *bool `json:"destructiveHint,omitempty"`
68+
IdempotentHint *bool `json:"idempotentHint,omitempty"`
69+
OpenWorldHint *bool `json:"openWorldHint,omitempty"`
70+
}
71+
6072
// ToolDefinition represents a tool from tools/list.
6173
type ToolDefinition struct {
62-
Name string `json:"name"`
63-
Description string `json:"description"`
64-
InputSchema json.RawMessage `json:"inputSchema"`
74+
Name string `json:"name"`
75+
Description string `json:"description"`
76+
InputSchema json.RawMessage `json:"inputSchema"`
77+
Annotations *ToolAnnotations `json:"annotations,omitempty"`
6578
}
6679

6780
// ToolsListResult is the result of tools/list.

0 commit comments

Comments
 (0)