← 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
M internal/generator/body_map_test.goM internal/generator/generator.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.go
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 →