Skip to content

fix(dynamic): send booleans the caller set to false instead of dropping them - #19

Merged
nictuku merged 1 commit into
mainfrom
nictuku/fix-bool-flags-false
Jul 30, 2026
Merged

nictuku merged 1 commit into
mainfrom
nictuku/fix-bool-flags-false

Conversation

@nictuku

@nictuku nictuku commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The bug

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 — so an explicit false was byte-identical to omitting the flag. false could not be transmitted at all, which means any tool parameter whose server-side default is true was permanently stuck on.

I hit this validating pipeboard.co#2242: pipeboard meta-ads delete-catalog-products --dry-run=false came back dry_run: true, and a deliberately wrong --expected-count produced a warning instead of a rejection — both dry-run tells. The tool could not be executed through a dynamic command at all.

Blast radius

15 tool/flag pairs across the cached Meta and Google schemas declare a true default. They failed in both directions:

Failure Tools
Fail-safe — operation impossible delete_catalog_products --dry-run, --readback
Fail-UNSAFE — caller asks for a draft, gets a live object publish_facebook_page_post --published=false, create_collection_ad --is-published=false, create_instant_experience --is-published=false
Cannot opt out of a network create_google_ads_campaign --target-youtube=false, --target-google-search=false

The unsafe row is the reason this is worth shipping promptly: someone creating a deliberately unpublished post got a live one, with no error.

The fix

Key off cmd.Flags().Changed() and forward the parsed value. An unset boolean is still omitted, so server-side defaults keep applying — only explicitly-typed flags are now sent.

Both flag registration and the change lookup go through a new propNameToFlagName, so the underscore→hyphen mapping can't drift between them (that mismatch would silently drop every boolean again).

Argument building is extracted into buildToolArgs and flag registration into registerToolFlags, so the behavior is unit-testable without network I/O. No behavior change for strings, numbers, arrays, or objects.

Note: a matching quirk remains for string flags — if *val == "" { continue } means an explicit empty string can't be sent either. Left alone deliberately: unlike booleans it has no "default is non-empty" failure mode, and changing it would alter behavior for scripts that pass a possibly-empty shell variable expecting omission.

Tests

TestBuildToolArgsBooleans (5 cases), TestBuildToolArgsHyphenatedBooleanFlag, TestBuildToolArgsTypeCoercion. They drive real cobra parsing through the real code path.

Verified they actually catch the bug: reverting just the fix hunk makes 3 of them fail —

--- FAIL: TestBuildToolArgsBooleans/explicit_false_is_sent,_not_dropped
    got map[catalog_id:123], want map[catalog_id:123 dry_run:false]
--- FAIL: TestBuildToolArgsHyphenatedBooleanFlag
    is_published = <nil> (present=false), want false

go build, go vet, gofmt, go test ./... all clean.

End-to-end verification

Against a local Next dev server + Python MCP backend, on a Pipeboard-owned ad account, using a disposable [PIPEBOARD TEST] catalog with one product:

Binary Invocation Result
0.8.0 --dry-run=false --expected-count 1 dry_run: true, deleted: 0 — bug reproduced
fixed (no --dry-run) dry_run: true, deleted: 0 — default preserved
fixed --dry-run=false --expected-count 7 hard rejection, nothing deleted
fixed --dry-run=false --expected-count 1 dry_run: false, deleted: 1, readback confirmed_absent: 1, still_present: []

Test catalog deleted afterwards; the account's own pre-existing catalog was only ever read and still hashes to the manifest token recorded before the run.

…ng them

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.
@nictuku
nictuku merged commit ffdb088 into main Jul 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant