[object Object]

← back to Cli Printing Press

fix(cli): emit form-encoded request bodies (#947)

48cc2a31b172328d6f6a314014553c3f8e981eb3 · 2026-05-10 12:12:08 -0700 · Trevin Chow

* fix(cli): emit form-encoded request bodies (closes #921)

Generator now branches request-body emission on Endpoint.RequestContentType so
endpoints declaring application/x-www-form-urlencoded route through new
PostForm/PutForm/PatchForm helpers instead of c.Post (which JSON-marshaled).
This unblocks OAuth 2.0 token endpoints (which RFC 6749 mandates form-encode)
and reverse-engineered APIs that mirror Laravel/Rails-style backends.

The new method declarations and helper types are gated on .HasFormRequest so
specs without form-encoded endpoints generate byte-identical output, verified
by `scripts/golden.sh verify` across all 16 cases. Multipart and form share
the contentType variable in client do() so both compose without conflict.

Per-field json-string encoding (Resy struct_data) was listed as optional in
the issue and is not implemented; the body-map helper does validate JSON for
object/array/json-string fields before writing them to the form.

* fix(cli): address Greptile review feedback

- Rename isComplexMultipartField → isComplexBodyField; the predicate is
  shared by multipart and form body-emission paths, so the multipart-
  specific name no longer matches its callers.
- Note the intentional HeaderOverrides omission on the promoted form
  branch via a Go-template comment so the gap is visible to future
  template editors without leaking into generated output.

Files touched

Diff

commit 48cc2a31b172328d6f6a314014553c3f8e981eb3
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 12:12:08 2026 -0700

    fix(cli): emit form-encoded request bodies (#947)
    
    * fix(cli): emit form-encoded request bodies (closes #921)
    
    Generator now branches request-body emission on Endpoint.RequestContentType so
    endpoints declaring application/x-www-form-urlencoded route through new
    PostForm/PutForm/PatchForm helpers instead of c.Post (which JSON-marshaled).
    This unblocks OAuth 2.0 token endpoints (which RFC 6749 mandates form-encode)
    and reverse-engineered APIs that mirror Laravel/Rails-style backends.
    
    The new method declarations and helper types are gated on .HasFormRequest so
    specs without form-encoded endpoints generate byte-identical output, verified
    by `scripts/golden.sh verify` across all 16 cases. Multipart and form share
    the contentType variable in client do() so both compose without conflict.
    
    Per-field json-string encoding (Resy struct_data) was listed as optional in
    the issue and is not implemented; the body-map helper does validate JSON for
    object/array/json-string fields before writing them to the form.
    
    * fix(cli): address Greptile review feedback
    
    - Rename isComplexMultipartField → isComplexBodyField; the predicate is
      shared by multipart and form body-emission paths, so the multipart-
      specific name no longer matches its callers.
    - Note the intentional HeaderOverrides omission on the promoted form
      branch via a Go-template comment so the gap is visible to future
      template editors without leaking into generated output.
---
 internal/generator/form_test.go                    | 117 +++++++++++++++++++++
 internal/generator/generator.go                    |  58 +++++++++-
 internal/generator/templates/client.go.tmpl        |  63 +++++++++--
 .../generator/templates/command_endpoint.go.tmpl   |  36 ++++++-
 .../generator/templates/command_promoted.go.tmpl   |   8 ++
 internal/generator/templates/mcp_tools.go.tmpl     |  55 +++++++++-
 internal/openapi/parser_test.go                    |  94 +++++++++++++++++
 7 files changed, 415 insertions(+), 16 deletions(-)

diff --git a/internal/generator/form_test.go b/internal/generator/form_test.go
new file mode 100644
index 00000000..9d0f6e36
--- /dev/null
+++ b/internal/generator/form_test.go
@@ -0,0 +1,117 @@
+package generator
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
+	"github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+// TestGenerateFormRequestBodyUsesFormClient validates that endpoints declaring
+// application/x-www-form-urlencoded request bodies are routed through the
+// new c.PostForm helper rather than c.Post — covers the OAuth and Resy bug
+// described in #921.
+func TestGenerateFormRequestBodyUsesFormClient(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("formapi")
+	apiSpec.Resources = map[string]spec.Resource{
+		"oauth": {
+			Description: "OAuth token operations",
+			Endpoints: map[string]spec.Endpoint{
+				"list": {
+					Method:      "GET",
+					Path:        "/oauth/clients",
+					Description: "List OAuth clients",
+				},
+				"token": {
+					Method:             "POST",
+					Path:               "/oauth/token",
+					Description:        "Exchange OAuth token",
+					RequestContentType: "application/x-www-form-urlencoded",
+					Body: []spec.Param{
+						{Name: "grant_type", Type: "string", Required: true, Description: "Grant type"},
+						{Name: "client_id", Type: "string", Required: true, Description: "Client id"},
+						{Name: "client_secret", Type: "string", Description: "Client secret"},
+					},
+				},
+			},
+		},
+		"venues": {
+			Description: "Venue operations",
+			Endpoints: map[string]spec.Endpoint{
+				"search": {
+					Method:             "POST",
+					Path:               "/venues/search",
+					Description:        "Search venues",
+					RequestContentType: "application/x-www-form-urlencoded",
+					Body: []spec.Param{
+						{Name: "struct_data", Type: "string", Format: "json", Required: true, Description: "JSON-encoded query payload"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientSrc := readGeneratedFile(t, outputDir, "internal", "client", "client.go")
+	assert.Contains(t, clientSrc, `func (c *Client) PostForm(path string, fields url.Values) (json.RawMessage, int, error)`)
+	assert.Contains(t, clientSrc, `func (c *Client) PostFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error)`)
+	assert.Contains(t, clientSrc, `type formRequestBody struct {`)
+	assert.Contains(t, clientSrc, `func encodeFormBody(body formRequestBody) ([]byte, string, error)`)
+	assert.Contains(t, clientSrc, `body.Fields.Encode()`)
+	assert.Contains(t, clientSrc, `req.Header.Set("Content-Type", contentType)`)
+
+	endpointSrc := readGeneratedFile(t, outputDir, "internal", "cli", "oauth_token.go")
+	assert.Contains(t, endpointSrc, `"net/url"`)
+	assert.Contains(t, endpointSrc, `fields := url.Values{}`)
+	assert.Contains(t, endpointSrc, `fields.Set("grant_type", bodyGrantType)`)
+	assert.Contains(t, endpointSrc, `fields.Set("client_id", bodyClientId)`)
+	assert.Contains(t, endpointSrc, `c.PostForm(path, fields)`)
+	assert.NotContains(t, endpointSrc, `var stdinBody bool`)
+	assert.NotContains(t, endpointSrc, `c.Post(path, body)`)
+	// Required-flag check should fire at top-level, not inside `if !stdinBody`.
+	assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "grant-type")`)
+	assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "client-id")`)
+
+	// JSON-string body field validates as JSON before sending. Single-endpoint
+	// resource collapses to the promoted form rather than a per-endpoint file.
+	venuesSrc := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_venues.go")
+	assert.Contains(t, venuesSrc, `if !json.Valid([]byte(bodyStructData))`)
+	assert.Contains(t, venuesSrc, `fields.Set("struct_data", bodyStructData)`)
+	assert.Contains(t, venuesSrc, `c.PostForm(path, fields)`)
+
+	mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+	assert.Contains(t, mcpSrc, `RequestContentType: "application/x-www-form-urlencoded"`)
+	assert.Contains(t, mcpSrc, `formFields := url.Values{}`)
+	assert.Contains(t, mcpSrc, `data, _, err = c.PostForm(path, formFields)`)
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
+
+// TestGenerateNonFormSpecOmitsFormHelpers asserts the negative case: a spec
+// with no form-encoded endpoints generates client.go without any form-specific
+// imports, types, or methods. Guards the byte-identical-output criterion in
+// #921's acceptance criteria.
+func TestGenerateNonFormSpecOmitsFormHelpers(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("plainapi")
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	clientSrc := readGeneratedFile(t, outputDir, "internal", "client", "client.go")
+	// Form-helper method declarations must be absent (HTTPClient.PostForm
+	// from net/http is fine — only the *Client receiver method is gated).
+	assert.NotContains(t, clientSrc, `func (c *Client) PostForm`)
+	assert.NotContains(t, clientSrc, `func (c *Client) PutForm`)
+	assert.NotContains(t, clientSrc, `func (c *Client) PatchForm`)
+	assert.NotContains(t, clientSrc, "formRequestBody")
+	assert.NotContains(t, clientSrc, "encodeFormBody")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index bef9af5c..77c2edff 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -311,6 +311,9 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"multipartBodyMaps":     multipartBodyMaps,
 		"endpointUsesMultipart": endpointUsesMultipart,
 		"hasMultipartRequest":   hasMultipartRequest,
+		"formBodyMaps":          formBodyMaps,
+		"endpointUsesForm":      endpointUsesForm,
+		"hasFormRequest":        hasFormRequest,
 		"publicFlagName":        publicFlagName,
 		"publicFlagAliases":     publicFlagAliases,
 		"flagChangedExpr":       flagChangedExpr,
@@ -646,6 +649,7 @@ type clientTemplateData struct {
 	*spec.APISpec
 	HasGraphQLPersistedQueries bool
 	HasMultipartRequest        bool
+	HasFormRequest             bool
 	// Populated by Generator.shouldEmitAuth() so this template gate stays in
 	// sync with auth.go emission, root.go registration, and scoreAuth.
 	HasAuthCommand bool
@@ -1435,6 +1439,7 @@ func (g *Generator) renderSingleFiles() error {
 				APISpec:                    g.Spec,
 				HasGraphQLPersistedQueries: g.hasTrafficAnalysisHint("graphql_persisted_query"),
 				HasMultipartRequest:        hasMultipartRequest(g.Spec),
+				HasFormRequest:             hasFormRequest(g.Spec),
 				HasAuthCommand:             g.shouldEmitAuth(),
 			}
 		case "config.go.tmpl":
@@ -3040,7 +3045,7 @@ func flagChangedExpr(p spec.Param) string {
 func mcpParamBindings(endpoint spec.Endpoint, pathTemplate string) []mcpParamBinding {
 	bindings := make([]mcpParamBinding, 0, len(endpoint.Params)+len(endpoint.Body))
 	requestContentType := ""
-	if endpointUsesMultipart(endpoint) {
+	if endpointUsesMultipart(endpoint) || endpointUsesForm(endpoint) {
 		requestContentType = endpoint.RequestContentType
 	}
 	for _, p := range endpoint.Params {
@@ -3156,7 +3161,7 @@ func multipartBodyMaps(body []spec.Param, indent string) string {
 		id := paramIdent(p)
 		ident := toCamel(id)
 		flag := publicFlagName(p)
-		if isComplexMultipartField(p) || isJSONStringParam(p) {
+		if isComplexBodyField(p) || isJSONStringParam(p) {
 			fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
 			fmt.Fprintf(&b, "%s\tif !json.Valid([]byte(body%s)) {\n", indent, ident)
 			fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: invalid JSON\")\n", indent, flag)
@@ -3189,6 +3194,18 @@ func endpointUsesMultipart(endpoint spec.Endpoint) bool {
 }
 
 func hasMultipartRequest(apiSpec *spec.APISpec) bool {
+	return anyEndpointMatches(apiSpec, endpointUsesMultipart)
+}
+
+func endpointUsesForm(endpoint spec.Endpoint) bool {
+	return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "application/x-www-form-urlencoded")
+}
+
+func hasFormRequest(apiSpec *spec.APISpec) bool {
+	return anyEndpointMatches(apiSpec, endpointUsesForm)
+}
+
+func anyEndpointMatches(apiSpec *spec.APISpec, predicate func(spec.Endpoint) bool) bool {
 	if apiSpec == nil {
 		return false
 	}
@@ -3196,7 +3213,7 @@ func hasMultipartRequest(apiSpec *spec.APISpec) bool {
 	walk = func(resources map[string]spec.Resource) bool {
 		for _, resource := range resources {
 			for _, endpoint := range resource.Endpoints {
-				if endpointUsesMultipart(endpoint) {
+				if predicate(endpoint) {
 					return true
 				}
 			}
@@ -3209,11 +3226,44 @@ func hasMultipartRequest(apiSpec *spec.APISpec) bool {
 	return walk(apiSpec.Resources)
 }
 
+// formBodyMaps renders per-flag form-field assignments for endpoints that send
+// application/x-www-form-urlencoded request bodies. Object/array/JSON-string
+// fields are validated as JSON then sent as a single string field (matching
+// the JSON-shaped struct_data convention used by reverse-engineered APIs);
+// scalar fields are formatted with %v.
+func formBodyMaps(body []spec.Param, indent string) string {
+	var b strings.Builder
+	for _, p := range body {
+		id := paramIdent(p)
+		ident := toCamel(id)
+		flag := publicFlagName(p)
+		if isComplexBodyField(p) || isJSONStringParam(p) {
+			fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
+			fmt.Fprintf(&b, "%s\tif !json.Valid([]byte(body%s)) {\n", indent, ident)
+			fmt.Fprintf(&b, "%s\t\treturn fmt.Errorf(\"parsing --%s JSON: invalid JSON\")\n", indent, flag)
+			fmt.Fprintf(&b, "%s\t}\n", indent)
+			fmt.Fprintf(&b, "%s\tfields.Set(%q, body%s)\n", indent, p.Name, ident)
+			fmt.Fprintf(&b, "%s}\n", indent)
+			continue
+		}
+		if p.Type == "string" {
+			fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
+			fmt.Fprintf(&b, "%s\tfields.Set(%q, body%s)\n", indent, p.Name, ident)
+			fmt.Fprintf(&b, "%s}\n", indent)
+			continue
+		}
+		fmt.Fprintf(&b, "%sif body%s != %s {\n", indent, ident, zeroVal(p.Type))
+		fmt.Fprintf(&b, "%s\tfields.Set(%q, fmt.Sprintf(\"%%v\", body%s))\n", indent, p.Name, ident)
+		fmt.Fprintf(&b, "%s}\n", indent)
+	}
+	return b.String()
+}
+
 func isBinaryParam(p spec.Param) bool {
 	return strings.EqualFold(strings.TrimSpace(p.Format), "binary")
 }
 
-func isComplexMultipartField(p spec.Param) bool {
+func isComplexBodyField(p spec.Param) bool {
 	return p.Type == "object" || p.Type == "array"
 }
 
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index f3f06812..818d1685 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -18,7 +18,7 @@ import (
 	"mime/multipart"
 {{- end}}
 	"net/http"
-{{- if .HasAuthCommand}}
+{{- if or .HasAuthCommand .HasFormRequest}}
 	"net/url"
 {{- end}}
 	"os"
@@ -463,6 +463,16 @@ func (c *Client) PostMultipartWithHeaders(path string, fields map[string]string,
 	return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
 }
 
+{{ end }}
+{{- if .HasFormRequest }}
+func (c *Client) PostForm(path string, fields url.Values) (json.RawMessage, int, error) {
+	return c.do("POST", path, nil, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PostFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("POST", path, nil, formRequestBody{Fields: fields}, headers)
+}
+
 {{ end }}
 
 func (c *Client) Delete(path string) (json.RawMessage, int, error) {
@@ -490,6 +500,16 @@ func (c *Client) PutMultipartWithHeaders(path string, fields map[string]string,
 	return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
 }
 
+{{ end }}
+{{- if .HasFormRequest }}
+func (c *Client) PutForm(path string, fields url.Values) (json.RawMessage, int, error) {
+	return c.do("PUT", path, nil, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PutFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("PUT", path, nil, formRequestBody{Fields: fields}, headers)
+}
+
 {{ end }}
 
 func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
@@ -509,6 +529,19 @@ func (c *Client) PatchMultipartWithHeaders(path string, fields map[string]string
 	return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
 }
 
+{{ end }}
+{{- if .HasFormRequest }}
+func (c *Client) PatchForm(path string, fields url.Values) (json.RawMessage, int, error) {
+	return c.do("PATCH", path, nil, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PatchFormWithHeaders(path string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("PATCH", path, nil, formRequestBody{Fields: fields}, headers)
+}
+
+{{ end }}
+
+{{- if .HasMultipartRequest }}
 type multipartRequestBody struct {
 	Fields     map[string]string
 	FileFields map[string]string
@@ -553,6 +586,17 @@ func encodeMultipartBody(body multipartRequestBody) ([]byte, string, error) {
 
 {{ end }}
 
+{{- if .HasFormRequest }}
+type formRequestBody struct {
+	Fields url.Values
+}
+
+func encodeFormBody(body formRequestBody) ([]byte, string, error) {
+	return []byte(body.Fields.Encode()), "application/x-www-form-urlencoded", nil
+}
+
+{{ end }}
+
 // do executes an HTTP request. headerOverrides, when non-nil, override global
 // RequiredHeaders for this specific request (used for per-endpoint API versioning).
 func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
@@ -621,7 +665,7 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 {{- end}}
 
 	var bodyBytes []byte
-{{- if .HasMultipartRequest}}
+{{- if or .HasMultipartRequest .HasFormRequest}}
 	var contentType string
 {{- end}}
 	if body != nil {
@@ -633,17 +677,22 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 			}
 			bodyBytes = b
 			contentType = ct
-		} else {
+		} else {{end}}{{- if .HasFormRequest}}if formBody, ok := body.(formRequestBody); ok {
+			b, ct, err := encodeFormBody(formBody)
+			if err != nil {
+				return nil, 0, err
+			}
+			bodyBytes = b
+			contentType = ct
+		} else {{end}}{{- if or .HasMultipartRequest .HasFormRequest}}{
 {{- end}}
 		b, err := json.Marshal(body)
 		if err != nil {
 			return nil, 0, fmt.Errorf("marshaling body: %w", err)
 		}
 		bodyBytes = b
-{{- if .HasMultipartRequest}}
+{{- if or .HasMultipartRequest .HasFormRequest}}
 		contentType = "application/json"
-{{- end}}
-{{- if .HasMultipartRequest}}
 		}
 {{- end}}
 	}
@@ -740,7 +789,7 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 			return nil, 0, fmt.Errorf("creating request: %w", err)
 		}
 		if bodyBytes != nil {
-{{- if .HasMultipartRequest}}
+{{- if or .HasMultipartRequest .HasFormRequest}}
 			req.Header.Set("Content-Type", contentType)
 {{- else}}
 			req.Header.Set("Content-Type", "application/json")
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 8fe455fc..1d81ae4a 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -2,6 +2,7 @@
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
 {{- $isGraphQLEndpoint := and (isGraphQL .APISpec) (eq .Endpoint.Path "/graphql") (or (eq .EndpointName "list") (eq .EndpointName "get"))}}
 {{- $isMultipart := endpointUsesMultipart .Endpoint}}
+{{- $isForm := endpointUsesForm .Endpoint}}
 
 package cli
 
@@ -11,8 +12,11 @@ import (
 {{- end}}
 	"encoding/json"
 	"fmt"
-{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
 	"io"
+{{- end}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) $isForm}}
+	"net/url"
 {{- end}}
 	"os"
 {{- if .IsAsync}}
@@ -37,7 +41,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if .Endpoint.Pagination}}
 	var flagAll bool
 {{- end}}
-{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
 	var stdinBody bool
 {{- end}}
 {{- if .IsAsync}}
@@ -103,7 +107,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 {{- end}}
 {{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
-{{- if $isMultipart}}
+{{- if or $isMultipart $isForm}}
 {{- range .Endpoint.Body}}
 {{- if and .Required (not .Default)}}
 			if !{{flagChangedExpr .}} && !flags.dryRun {
@@ -272,6 +276,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- else}}
 			data, statusCode, err := c.PostMultipart(path, fields, fileFields)
 {{- end}}
+{{- else if $isForm}}
+			fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+			data, statusCode, err := c.PostFormWithHeaders(path, fields, headerOverrides)
+{{- else}}
+			data, statusCode, err := c.PostForm(path, fields)
+{{- end}}
 {{- else}}
 			var body map[string]any
 			if stdinBody {
@@ -309,6 +321,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- else}}
 			data, statusCode, err := c.PutMultipart(path, fields, fileFields)
 {{- end}}
+{{- else if $isForm}}
+			fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+			data, statusCode, err := c.PutFormWithHeaders(path, fields, headerOverrides)
+{{- else}}
+			data, statusCode, err := c.PutForm(path, fields)
+{{- end}}
 {{- else}}
 			var body map[string]any
 			if stdinBody {
@@ -340,6 +360,14 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- else}}
 			data, statusCode, err := c.PatchMultipart(path, fields, fileFields)
 {{- end}}
+{{- else if $isForm}}
+			fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+			data, statusCode, err := c.PatchFormWithHeaders(path, fields, headerOverrides)
+{{- else}}
+			data, statusCode, err := c.PatchForm(path, fields)
+{{- end}}
 {{- else}}
 			var body map[string]any
 			if stdinBody {
@@ -566,7 +594,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if .Endpoint.Pagination}}
 	cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
 {{- end}}
-{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart) (not $isForm)}}
 	cmd.Flags().BoolVar(&stdinBody, "stdin", false, "Read request body as JSON from stdin")
 {{- end}}
 {{- if .IsAsync}}
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 1aebe721..6aecf4b3 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -1,12 +1,16 @@
 // Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
 {{- $isMultipart := endpointUsesMultipart .Endpoint}}
+{{- $isForm := endpointUsesForm .Endpoint}}
 
 package cli
 
 import (
 	"encoding/json"
 	"fmt"
+{{- if and (or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")) $isForm}}
+	"net/url"
+{{- end}}
 	"os"
 
 	"github.com/spf13/cobra"
@@ -180,6 +184,10 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 			fileFields := map[string]string{}
 {{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
 			data, _, err := c.{{pascal (lower .Endpoint.Method)}}Multipart(path, fields, fileFields)
+{{- else if $isForm}}{{/* HeaderOverrides intentionally omitted on promoted form/multipart/JSON paths — parity with the existing multipart promoted branch above. */}}
+			fields := url.Values{}
+{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+			data, _, err := c.{{pascal (lower .Endpoint.Method)}}Form(path, fields)
 {{- else}}
 			// HasStore + non-GET falls through to a live API call here
 			// rather than through resolveRead (GET-only internally); a
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 1e68eecd..db8d5932 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -2,6 +2,7 @@
 // Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
 {{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
 {{- $hasMultipartRequest := hasMultipartRequest .APISpec}}
+{{- $hasFormRequest := hasFormRequest .APISpec}}
 
 package mcp
 
@@ -9,6 +10,9 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+{{- if $hasFormRequest}}
+	"net/url"
+{{- end}}
 	"os"
 	"path/filepath"
 	"strings"
@@ -180,7 +184,7 @@ type mcpParamBinding struct {
 	PublicName string
 	WireName   string
 	Location   string
-{{- if $hasMultipartRequest}}
+{{- if or $hasMultipartRequest $hasFormRequest}}
 	Format             string
 	RequestContentType string
 {{- end}}
@@ -198,6 +202,18 @@ func mcpMultipartFieldValue(v any) string {
 }
 {{- end}}
 
+{{- if $hasFormRequest}}
+func mcpFormFieldValue(v any) string {
+	if s, ok := v.(string); ok {
+		return s
+	}
+	if data, err := json.Marshal(v); err == nil {
+		return string(data)
+	}
+	return fmt.Sprintf("%v", v)
+}
+{{- end}}
+
 // makeAPIHandler creates a generic MCP tool handler for an API endpoint.
 {{- if .HasTierRouting}}
 func makeAPIHandler(method, pathTemplate, tier string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
@@ -230,6 +246,10 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 		multipartFields := make(map[string]string)
 		multipartFileFields := make(map[string]string)
 		multipart := false
+{{- end}}
+{{- if $hasFormRequest}}
+		formFields := url.Values{}
+		formEncoded := false
 {{- end}}
 		for _, binding := range bindings {
 			knownArgs[binding.PublicName] = true
@@ -237,6 +257,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 			if strings.EqualFold(binding.RequestContentType, "multipart/form-data") {
 				multipart = true
 			}
+{{- end}}
+{{- if $hasFormRequest}}
+			if strings.EqualFold(binding.RequestContentType, "application/x-www-form-urlencoded") {
+				formEncoded = true
+			}
 {{- end}}
 			v, ok := args[binding.PublicName]
 			if !ok {
@@ -257,6 +282,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 						multipartFields[binding.WireName] = mcpMultipartFieldValue(v)
 					}
 				}
+{{- end}}
+{{- if $hasFormRequest}}
+				if formEncoded {
+					formFields.Set(binding.WireName, mcpFormFieldValue(v))
+				}
 {{- end}}
 			default:
 				params[binding.WireName] = fmt.Sprintf("%v", v)
@@ -284,6 +314,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				if multipart {
 					multipartFields[k] = mcpMultipartFieldValue(v)
 				}
+{{- end}}
+{{- if $hasFormRequest}}
+				if formEncoded {
+					formFields.Set(k, mcpFormFieldValue(v))
+				}
 {{- end}}
 			default:
 				params[k] = fmt.Sprintf("%v", v)
@@ -300,6 +335,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)
 				break
 			}
+{{- end}}
+{{- if $hasFormRequest}}
+			if formEncoded {
+				data, _, err = c.PostForm(path, formFields)
+				break
+			}
 {{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Post(path, body)
@@ -309,6 +350,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				data, _, err = c.PutMultipart(path, multipartFields, multipartFileFields)
 				break
 			}
+{{- end}}
+{{- if $hasFormRequest}}
+			if formEncoded {
+				data, _, err = c.PutForm(path, formFields)
+				break
+			}
 {{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Put(path, body)
@@ -318,6 +365,12 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				data, _, err = c.PatchMultipart(path, multipartFields, multipartFileFields)
 				break
 			}
+{{- end}}
+{{- if $hasFormRequest}}
+			if formEncoded {
+				data, _, err = c.PatchForm(path, formFields)
+				break
+			}
 {{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Patch(path, body)
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index e99256e6..f76d1d79 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -4572,6 +4572,100 @@ paths:
 	assert.True(t, byName["filename"].Required)
 }
 
+func TestParseFormUrlencodedRequestBodyPreservesContentType(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: OAuth API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+paths:
+  /oauth/token:
+    post:
+      operationId: exchangeToken
+      summary: Exchange OAuth token
+      requestBody:
+        required: true
+        content:
+          application/x-www-form-urlencoded:
+            schema:
+              type: object
+              required: [grant_type, client_id]
+              properties:
+                grant_type:
+                  type: string
+                client_id:
+                  type: string
+                client_secret:
+                  type: string
+                refresh_token:
+                  type: string
+      responses:
+        "200":
+          description: ok
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/oauth/token")
+	assert.Equal(t, "application/x-www-form-urlencoded", endpoint.RequestContentType)
+	require.Len(t, endpoint.Body, 4)
+	byName := map[string]spec.Param{}
+	for _, param := range endpoint.Body {
+		byName[param.Name] = param
+	}
+	assert.True(t, byName["grant_type"].Required)
+	assert.True(t, byName["client_id"].Required)
+	assert.False(t, byName["client_secret"].Required)
+}
+
+// TestParseJSONPreferredOverFormUrlencoded asserts the parser still picks
+// application/json when the spec offers both content types — keeping JSON-
+// declared specs byte-identical and letting form-only OAuth/legacy endpoints
+// surface their wire shape.
+func TestParseJSONPreferredOverFormUrlencoded(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Multi Content API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+paths:
+  /items:
+    post:
+      operationId: createItem
+      requestBody:
+        required: true
+        content:
+          application/x-www-form-urlencoded:
+            schema:
+              type: object
+              properties:
+                name:
+                  type: string
+          application/json:
+            schema:
+              type: object
+              properties:
+                name:
+                  type: string
+      responses:
+        "201":
+          description: created
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/items")
+	assert.Equal(t, "application/json", endpoint.RequestContentType)
+}
+
 func findParsedEndpointByPath(t *testing.T, parsed *spec.APISpec, method, path string) spec.Endpoint {
 	t.Helper()
 	for _, resource := range parsed.Resources {

← 6cd57cc2 fix(cli): infer resource-prefixed IDField from item-schema p  ·  back to Cli Printing Press  ·  fix(cli): isolate generic resources by type (#901) ff755316 →