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
16 changes: 14 additions & 2 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
if mcpToolsListVerbose {
for _, tool := range tools {
fmt.Printf("=== %s ===\n", tool.Name)
fmt.Printf("Title: %s\n", titleOrMissing(tool))
fmt.Printf("Category: %s\n", toolCategoryLabel(tool.Annotations))
if tool.Annotations != nil {
annJSON, _ := json.MarshalIndent(tool.Annotations, "", " ")
Expand All @@ -133,16 +134,27 @@ func runMCPToolsList(cmd *cobra.Command, args []string) error {
}
} else {
for _, tool := range tools {
fmt.Printf("%-7s %-40s %s\n",
fmt.Printf("%-7s %-40s %-32s %s\n",
"["+toolCategoryLabel(tool.Annotations)+"]",
tool.Name,
truncate(tool.Description, 80))
truncate(titleOrMissing(tool), 32),
truncate(tool.Description, 60))
}
}

return nil
}

// titleOrMissing renders a tool's display title, or a loud placeholder when the
// server sent none. A missing title is a blocking Claude Connectors submission
// defect, so it gets called out rather than silently blank.
func titleOrMissing(tool client.ToolDefinition) string {
if t := tool.DisplayTitle(); t != "" {
return t
}
return "(no title!)"
}

// 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
Expand Down
70 changes: 70 additions & 0 deletions cmd/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,73 @@ func TestEmitJSONError_InJSONMode(t *testing.T) {
t.Errorf("error = %v, want EOF", got["error"])
}
}

func TestToolDefinition_TitleRoundTrip(t *testing.T) {
// A tools/list response as Pipeboard servers actually send it: `title` is a
// TOP-LEVEL field (MCP spec 2025-06-18), not `annotations.title`. Decoding
// used to drop it, so `tools-list` showed every tool as untitled and the
// on-disk cache persisted that — hence the round-trip assertion.
raw := []byte(`{
"name": "execute_google_ads_gaql_query",
"title": "Execute Google Ads GAQL Query",
"description": "Runs a GAQL query",
"inputSchema": {"type":"object","properties":{}},
"annotations": {"readOnlyHint": true}
}`)

var td client.ToolDefinition
if err := json.Unmarshal(raw, &td); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if td.Title != "Execute Google Ads GAQL Query" {
t.Fatalf("Title = %q, want the server's top-level title", td.Title)
}

out, err := json.Marshal(td)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var back map[string]interface{}
if err := json.Unmarshal(out, &back); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
if back["title"] != "Execute Google Ads GAQL Query" {
t.Errorf("re-marshaled title = %v, want it preserved for --json and the cache", back["title"])
}
}

func TestToolDisplayTitle(t *testing.T) {
tests := []struct {
name string
td client.ToolDefinition
want string
}{
{"top-level title", client.ToolDefinition{Title: "Get Campaigns"}, "Get Campaigns"},
{"legacy annotations.title fallback", client.ToolDefinition{
Annotations: &client.ToolAnnotations{Title: "Get Campaigns"},
}, "Get Campaigns"},
{"top-level wins over annotations", client.ToolDefinition{
Title: "Get Campaigns",
Annotations: &client.ToolAnnotations{Title: "Stale Legacy Title"},
}, "Get Campaigns"},
{"neither -> empty, never the machine name", client.ToolDefinition{Name: "get_campaigns"}, ""},
{"nil annotations", client.ToolDefinition{Name: "get_campaigns", Annotations: nil}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.td.DisplayTitle(); got != tt.want {
t.Errorf("DisplayTitle() = %q, want %q", got, tt.want)
}
})
}
}

func TestTitleOrMissing(t *testing.T) {
if got := titleOrMissing(client.ToolDefinition{Title: "Get Campaigns"}); got != "Get Campaigns" {
t.Errorf("titleOrMissing = %q, want the title", got)
}
// An untitled tool is a blocking submission defect — say so, don't render blank.
if got := titleOrMissing(client.ToolDefinition{Name: "get_campaigns"}); got != "(no title!)" {
t.Errorf("titleOrMissing = %q, want the missing-title placeholder", got)
}
}
24 changes: 24 additions & 0 deletions internal/client/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,37 @@ type ToolAnnotations struct {
}

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

// DisplayTitle returns the tool's human-readable title, preferring the
// top-level field and falling back to the legacy `annotations.title`. Empty
// when the server sends neither — which for a Pipeboard server means a real
// compliance gap (a title is a blocking Claude Connectors submission
// requirement), so callers should show its absence rather than paper over it
// with the machine name.
func (t ToolDefinition) DisplayTitle() string {
if t.Title != "" {
return t.Title
}
if t.Annotations != nil {
return t.Annotations.Title
}
return ""
}

// ToolsListResult is the result of tools/list.
type ToolsListResult struct {
Tools []ToolDefinition `json:"tools"`
Expand Down
Loading