[object Object]

← back to Cli Printing Press

fix(cli): emit --body-json fallback for oneOf/anyOf request bodies (#994)

910c228d48e4de0bdc21e6ba4b5f39e827ef2f5a · 2026-05-11 07:48:10 -0700 · Trevin Chow

* fix(cli): emit --body-json fallback for oneOf/anyOf request bodies

Endpoints whose request body schema is a oneOf/anyOf (Cloudflare DNS
records, Stripe PaymentMethod, Notion blocks, Linear filters, etc.)
previously parsed with a "skipping request body" warning and shipped
with no body parameter, so the generated `create` / `update` commands
sent an empty body and the API rejected the call.

The parser now flips a new `Endpoint.BodyJSONFallback` flag for these
endpoints. The command templates respond by emitting a single
`--body-json string` flag that accepts the JSON object verbatim, and
the MCP surface exposes a matching `body_json` input so agents reach
the same endpoint class.

Acceptance: regenerating a CLI from a spec with a oneOf body now shows
`--body-json` in `<resource> create --help`, and `--body-json '<obj>'
--dry-run` round-trips the payload through the request preview.

Refs #977

* fix(cli): tighten --body-json fallback (required, JSON-only, parity)

Address review feedback on PR #994:

- Thread `requestBody.required` through `mapRequestBody` into a new
  `Endpoint.BodyRequired` field. When set, the CLI emits a
  `Changed("body-json")` pre-flight check and the MCP tool marks
  `body_json` as `mcplib.Required()`. Closes the regression where a
  required oneOf body could ship empty.
- Restrict the fallback to JSON-shaped content types
  (`application/json`, `*/*+json`, `text/json`). Multipart and form
  bodies with a root-level oneOf revert to the prior "skip body"
  behavior because their template branch is not wired to read
  `flagBodyJSON`. New `isJSONContentType` helper.
- Enforce JSON-object shape symmetrically on the MCP `body_json` input:
  the handler now unmarshals, type-asserts to `map[string]any`, and
  returns a typed error (`got JSON []interface {}`) instead of
  silently forwarding arrays.
- Improve the CLI error message to include the parsed JSON kind so a
  user who passes `["..."]` sees `got JSON []interface {}` rather than
  a bare "must be a JSON object".
- Reword the `--body-json` help to lead with the user-facing intent
  ("provide the full request body as a JSON object string") and
  retain the `oneOf/anyOf` hint for spec-aware readers.
- Sharpen the `BodyJSONFallback` field doc to state the parser
  invariant (Body is empty); drop the test note claiming defensive
  behavior the parser never produces.

Adds parser tests for `BodyRequired` threading, vendor-JSON content
type preservation, and the multipart-skip guard. Adds a generator
test for the BodyRequired Changed-check path.

Refs #977

Files touched

Diff

commit 910c228d48e4de0bdc21e6ba4b5f39e827ef2f5a
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon May 11 07:48:10 2026 -0700

    fix(cli): emit --body-json fallback for oneOf/anyOf request bodies (#994)
    
    * fix(cli): emit --body-json fallback for oneOf/anyOf request bodies
    
    Endpoints whose request body schema is a oneOf/anyOf (Cloudflare DNS
    records, Stripe PaymentMethod, Notion blocks, Linear filters, etc.)
    previously parsed with a "skipping request body" warning and shipped
    with no body parameter, so the generated `create` / `update` commands
    sent an empty body and the API rejected the call.
    
    The parser now flips a new `Endpoint.BodyJSONFallback` flag for these
    endpoints. The command templates respond by emitting a single
    `--body-json string` flag that accepts the JSON object verbatim, and
    the MCP surface exposes a matching `body_json` input so agents reach
    the same endpoint class.
    
    Acceptance: regenerating a CLI from a spec with a oneOf body now shows
    `--body-json` in `<resource> create --help`, and `--body-json '<obj>'
    --dry-run` round-trips the payload through the request preview.
    
    Refs #977
    
    * fix(cli): tighten --body-json fallback (required, JSON-only, parity)
    
    Address review feedback on PR #994:
    
    - Thread `requestBody.required` through `mapRequestBody` into a new
      `Endpoint.BodyRequired` field. When set, the CLI emits a
      `Changed("body-json")` pre-flight check and the MCP tool marks
      `body_json` as `mcplib.Required()`. Closes the regression where a
      required oneOf body could ship empty.
    - Restrict the fallback to JSON-shaped content types
      (`application/json`, `*/*+json`, `text/json`). Multipart and form
      bodies with a root-level oneOf revert to the prior "skip body"
      behavior because their template branch is not wired to read
      `flagBodyJSON`. New `isJSONContentType` helper.
    - Enforce JSON-object shape symmetrically on the MCP `body_json` input:
      the handler now unmarshals, type-asserts to `map[string]any`, and
      returns a typed error (`got JSON []interface {}`) instead of
      silently forwarding arrays.
    - Improve the CLI error message to include the parsed JSON kind so a
      user who passes `["..."]` sees `got JSON []interface {}` rather than
      a bare "must be a JSON object".
    - Reword the `--body-json` help to lead with the user-facing intent
      ("provide the full request body as a JSON object string") and
      retain the `oneOf/anyOf` hint for spec-aware readers.
    - Sharpen the `BodyJSONFallback` field doc to state the parser
      invariant (Body is empty); drop the test note claiming defensive
      behavior the parser never produces.
    
    Adds parser tests for `BodyRequired` threading, vendor-JSON content
    type preservation, and the multipart-skip guard. Adds a generator
    test for the BodyRequired Changed-check path.
    
    Refs #977
---
 internal/generator/body_map_test.go                | 118 +++++++++++++++
 internal/generator/generator.go                    |  77 ++++++++++
 .../generator/templates/command_endpoint.go.tmpl   |   6 +-
 .../generator/templates/command_promoted.go.tmpl   |   2 +-
 internal/generator/templates/mcp_tools.go.tmpl     |  44 ++++++
 internal/openapi/parser.go                         |  46 +++++-
 internal/openapi/parser_test.go                    | 167 +++++++++++++++++++++
 internal/spec/spec.go                              |  24 ++-
 8 files changed, 466 insertions(+), 18 deletions(-)

diff --git a/internal/generator/body_map_test.go b/internal/generator/body_map_test.go
index 6e1f0b05..97ff6507 100644
--- a/internal/generator/body_map_test.go
+++ b/internal/generator/body_map_test.go
@@ -442,3 +442,121 @@ func TestBodyRequiredChecks_TopLevelKeepsAliasOR(t *testing.T) {
 		t.Errorf("expected alias-OR in required check, got:\n%s", got)
 	}
 }
+
+// TestBodyJSONFallback_VarDecls emits a single flagBodyJSON string and
+// suppresses per-field var declarations when the endpoint opts into the
+// oneOf/anyOf fallback.
+func TestBodyJSONFallback_VarDecls(t *testing.T) {
+	t.Parallel()
+	got := bodyVarDecls(spec.Endpoint{BodyJSONFallback: true})
+	want := "\n\tvar flagBodyJSON string"
+	if got != want {
+		t.Errorf("bodyVarDecls fallback mismatch.\n got:%q\nwant:%q", got, want)
+	}
+}
+
+// TestBodyJSONFallback_FlagRegs registers a single --body-json flag with
+// user-facing help that also names oneOf/anyOf for spec-aware readers.
+func TestBodyJSONFallback_FlagRegs(t *testing.T) {
+	t.Parallel()
+	got := bodyFlagRegs(spec.Endpoint{BodyJSONFallback: true})
+	if !strings.Contains(got, `cmd.Flags().StringVar(&flagBodyJSON, "body-json"`) {
+		t.Errorf("expected --body-json flag registration, got:\n%s", got)
+	}
+	if !strings.Contains(got, "polymorphic schema") {
+		t.Errorf("expected user-facing help text, got:\n%s", got)
+	}
+	if !strings.Contains(got, "oneOf/anyOf") {
+		t.Errorf("expected spec-aware hint mentioning oneOf/anyOf, got:\n%s", got)
+	}
+}
+
+// TestBodyJSONFallback_RequiredChecks emits no required-flag check
+// because the parser cannot tell whether the request body is mandatory
+// for an opaque schema. An empty body either succeeds or surfaces a
+// clear 400 from the API.
+func TestBodyJSONFallback_RequiredChecks(t *testing.T) {
+	t.Parallel()
+	got := bodyRequiredChecks(spec.Endpoint{BodyJSONFallback: true}, "\t\t\t")
+	if got != "" {
+		t.Errorf("bodyRequiredChecks should emit nothing for BodyJSONFallback, got:%q", got)
+	}
+}
+
+// TestBodyJSONFallback_BodyMap dispatches bodyMapForEndpoint to the
+// JSON-fallback renderer when the endpoint has BodyJSONFallback set.
+// The block must parse the flag, reject non-object payloads, and
+// overwrite the empty body map prepared by the caller.
+func TestBodyJSONFallback_BodyMap(t *testing.T) {
+	t.Parallel()
+	got := bodyMapForEndpoint(spec.Endpoint{BodyJSONFallback: true}, "\t")
+	wantSubstrings := []string{
+		`if flagBodyJSON != ""`,
+		`var parsedBodyJSON any`,
+		`json.Unmarshal([]byte(flagBodyJSON), &parsedBodyJSON)`,
+		`asMap, ok := parsedBodyJSON.(map[string]any)`,
+		`body = asMap`,
+		`--body-json must be a JSON object, got JSON %T`,
+	}
+	for _, s := range wantSubstrings {
+		if !strings.Contains(got, s) {
+			t.Errorf("body-json fallback output missing %q, got:\n%s", s, got)
+		}
+	}
+}
+
+// TestBodyJSONFallback_BodyMap_TypedPath confirms bodyMapForEndpoint
+// falls through to the typed renderer when BodyJSONFallback is false,
+// preserving existing CLIs' generated output.
+func TestBodyJSONFallback_BodyMap_TypedPath(t *testing.T) {
+	t.Parallel()
+	endpoint := spec.Endpoint{Body: []spec.Param{{Name: "name", Type: "string"}}}
+	got := bodyMapForEndpoint(endpoint, "\t")
+	if strings.Contains(got, "flagBodyJSON") {
+		t.Errorf("typed-body path must not emit flagBodyJSON branch, got:\n%s", got)
+	}
+	if !strings.Contains(got, `body["name"] = bodyName`) {
+		t.Errorf("expected typed body-map output for name field, got:\n%s", got)
+	}
+}
+
+// TestMCPParamBindings_BodyJSONFallback inserts a single body_json
+// binding with Location="body_json", mirroring the CLI surface. Parser
+// invariant: Body is empty when BodyJSONFallback is set.
+func TestMCPParamBindings_BodyJSONFallback(t *testing.T) {
+	t.Parallel()
+	endpoint := spec.Endpoint{
+		BodyJSONFallback: true,
+		Params:           []spec.Param{{Name: "zoneId", Type: "string"}},
+	}
+	bindings := mcpParamBindings(endpoint, "/zones/{zoneId}/records")
+
+	var foundBodyJSON, foundTypedBody bool
+	for _, b := range bindings {
+		if b.Location == "body_json" && b.PublicName == "body_json" {
+			foundBodyJSON = true
+		}
+		if b.Location == "body" {
+			foundTypedBody = true
+		}
+	}
+	if !foundBodyJSON {
+		t.Errorf("expected a body_json binding, got: %+v", bindings)
+	}
+	if foundTypedBody {
+		t.Errorf("BodyJSONFallback should suppress per-field body bindings, got: %+v", bindings)
+	}
+}
+
+// TestBodyJSONFallback_RequiredChecks_RequiredBody emits a Changed check
+// on --body-json when the OpenAPI requestBody.required flag was true.
+func TestBodyJSONFallback_RequiredChecks_RequiredBody(t *testing.T) {
+	t.Parallel()
+	got := bodyRequiredChecks(spec.Endpoint{BodyJSONFallback: true, BodyRequired: true}, "\t\t\t")
+	if !strings.Contains(got, `cmd.Flags().Changed("body-json")`) {
+		t.Errorf("expected Changed check on body-json for required body, got:%q", got)
+	}
+	if !strings.Contains(got, `"required flag \"%s\" not set", "body-json"`) {
+		t.Errorf("expected body-json in error message, got:%q", got)
+	}
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 947ffccb..342b2b37 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -308,6 +308,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"jsonStringParam":       isJSONStringParam,
 		"jsonEnumSuggestion":    jsonEnumSuggestion,
 		"bodyMap":               bodyMap,
+		"bodyMapForEndpoint":    bodyMapForEndpoint,
 		"bodyVarDecls":          bodyVarDecls,
 		"bodyFlagRegs":          bodyFlagRegs,
 		"bodyRequiredChecks":    bodyRequiredChecks,
@@ -317,6 +318,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"formBodyMaps":          formBodyMaps,
 		"endpointUsesForm":      endpointUsesForm,
 		"hasFormRequest":        hasFormRequest,
+		"hasBodyJSONFallback":   hasBodyJSONFallback,
 		"publicFlagName":        publicFlagName,
 		"publicFlagAliases":     publicFlagAliases,
 		"flagChangedExpr":       flagChangedExpr,
@@ -3077,6 +3079,17 @@ func mcpParamBindings(endpoint spec.Endpoint, pathTemplate string) []mcpParamBin
 			RequestContentType: requestContentType,
 		})
 	}
+	if endpoint.BodyJSONFallback {
+		// Single opaque body-json input; the handler parses it as JSON and
+		// sends it verbatim, mirroring the CLI's --body-json fallback for
+		// oneOf/anyOf request bodies. Body is empty by parser invariant.
+		bindings = append(bindings, mcpParamBinding{
+			PublicName: "body_json",
+			WireName:   "body_json",
+			Location:   "body_json",
+		})
+		return bindings
+	}
 	for _, p := range endpoint.Body {
 		bindings = append(bindings, mcpParamBinding{
 			PublicName:         p.PublicInputName(),
@@ -3153,6 +3166,43 @@ func bodyMap(body []spec.Param, indent string) string {
 	return b.String()
 }
 
+// bodyMapForEndpoint dispatches between the typed-flag body-map renderer
+// and the --body-json fallback renderer. Templates call this in place of
+// bodyMap so the BodyJSONFallback decision lives in one place.
+func bodyMapForEndpoint(endpoint spec.Endpoint, indent string) string {
+	if endpoint.BodyJSONFallback {
+		return bodyJSONFallbackMap(indent)
+	}
+	return bodyMap(endpoint.Body, indent)
+}
+
+// bodyJSONFallbackMap renders the body-population block used when an
+// endpoint's request body schema is a oneOf/anyOf (or otherwise opaque)
+// and we expose a single `--body-json` string flag. The caller has
+// already emitted `body = map[string]any{}`; this block conditionally
+// overwrites body with a parsed JSON object when the user passed a value.
+//
+// The fallback intentionally accepts only JSON objects. Top-level
+// discriminated unions in real-world specs (Cloudflare DNS records,
+// Stripe PaymentMethod, Notion blocks, Linear filters) are object-shaped;
+// rare array-typed bodies are out of scope for the minimum-viable
+// fallback and surface as a clear error message.
+func bodyJSONFallbackMap(indent string) string {
+	var b strings.Builder
+	fmt.Fprintf(&b, "%sif flagBodyJSON != \"\" {\n", indent)
+	fmt.Fprintf(&b, "%s\tvar parsedBodyJSON any\n", indent)
+	fmt.Fprintf(&b, "%s\tif err := json.Unmarshal([]byte(flagBodyJSON), &parsedBodyJSON); err != nil {\n", indent)
+	fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --body-json: %%w\", err)\n", indent)
+	fmt.Fprintf(&b, "%s\t}\n", indent)
+	fmt.Fprintf(&b, "%s\tasMap, ok := parsedBodyJSON.(map[string]any)\n", indent)
+	fmt.Fprintf(&b, "%s\tif !ok {\n", indent)
+	fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"--body-json must be a JSON object, got JSON %%T\", parsedBodyJSON)\n", indent)
+	fmt.Fprintf(&b, "%s\t}\n", indent)
+	fmt.Fprintf(&b, "%s\tbody = asMap\n", indent)
+	fmt.Fprintf(&b, "%s}\n", indent)
+	return b.String()
+}
+
 func renderBodyMap(b *strings.Builder, body []spec.Param, indent, mapVar, identPrefix, flagPrefix string) {
 	for _, p := range body {
 		id := paramIdent(p)
@@ -3202,8 +3252,15 @@ func renderBodyMap(b *strings.Builder, body []spec.Param, indent, mapVar, identP
 // parents as JSON-string fields. Output starts with "\n\tvar ..."
 // matching the one-tab indent of the original `{{- range .Endpoint.Body}}`
 // template loop.
+//
+// When endpoint.BodyJSONFallback is set (oneOf/anyOf body schema), a single
+// `flagBodyJSON` string is declared instead of per-field flags.
 func bodyVarDecls(endpoint spec.Endpoint) string {
 	var b strings.Builder
+	if endpoint.BodyJSONFallback {
+		b.WriteString("\n\tvar flagBodyJSON string")
+		return b.String()
+	}
 	if bodyUsesFlatEmission(endpoint) {
 		for _, p := range endpoint.Body {
 			fmt.Fprintf(&b, "\n\tvar body%s %s", toCamel(paramIdent(p)), goType(p.Type))
@@ -3242,6 +3299,10 @@ func renderBodyVarDecls(b *strings.Builder, body []spec.Param, identPrefix strin
 // not collide. Aliases are emitted only at the top level.
 func bodyFlagRegs(endpoint spec.Endpoint) string {
 	var b strings.Builder
+	if endpoint.BodyJSONFallback {
+		b.WriteString("\n\tcmd.Flags().StringVar(&flagBodyJSON, \"body-json\", \"\", \"Provide the full request body as a JSON object string (this endpoint accepts a polymorphic schema: oneOf/anyOf)\")")
+		return b.String()
+	}
 	if bodyUsesFlatEmission(endpoint) {
 		for _, p := range endpoint.Body {
 			renderFlatBodyFlagReg(&b, p, "", "", true)
@@ -3289,6 +3350,14 @@ func renderFlatBodyFlagReg(b *strings.Builder, p spec.Param, identPrefix, flagPr
 // parent-prefixed flag because aliases are not propagated to children.
 func bodyRequiredChecks(endpoint spec.Endpoint, indent string) string {
 	var b strings.Builder
+	if endpoint.BodyJSONFallback {
+		if endpoint.BodyRequired {
+			fmt.Fprintf(&b, "\n%sif !cmd.Flags().Changed(\"body-json\") && !flags.dryRun {", indent)
+			fmt.Fprintf(&b, "\n%s\treturn fmt.Errorf(\"required flag \\\"%%s\\\" not set\", \"body-json\")", indent)
+			fmt.Fprintf(&b, "\n%s}", indent)
+		}
+		return b.String()
+	}
 	if bodyUsesFlatEmission(endpoint) {
 		for _, p := range endpoint.Body {
 			renderFlatBodyRequiredCheck(&b, p, indent, "", true)
@@ -3383,6 +3452,14 @@ func hasFormRequest(apiSpec *spec.APISpec) bool {
 	return anyEndpointMatches(apiSpec, endpointUsesForm)
 }
 
+func endpointUsesBodyJSONFallback(endpoint spec.Endpoint) bool {
+	return endpoint.BodyJSONFallback
+}
+
+func hasBodyJSONFallback(apiSpec *spec.APISpec) bool {
+	return anyEndpointMatches(apiSpec, endpointUsesBodyJSONFallback)
+}
+
 func anyEndpointMatches(apiSpec *spec.APISpec, predicate func(spec.Endpoint) bool) bool {
 	if apiSpec == nil {
 		return false
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 20e9d52c..75c0b94c 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -284,7 +284,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				body = jsonBody
 			} else {
 				body = map[string]any{}
-{{bodyMap .Endpoint.Body "\t\t\t\t"}}			}
+{{bodyMapForEndpoint .Endpoint "\t\t\t\t"}}			}
 {{- if .Endpoint.HeaderOverrides}}
 			data, statusCode, err := c.PostWithHeaders(path, body, headerOverrides)
 {{- else}}
@@ -329,7 +329,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				body = jsonBody
 			} else {
 				body = map[string]any{}
-{{bodyMap .Endpoint.Body "\t\t\t\t"}}			}
+{{bodyMapForEndpoint .Endpoint "\t\t\t\t"}}			}
 {{- if .Endpoint.HeaderOverrides}}
 			data, statusCode, err := c.PutWithHeaders(path, body, headerOverrides)
 {{- else}}
@@ -368,7 +368,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 				body = jsonBody
 			} else {
 				body = map[string]any{}
-{{bodyMap .Endpoint.Body "\t\t\t\t"}}			}
+{{bodyMapForEndpoint .Endpoint "\t\t\t\t"}}			}
 {{- if .Endpoint.HeaderOverrides}}
 			data, statusCode, err := c.PatchWithHeaders(path, body, headerOverrides)
 {{- else}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 9cc134e4..620f58f2 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -186,7 +186,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			// body-aware cached read helper is filed as #425 for when a
 			// second store-backed POST-search consumer ships.
 			body := map[string]any{}
-{{bodyMap .Endpoint.Body "\t\t\t"}}			data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
+{{bodyMapForEndpoint .Endpoint "\t\t\t"}}			data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
 {{- end}}
 {{- else}}
 			// HEAD/OPTIONS and unknown verbs fall back to GET so generation
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 3d696d53..1fbeba98 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -3,6 +3,7 @@
 {{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 {{- $hasMultipartRequest := hasMultipartRequest .APISpec}}
 {{- $hasFormRequest := hasFormRequest .APISpec}}
+{{- $hasBodyJSONFallback := hasBodyJSONFallback .APISpec}}
 
 package mcp
 
@@ -63,6 +64,9 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithString({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
 {{- end}}
 {{- end}}
+{{- if $endpoint.BodyJSONFallback}}
+			mcplib.WithString("body_json"{{if $endpoint.BodyRequired}}, mcplib.Required(){{end}}, mcplib.Description("Request body as a JSON object string (this endpoint accepts a polymorphic schema: oneOf/anyOf)")),
+{{- end}}
 {{- if eq (upper $endpoint.Method) "GET"}}
 			mcplib.WithReadOnlyHintAnnotation(true),
 			mcplib.WithDestructiveHintAnnotation(false),
@@ -107,6 +111,9 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithString({{printf "%q" (mcpInputName .)}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (mcpParamDesc .)}})),
 {{- end}}
 {{- end}}
+{{- if $endpoint.BodyJSONFallback}}
+			mcplib.WithString("body_json"{{if $endpoint.BodyRequired}}, mcplib.Required(){{end}}, mcplib.Description("Request body as a JSON object string (this endpoint accepts a polymorphic schema: oneOf/anyOf)")),
+{{- end}}
 {{- if eq (upper $endpoint.Method) "GET"}}
 			mcplib.WithReadOnlyHintAnnotation(true),
 			mcplib.WithDestructiveHintAnnotation(false),
@@ -250,6 +257,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 {{- if $hasFormRequest}}
 		formFields := url.Values{}
 		formEncoded := false
+{{- end}}
+{{- if $hasBodyJSONFallback}}
+		// bodyJSONOverride holds an opaque body for endpoints whose request
+		// schema is a oneOf/anyOf and was wired with a single body_json input.
+		// When set, it replaces the marshaled bodyArgs map for POST/PUT/PATCH.
+		var bodyJSONOverride json.RawMessage
 {{- end}}
 		for _, binding := range bindings {
 			knownArgs[binding.PublicName] = true
@@ -287,6 +300,19 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				if formEncoded {
 					formFields.Set(binding.WireName, mcpFormFieldValue(v))
 				}
+{{- end}}
+{{- if $hasBodyJSONFallback}}
+			case "body_json":
+				if s, ok := v.(string); ok && s != "" {
+					var parsed any
+					if err := json.Unmarshal([]byte(s), &parsed); err != nil {
+						return mcplib.NewToolResultError("body_json must be a valid JSON object: " + err.Error()), nil
+					}
+					if _, isMap := parsed.(map[string]any); !isMap {
+						return mcplib.NewToolResultError(fmt.Sprintf("body_json must be a JSON object, got JSON %T", parsed)), nil
+					}
+					bodyJSONOverride = json.RawMessage(s)
+				}
 {{- end}}
 			default:
 				params[binding.WireName] = fmt.Sprintf("%v", v)
@@ -341,6 +367,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				data, _, err = c.PostForm(path, formFields)
 				break
 			}
+{{- end}}
+{{- if $hasBodyJSONFallback}}
+			if len(bodyJSONOverride) > 0 {
+				data, _, err = c.Post(path, bodyJSONOverride)
+				break
+			}
 {{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Post(path, body)
@@ -356,6 +388,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				data, _, err = c.PutForm(path, formFields)
 				break
 			}
+{{- end}}
+{{- if $hasBodyJSONFallback}}
+			if len(bodyJSONOverride) > 0 {
+				data, _, err = c.Put(path, bodyJSONOverride)
+				break
+			}
 {{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Put(path, body)
@@ -371,6 +409,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				data, _, err = c.PatchForm(path, formFields)
 				break
 			}
+{{- end}}
+{{- if $hasBodyJSONFallback}}
+			if len(bodyJSONOverride) > 0 {
+				data, _, err = c.Patch(path, bodyJSONOverride)
+				break
+			}
 {{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Patch(path, body)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index ffdcf568..4fada5ed 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1772,7 +1772,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			}
 
 			params := mapParameters(pathItem, op)
-			body, requestContentType := mapRequestBody(op.RequestBody, method, path)
+			body, requestContentType, bodyJSONFallback, bodyRequired := mapRequestBody(op.RequestBody, method, path)
 
 			// Deduplicate body params that collide with query/path params by flag name
 			if len(body) > 0 && len(params) > 0 {
@@ -1796,6 +1796,8 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 				Description:        description,
 				Params:             params,
 				Body:               body,
+				BodyJSONFallback:   bodyJSONFallback,
+				BodyRequired:       bodyRequired,
 				RequestContentType: requestContentType,
 			}
 			endpoint.Tier = readTierExtension(op.Extensions, fmt.Sprintf("%s %q", strings.ToUpper(method), path))
@@ -2498,26 +2500,35 @@ func mergeParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []*ope
 	return merged
 }
 
-func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) ([]spec.Param, string) {
+func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) ([]spec.Param, string, bool, bool) {
 	requestBody := requestBodyValue(requestBodyRef)
 	if requestBody == nil || requestBody.Content == nil {
-		return nil, ""
+		return nil, "", false, false
 	}
 
 	requestContentType, media := requestBodyMediaType(requestBody.Content)
 	if media == nil || media.Schema == nil || media.Schema.Value == nil {
-		return nil, ""
+		return nil, "", false, false
 	}
 
 	properties := map[string]*openapi3.SchemaRef{}
 	required := map[string]struct{}{}
 	if collectAllOfProperties(media.Schema, properties, required, map[*openapi3.Schema]struct{}{}) {
-		warnf("skipping request body for %s %q: contains oneOf/anyOf", strings.ToUpper(method), path)
-		return nil, ""
+		// oneOf/anyOf at the body root cannot be flattened to named flags.
+		// Only enable the --body-json fallback for JSON-shaped content
+		// types; the runtime decode path is wired through the JSON branch
+		// of the command template and does not understand multipart or
+		// form-urlencoded encodings.
+		if !isJSONContentType(requestContentType) {
+			warnf("skipping request body for %s %q: contains oneOf/anyOf and content type %q is not JSON-shaped", strings.ToUpper(method), path, requestContentType)
+			return nil, "", false, false
+		}
+		warnf("request body for %s %q contains oneOf/anyOf; emitting --body-json fallback", strings.ToUpper(method), path)
+		return nil, requestContentType, true, requestBody.Required
 	}
 
 	if len(properties) == 0 {
-		return nil, ""
+		return nil, "", false, false
 	}
 
 	names := make([]string, 0, len(properties))
@@ -2568,7 +2579,26 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
 		body = append(body, param)
 	}
 
-	return body, requestContentType
+	return body, requestContentType, false, requestBody.Required
+}
+
+// isJSONContentType reports whether ct is a JSON-shaped media type:
+// application/json, any */*+json variant (e.g. application/vnd.api+json),
+// or text/json. Multipart and form-urlencoded encodings are excluded so
+// the --body-json fallback only fires when the runtime is wired through
+// the JSON branch of the command template.
+func isJSONContentType(ct string) bool {
+	ct = strings.ToLower(strings.TrimSpace(ct))
+	if ct == "" {
+		return false
+	}
+	if i := strings.Index(ct, ";"); i >= 0 {
+		ct = strings.TrimSpace(ct[:i])
+	}
+	if ct == "application/json" || ct == "text/json" {
+		return true
+	}
+	return strings.HasPrefix(ct, "application/") && strings.HasSuffix(ct, "+json")
 }
 
 func requestBodyMediaType(content openapi3.Content) (string, *openapi3.MediaType) {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index eec0eb40..52b44bb5 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -651,6 +651,173 @@ components:
 	assert.Empty(t, fieldsByName["child"].Fields)
 }
 
+func TestParseOneOfRequestBodyEmitsJSONFallback(t *testing.T) {
+	t.Parallel()
+
+	parsed, err := Parse([]byte(`
+openapi: 3.0.3
+info:
+  title: DNS API
+  version: 1.0.0
+paths:
+  /zones/{zoneId}/records:
+    post:
+      operationId: createRecord
+      parameters:
+        - name: zoneId
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              oneOf:
+                - $ref: "#/components/schemas/ARecord"
+                - $ref: "#/components/schemas/CNAMERecord"
+      responses:
+        "200":
+          description: ok
+components:
+  schemas:
+    ARecord:
+      type: object
+      required: [type, name, content]
+      properties:
+        type: {type: string, enum: [A]}
+        name: {type: string}
+        content: {type: string}
+    CNAMERecord:
+      type: object
+      required: [type, name, content]
+      properties:
+        type: {type: string, enum: [CNAME]}
+        name: {type: string}
+        content: {type: string}
+`))
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/zones/{zoneId}/records")
+	assert.True(t, endpoint.BodyJSONFallback, "endpoint with oneOf body should opt into --body-json fallback")
+	assert.Empty(t, endpoint.Body, "BodyJSONFallback endpoints expose a single --body-json flag, not typed body params")
+	assert.Equal(t, "application/json", endpoint.RequestContentType, "fallback endpoints should default to application/json")
+}
+
+func TestParseAnyOfRequestBodyEmitsJSONFallback(t *testing.T) {
+	t.Parallel()
+
+	parsed, err := Parse([]byte(`
+openapi: 3.0.3
+info:
+  title: Block API
+  version: 1.0.0
+paths:
+  /blocks:
+    post:
+      operationId: createBlock
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              anyOf:
+                - type: object
+                  properties:
+                    paragraph: {type: string}
+                - type: object
+                  properties:
+                    heading: {type: string}
+      responses:
+        "200":
+          description: ok
+`))
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/blocks")
+	assert.True(t, endpoint.BodyJSONFallback)
+	assert.True(t, endpoint.BodyRequired, "requestBody.required should thread through to Endpoint.BodyRequired")
+	assert.Empty(t, endpoint.Body)
+}
+
+// TestParseOneOfRequestBodyPreservesVendorJSONContentType confirms a
+// non-default JSON content type (application/vnd.api+json,
+// application/problem+json, etc.) round-trips through the fallback path.
+// The runtime decode is content-type-agnostic; what matters is that the
+// declared type isn't multipart or form-urlencoded.
+func TestParseOneOfRequestBodyPreservesVendorJSONContentType(t *testing.T) {
+	t.Parallel()
+
+	parsed, err := Parse([]byte(`
+openapi: 3.0.3
+info:
+  title: Vendor API
+  version: 1.0.0
+paths:
+  /events:
+    post:
+      operationId: createEvent
+      requestBody:
+        content:
+          application/vnd.api+json:
+            schema:
+              oneOf:
+                - type: object
+                  properties:
+                    type: {type: string}
+                - type: object
+                  properties:
+                    kind: {type: string}
+      responses:
+        "200":
+          description: ok
+`))
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/events")
+	assert.True(t, endpoint.BodyJSONFallback)
+	assert.Equal(t, "application/vnd.api+json", endpoint.RequestContentType)
+}
+
+// TestParseOneOfRequestBodyMultipartDoesNotEmitJSONFallback covers the
+// guard that prevents emitting a --body-json flag for non-JSON content
+// types. The runtime JSON branch of command_endpoint.go.tmpl is not
+// wired for multipart, so a fallback flag there would be dead.
+func TestParseOneOfRequestBodyMultipartDoesNotEmitJSONFallback(t *testing.T) {
+	t.Parallel()
+
+	parsed, err := Parse([]byte(`
+openapi: 3.0.3
+info:
+  title: Upload API
+  version: 1.0.0
+paths:
+  /uploads:
+    post:
+      operationId: createUpload
+      requestBody:
+        content:
+          multipart/form-data:
+            schema:
+              oneOf:
+                - type: object
+                  properties:
+                    file: {type: string, format: binary}
+                - type: object
+                  properties:
+                    url: {type: string}
+      responses:
+        "200":
+          description: ok
+`))
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/uploads")
+	assert.False(t, endpoint.BodyJSONFallback, "multipart oneOf should not opt into the JSON fallback")
+	assert.Empty(t, endpoint.Body)
+}
+
 func TestParseStytchOpenAPI(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index e48d402c..b23c0397 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -954,12 +954,24 @@ func resourceHasBaseURLOverride(resource Resource) bool {
 }
 
 type Endpoint struct {
-	Method             string            `yaml:"method" json:"method"`
-	Path               string            `yaml:"path" json:"path"`
-	BaseURL            string            `yaml:"base_url,omitempty" json:"base_url,omitempty"`
-	Description        string            `yaml:"description" json:"description"`
-	Params             []Param           `yaml:"params" json:"params"`
-	Body               []Param           `yaml:"body" json:"body"`
+	Method      string  `yaml:"method" json:"method"`
+	Path        string  `yaml:"path" json:"path"`
+	BaseURL     string  `yaml:"base_url,omitempty" json:"base_url,omitempty"`
+	Description string  `yaml:"description" json:"description"`
+	Params      []Param `yaml:"params" json:"params"`
+	Body        []Param `yaml:"body" json:"body"`
+	// BodyJSONFallback signals that the request body schema is a oneOf/anyOf
+	// (or other shape that cannot be flattened to named flags) and that the
+	// generator should emit a single --body-json string flag instead of
+	// per-field typed flags. The parser sets this only for JSON-shaped
+	// content types and leaves Body empty; helpers treat Body as ignored
+	// when this flag is true.
+	BodyJSONFallback bool `yaml:"body_json_fallback,omitempty" json:"body_json_fallback,omitempty"`
+	// BodyRequired mirrors OpenAPI's requestBody.required for body params
+	// the parser cannot describe at field level (currently used only by
+	// the BodyJSONFallback path). The typed body path uses per-Param
+	// Required flags instead; this field is ignored when Body is populated.
+	BodyRequired       bool              `yaml:"body_required,omitempty" json:"body_required,omitempty"`
 	RequestContentType string            `yaml:"request_content_type,omitempty" json:"request_content_type,omitempty"`
 	Response           ResponseDef       `yaml:"response" json:"response"`
 	ResponseFormat     string            `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html

← 242a56bc fix(cli): validate ListIDs resourceType to close SQL injecti  ·  back to Cli Printing Press  ·  fix(ci): drop cancel-in-progress from pr-title workflow (#10 424199a7 →