Skip to content

Commit ffdb088

Browse files
authored
fix(dynamic): send booleans the caller set to false instead of dropping them (#19)
Dynamic commands only forwarded a boolean when it was true: for propName, val := range boolFlags { if val != nil && *val { toolArgs[propName] = true } } Cobra parses --flag=false and records it as Changed, but the value was then discarded, making an explicit false byte-identical to omitting the flag. False could not be transmitted at all, so any tool parameter whose server-side default is true was stuck on. 15 such tool/flag pairs exist across the Meta and Google caches, and they failed in both directions: - delete_catalog_products --dry-run=false stayed in dry run, so the tool could never execute a deletion through a dynamic command. - publish_facebook_page_post --published=false, create_collection_ad and create_instant_experience --is-published=false silently produced a LIVE published object for a caller who asked for a draft. - create_google_ads_campaign --target-youtube=false could not opt out of a network. Key off cmd.Flags().Changed() and forward the parsed value, so an unset boolean is still omitted and the server default applies. Both the flag registration and the change lookup now go through propNameToFlagName, so the underscore-to-hyphen mapping cannot drift between them. Argument building is extracted into buildToolArgs and flag registration into registerToolFlags so the behaviour is unit-testable without network I/O; the new tests fail against the previous implementation.
1 parent 71f34a9 commit ffdb088

2 files changed

Lines changed: 194 additions & 22 deletions

File tree

‎cmd/dynamic.go‎

Lines changed: 56 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ func (p PropertySchema) resolveType() string {
140140
return "string"
141141
}
142142

143+
// propNameToFlagName maps a JSON Schema property name to its CLI flag name.
144+
// Both flag registration and argument building go through this, so the two can
145+
// never disagree about what a property's flag is called.
146+
func propNameToFlagName(propName string) string {
147+
return strings.ReplaceAll(propName, "_", "-")
148+
}
149+
143150
func buildToolCommand(server ServerConfig, tool client.ToolDefinition) *cobra.Command {
144151
cmdName := toolNameToCommandName(tool.Name, server.ToolPrefix)
145152

@@ -148,6 +155,24 @@ func buildToolCommand(server ServerConfig, tool client.ToolDefinition) *cobra.Co
148155
_ = json.Unmarshal(tool.InputSchema, &schema)
149156
}
150157

158+
cmd := &cobra.Command{
159+
Use: cmdName,
160+
Short: truncate(tool.Description, 80),
161+
Long: tool.Description,
162+
}
163+
164+
stringFlags, boolFlags := registerToolFlags(cmd, schema)
165+
166+
cmd.RunE = func(cmd *cobra.Command, args []string) error {
167+
return runDynamicTool(cmd, server, tool.Name, schema, stringFlags, boolFlags)
168+
}
169+
170+
return cmd
171+
}
172+
173+
// registerToolFlags declares one flag per JSON Schema property and returns the
174+
// storage those flags parse into, keyed by property name.
175+
func registerToolFlags(cmd *cobra.Command, schema InputSchema) (map[string]*string, map[string]*bool) {
151176
requiredSet := make(map[string]bool)
152177
for _, r := range schema.Required {
153178
requiredSet[r] = true
@@ -157,17 +182,8 @@ func buildToolCommand(server ServerConfig, tool client.ToolDefinition) *cobra.Co
157182
stringFlags := make(map[string]*string)
158183
boolFlags := make(map[string]*bool)
159184

160-
cmd := &cobra.Command{
161-
Use: cmdName,
162-
Short: truncate(tool.Description, 80),
163-
Long: tool.Description,
164-
RunE: func(cmd *cobra.Command, args []string) error {
165-
return runDynamicTool(server, tool.Name, schema, stringFlags, boolFlags)
166-
},
167-
}
168-
169185
for propName, prop := range schema.Properties {
170-
flagName := strings.ReplaceAll(propName, "_", "-")
186+
flagName := propNameToFlagName(propName)
171187
desc := prop.Description
172188
if len(prop.Enum) > 0 {
173189
desc += " (options: " + strings.Join(prop.Enum, ", ") + ")"
@@ -186,10 +202,10 @@ func buildToolCommand(server ServerConfig, tool client.ToolDefinition) *cobra.Co
186202
}
187203
}
188204

189-
return cmd
205+
return stringFlags, boolFlags
190206
}
191207

192-
func runDynamicTool(server ServerConfig, toolName string, schema InputSchema, stringFlags map[string]*string, boolFlags map[string]*bool) error {
208+
func runDynamicTool(cmd *cobra.Command, server ServerConfig, toolName string, schema InputSchema, stringFlags map[string]*string, boolFlags map[string]*bool) error {
193209
token, err := getToken()
194210
if err != nil {
195211
return err
@@ -202,7 +218,20 @@ func runDynamicTool(server ServerConfig, toolName string, schema InputSchema, st
202218
return fmt.Errorf("handshake failed: %w", err)
203219
}
204220

205-
// Build arguments from flag values, coercing types based on schema
221+
toolArgs := buildToolArgs(cmd, schema, stringFlags, boolFlags)
222+
223+
fmt.Fprintf(os.Stderr, "Calling %s...\n", toolName)
224+
result, err := c.CallTool(server.Path, toolName, toolArgs)
225+
if err != nil {
226+
return fmt.Errorf("tool call failed: %w", err)
227+
}
228+
229+
return printToolResult(result)
230+
}
231+
232+
// buildToolArgs turns parsed flag values into the tools/call arguments object,
233+
// coercing each one to the type its JSON Schema declares.
234+
func buildToolArgs(cmd *cobra.Command, schema InputSchema, stringFlags map[string]*string, boolFlags map[string]*bool) map[string]interface{} {
206235
toolArgs := make(map[string]interface{})
207236

208237
for propName, val := range stringFlags {
@@ -235,19 +264,24 @@ func runDynamicTool(server ServerConfig, toolName string, schema InputSchema, st
235264
}
236265
}
237266

267+
// Send a boolean whenever the caller actually typed the flag, including
268+
// `--flag=false`. Forwarding only `true` made an explicit false
269+
// indistinguishable from omitting the flag, so any tool param whose
270+
// server-side default is true could never be turned off — e.g.
271+
// `delete_catalog_products --dry-run=false` stayed in dry run and could
272+
// never execute, and `publish_facebook_page_post --published=false`
273+
// silently published a post the caller asked to keep as a draft.
238274
for propName, val := range boolFlags {
239-
if val != nil && *val {
240-
toolArgs[propName] = true
275+
if val == nil {
276+
continue
241277
}
278+
if cmd != nil && !cmd.Flags().Changed(propNameToFlagName(propName)) {
279+
continue
280+
}
281+
toolArgs[propName] = *val
242282
}
243283

244-
fmt.Fprintf(os.Stderr, "Calling %s...\n", toolName)
245-
result, err := c.CallTool(server.Path, toolName, toolArgs)
246-
if err != nil {
247-
return fmt.Errorf("tool call failed: %w", err)
248-
}
249-
250-
return printToolResult(result)
284+
return toolArgs
251285
}
252286

253287
// printToolResult extracts text content from the MCP tools/call response and prints it.

‎cmd/dynamic_test.go‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"testing"
66

77
"github.com/pipeboard-co/pipeboard-cli/internal/client"
8+
"github.com/spf13/cobra"
89
)
910

1011
func TestToolNameToCommandName(t *testing.T) {
@@ -170,3 +171,140 @@ func containsSubstr(s, sub string) bool {
170171
}
171172
return false
172173
}
174+
175+
// parseToolArgs registers the schema's flags on a throwaway command, parses
176+
// argv through the same cobra machinery the real command uses, and returns the
177+
// arguments object that would be sent to tools/call.
178+
func parseToolArgs(t *testing.T, rawSchema string, argv ...string) map[string]interface{} {
179+
t.Helper()
180+
181+
var schema InputSchema
182+
if err := json.Unmarshal([]byte(rawSchema), &schema); err != nil {
183+
t.Fatalf("unmarshal schema: %v", err)
184+
}
185+
186+
cmd := &cobra.Command{Use: "test"}
187+
stringFlags, boolFlags := registerToolFlags(cmd, schema)
188+
189+
if err := cmd.Flags().Parse(argv); err != nil {
190+
t.Fatalf("parse %v: %v", argv, err)
191+
}
192+
193+
return buildToolArgs(cmd, schema, stringFlags, boolFlags)
194+
}
195+
196+
// A boolean the caller explicitly set to false must reach the server as false.
197+
// Sending only `true` made `--flag=false` indistinguishable from omitting the
198+
// flag, so any tool param whose server-side default is true could never be
199+
// turned off — delete_catalog_products could never leave dry run, and
200+
// publish_facebook_page_post --published=false published a post regardless.
201+
func TestBuildToolArgsBooleans(t *testing.T) {
202+
schema := `{
203+
"type": "object",
204+
"properties": {
205+
"catalog_id": {"type": "string"},
206+
"dry_run": {"type": "boolean"},
207+
"readback": {"type": "boolean"}
208+
}
209+
}`
210+
211+
tests := []struct {
212+
name string
213+
argv []string
214+
want map[string]interface{}
215+
}{
216+
{
217+
name: "explicit false is sent, not dropped",
218+
argv: []string{"--catalog-id", "123", "--dry-run=false"},
219+
want: map[string]interface{}{"catalog_id": "123", "dry_run": false},
220+
},
221+
{
222+
name: "explicit true is sent",
223+
argv: []string{"--dry-run=true"},
224+
want: map[string]interface{}{"dry_run": true},
225+
},
226+
{
227+
name: "bare flag means true",
228+
argv: []string{"--dry-run"},
229+
want: map[string]interface{}{"dry_run": true},
230+
},
231+
{
232+
name: "unset booleans are omitted so the server default applies",
233+
argv: []string{"--catalog-id", "123"},
234+
want: map[string]interface{}{"catalog_id": "123"},
235+
},
236+
{
237+
name: "each boolean is tracked independently",
238+
argv: []string{"--dry-run=false"},
239+
want: map[string]interface{}{"dry_run": false},
240+
},
241+
}
242+
243+
for _, tc := range tests {
244+
t.Run(tc.name, func(t *testing.T) {
245+
got := parseToolArgs(t, schema, tc.argv...)
246+
if len(got) != len(tc.want) {
247+
t.Fatalf("got %v, want %v", got, tc.want)
248+
}
249+
for k, want := range tc.want {
250+
if got[k] != want {
251+
t.Errorf("arg %q = %v (%T), want %v (%T)", k, got[k], got[k], want, want)
252+
}
253+
}
254+
})
255+
}
256+
}
257+
258+
// Underscored properties are exposed as hyphenated flags; the change-tracking
259+
// lookup must use the same mapping or every boolean silently drops out again.
260+
func TestBuildToolArgsHyphenatedBooleanFlag(t *testing.T) {
261+
schema := `{
262+
"type": "object",
263+
"properties": {"is_published": {"type": "boolean"}}
264+
}`
265+
266+
got := parseToolArgs(t, schema, "--is-published=false")
267+
if v, ok := got["is_published"]; !ok || v != false {
268+
t.Errorf("is_published = %v (present=%v), want false", v, ok)
269+
}
270+
}
271+
272+
// Non-boolean flags keep their schema-declared types.
273+
func TestBuildToolArgsTypeCoercion(t *testing.T) {
274+
schema := `{
275+
"type": "object",
276+
"properties": {
277+
"name": {"type": "string"},
278+
"limit": {"type": "integer"},
279+
"ratio": {"type": "number"},
280+
"product_ids": {"type": "array"},
281+
"filter": {"type": "object"}
282+
}
283+
}`
284+
285+
got := parseToolArgs(t, schema,
286+
"--name", "hello",
287+
"--limit", "42",
288+
"--ratio", "1.5",
289+
"--product-ids", `["a","b"]`,
290+
"--filter", `{"k":"v"}`,
291+
)
292+
293+
if got["name"] != "hello" {
294+
t.Errorf("name = %v, want hello", got["name"])
295+
}
296+
if got["limit"] != 42 {
297+
t.Errorf("limit = %v (%T), want 42 (int)", got["limit"], got["limit"])
298+
}
299+
if got["ratio"] != 1.5 {
300+
t.Errorf("ratio = %v (%T), want 1.5 (float64)", got["ratio"], got["ratio"])
301+
}
302+
ids, ok := got["product_ids"].([]interface{})
303+
if !ok || len(ids) != 2 || ids[0] != "a" {
304+
t.Errorf("product_ids = %v (%T), want [a b]", got["product_ids"], got["product_ids"])
305+
}
306+
filter, ok := got["filter"].(map[string]interface{})
307+
if !ok || filter["k"] != "v" {
308+
t.Errorf("filter = %v (%T), want map[k:v]", got["filter"], got["filter"])
309+
}
310+
}

0 commit comments

Comments
 (0)