Skip to content

Commit 02ac550

Browse files
authored
fix(mcp): decode and show each tool's title in tools-list (#20)
ToolDefinition had no top-level Title field, so every tool decoded as untitled. tools/list showed 0/63 titled against a google-ads-mcp that returns 63/63 -- and because --json and the on-disk tools cache both re-marshal this struct, the field vanished from those too. That is worse than cosmetic: a title is a BLOCKING Claude Connectors Directory submission requirement, and the pipeboard.co docs point at this command as the live compliance check. Following that advice reported the whole fleet as non-compliant when it was fine. MCP puts the display name in two places: the top-level title (spec 2025-06-18, what Pipeboard servers populate) and the legacy annotations.title. Only the latter was modeled. Add the top-level field, prefer it via DisplayTitle() with the legacy one as fallback, and surface it in all three output modes -- a title column in the compact list, a Title: line under --verbose, and the raw field under --json. An untitled tool renders "(no title!)" rather than blank, since on a Pipeboard server that is a real defect worth seeing. Verified against live production: meta-ads-mcp 122/122 titled, tiktok-ads-mcp 79/79, google-ads-mcp 63/63.
1 parent ffdb088 commit 02ac550

3 files changed

Lines changed: 108 additions & 2 deletions

File tree

‎cmd/mcp.go‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ 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("Title: %s\n", titleOrMissing(tool))
119120
fmt.Printf("Category: %s\n", toolCategoryLabel(tool.Annotations))
120121
if tool.Annotations != nil {
121122
annJSON, _ := json.MarshalIndent(tool.Annotations, "", " ")
@@ -133,16 +134,27 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
133134
}
134135
} else {
135136
for _, tool := range tools {
136-
fmt.Printf("%-7s %-40s %s\n",
137+
fmt.Printf("%-7s %-40s %-32s %s\n",
137138
"["+toolCategoryLabel(tool.Annotations)+"]",
138139
tool.Name,
139-
truncate(tool.Description, 80))
140+
truncate(titleOrMissing(tool), 32),
141+
truncate(tool.Description, 60))
140142
}
141143
}
142144

143145
return nil
144146
}
145147

148+
// titleOrMissing renders a tool's display title, or a loud placeholder when the
149+
// server sent none. A missing title is a blocking Claude Connectors submission
150+
// defect, so it gets called out rather than silently blank.
151+
func titleOrMissing(tool client.ToolDefinition) string {
152+
if t := tool.DisplayTitle(); t != "" {
153+
return t
154+
}
155+
return "(no title!)"
156+
}
157+
146158
// toolCategoryLabel returns a short tag describing a tool's behavior based on
147159
// its MCP annotations. Categories follow the MCP hint semantics; empty
148160
// annotations fall back to "?" because the spec defaults are pessimistic

‎cmd/mcp_test.go‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,73 @@ func TestEmitJSONError_InJSONMode(t *testing.T) {
276276
t.Errorf("error = %v, want EOF", got["error"])
277277
}
278278
}
279+
280+
func TestToolDefinition_TitleRoundTrip(t *testing.T) {
281+
// A tools/list response as Pipeboard servers actually send it: `title` is a
282+
// TOP-LEVEL field (MCP spec 2025-06-18), not `annotations.title`. Decoding
283+
// used to drop it, so `tools-list` showed every tool as untitled and the
284+
// on-disk cache persisted that — hence the round-trip assertion.
285+
raw := []byte(`{
286+
"name": "execute_google_ads_gaql_query",
287+
"title": "Execute Google Ads GAQL Query",
288+
"description": "Runs a GAQL query",
289+
"inputSchema": {"type":"object","properties":{}},
290+
"annotations": {"readOnlyHint": true}
291+
}`)
292+
293+
var td client.ToolDefinition
294+
if err := json.Unmarshal(raw, &td); err != nil {
295+
t.Fatalf("unmarshal: %v", err)
296+
}
297+
if td.Title != "Execute Google Ads GAQL Query" {
298+
t.Fatalf("Title = %q, want the server's top-level title", td.Title)
299+
}
300+
301+
out, err := json.Marshal(td)
302+
if err != nil {
303+
t.Fatalf("marshal: %v", err)
304+
}
305+
var back map[string]interface{}
306+
if err := json.Unmarshal(out, &back); err != nil {
307+
t.Fatalf("re-unmarshal: %v", err)
308+
}
309+
if back["title"] != "Execute Google Ads GAQL Query" {
310+
t.Errorf("re-marshaled title = %v, want it preserved for --json and the cache", back["title"])
311+
}
312+
}
313+
314+
func TestToolDisplayTitle(t *testing.T) {
315+
tests := []struct {
316+
name string
317+
td client.ToolDefinition
318+
want string
319+
}{
320+
{"top-level title", client.ToolDefinition{Title: "Get Campaigns"}, "Get Campaigns"},
321+
{"legacy annotations.title fallback", client.ToolDefinition{
322+
Annotations: &client.ToolAnnotations{Title: "Get Campaigns"},
323+
}, "Get Campaigns"},
324+
{"top-level wins over annotations", client.ToolDefinition{
325+
Title: "Get Campaigns",
326+
Annotations: &client.ToolAnnotations{Title: "Stale Legacy Title"},
327+
}, "Get Campaigns"},
328+
{"neither -> empty, never the machine name", client.ToolDefinition{Name: "get_campaigns"}, ""},
329+
{"nil annotations", client.ToolDefinition{Name: "get_campaigns", Annotations: nil}, ""},
330+
}
331+
for _, tt := range tests {
332+
t.Run(tt.name, func(t *testing.T) {
333+
if got := tt.td.DisplayTitle(); got != tt.want {
334+
t.Errorf("DisplayTitle() = %q, want %q", got, tt.want)
335+
}
336+
})
337+
}
338+
}
339+
340+
func TestTitleOrMissing(t *testing.T) {
341+
if got := titleOrMissing(client.ToolDefinition{Title: "Get Campaigns"}); got != "Get Campaigns" {
342+
t.Errorf("titleOrMissing = %q, want the title", got)
343+
}
344+
// An untitled tool is a blocking submission defect — say so, don't render blank.
345+
if got := titleOrMissing(client.ToolDefinition{Name: "get_campaigns"}); got != "(no title!)" {
346+
t.Errorf("titleOrMissing = %q, want the missing-title placeholder", got)
347+
}
348+
}

‎internal/client/mcp.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,37 @@ type ToolAnnotations struct {
7070
}
7171

7272
// ToolDefinition represents a tool from tools/list.
73+
//
74+
// Title is the tool's human-readable display name. Note there are TWO places a
75+
// title can live: this top-level field (MCP spec 2025-06-18 onward, what
76+
// Pipeboard servers populate) and the legacy `annotations.title`. Without the
77+
// top-level field here, every tool decoded as untitled — including in `--json`
78+
// output and the on-disk tools cache, both of which re-marshal this struct — so
79+
// `tools-list` reported 0/63 titled against a server that returns 63/63.
7380
type ToolDefinition struct {
7481
Name string `json:"name"`
82+
Title string `json:"title,omitempty"`
7583
Description string `json:"description"`
7684
InputSchema json.RawMessage `json:"inputSchema"`
7785
Annotations *ToolAnnotations `json:"annotations,omitempty"`
7886
}
7987

88+
// DisplayTitle returns the tool's human-readable title, preferring the
89+
// top-level field and falling back to the legacy `annotations.title`. Empty
90+
// when the server sends neither — which for a Pipeboard server means a real
91+
// compliance gap (a title is a blocking Claude Connectors submission
92+
// requirement), so callers should show its absence rather than paper over it
93+
// with the machine name.
94+
func (t ToolDefinition) DisplayTitle() string {
95+
if t.Title != "" {
96+
return t.Title
97+
}
98+
if t.Annotations != nil {
99+
return t.Annotations.Title
100+
}
101+
return ""
102+
}
103+
80104
// ToolsListResult is the result of tools/list.
81105
type ToolsListResult struct {
82106
Tools []ToolDefinition `json:"tools"`

0 commit comments

Comments
 (0)