[object Object]

← back to Cli Printing Press

fix(cli): emit multipart requests for upload endpoints (#904)

fc291cae1938593d86e4abac349a9a2117ced0a0 · 2026-05-10 10:47:16 -0700 · Trevin Chow

* fix(cli): emit multipart requests for upload endpoints

* fix(cli): address multipart review feedback

Files touched

Diff

commit fc291cae1938593d86e4abac349a9a2117ced0a0
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sun May 10 10:47:16 2026 -0700

    fix(cli): emit multipart requests for upload endpoints (#904)
    
    * fix(cli): emit multipart requests for upload endpoints
    
    * fix(cli): address multipart review feedback
---
 internal/generator/generator.go                    | 122 ++++++++++++++++++---
 internal/generator/multipart_test.go               |  89 +++++++++++++++
 internal/generator/templates/client.go.tmpl        | 101 +++++++++++++++++
 .../generator/templates/command_endpoint.go.tmpl   |  50 ++++++++-
 .../generator/templates/command_promoted.go.tmpl   |   8 ++
 internal/generator/templates/mcp_tools.go.tmpl     |  67 ++++++++++-
 internal/openapi/parser.go                         |  83 +++++++-------
 internal/openapi/parser_test.go                    |  49 +++++++++
 internal/spec/spec.go                              |  31 +++---
 9 files changed, 524 insertions(+), 76 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index b9f8bc95..bef9af5c 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -305,14 +305,17 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 			}
 			return " (one of: " + strings.Join(values, ", ") + ")"
 		},
-		"jsonStringParam":    isJSONStringParam,
-		"jsonEnumSuggestion": jsonEnumSuggestion,
-		"bodyMap":            bodyMap,
-		"publicFlagName":     publicFlagName,
-		"publicFlagAliases":  publicFlagAliases,
-		"flagChangedExpr":    flagChangedExpr,
-		"mcpInputName":       mcpInputName,
-		"mcpParamBindings":   mcpParamBindings,
+		"jsonStringParam":       isJSONStringParam,
+		"jsonEnumSuggestion":    jsonEnumSuggestion,
+		"bodyMap":               bodyMap,
+		"multipartBodyMaps":     multipartBodyMaps,
+		"endpointUsesMultipart": endpointUsesMultipart,
+		"hasMultipartRequest":   hasMultipartRequest,
+		"publicFlagName":        publicFlagName,
+		"publicFlagAliases":     publicFlagAliases,
+		"flagChangedExpr":       flagChangedExpr,
+		"mcpInputName":          mcpInputName,
+		"mcpParamBindings":      mcpParamBindings,
 		// endpointNeedsClientLimit reports whether a list endpoint needs
 		// client-side truncation. True when the endpoint has a `limit`-named
 		// param AND no Pagination block — the spec author asked for a
@@ -642,6 +645,7 @@ type authTemplateData struct {
 type clientTemplateData struct {
 	*spec.APISpec
 	HasGraphQLPersistedQueries bool
+	HasMultipartRequest        bool
 	// Populated by Generator.shouldEmitAuth() so this template gate stays in
 	// sync with auth.go emission, root.go registration, and scoreAuth.
 	HasAuthCommand bool
@@ -1430,6 +1434,7 @@ func (g *Generator) renderSingleFiles() error {
 			data = &clientTemplateData{
 				APISpec:                    g.Spec,
 				HasGraphQLPersistedQueries: g.hasTrafficAnalysisHint("graphql_persisted_query"),
+				HasMultipartRequest:        hasMultipartRequest(g.Spec),
 				HasAuthCommand:             g.shouldEmitAuth(),
 			}
 		case "config.go.tmpl":
@@ -3013,9 +3018,11 @@ type jsonFlagSuggestion struct {
 }
 
 type mcpParamBinding struct {
-	PublicName string
-	WireName   string
-	Location   string
+	PublicName         string
+	WireName           string
+	Location           string
+	Format             string
+	RequestContentType string
 }
 
 func flagChangedExpr(p spec.Param) string {
@@ -3032,27 +3039,41 @@ 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) {
+		requestContentType = endpoint.RequestContentType
+	}
 	for _, p := range endpoint.Params {
 		loc := "query"
 		if strings.Contains(pathTemplate, "{"+p.Name+"}") {
 			loc = "path"
 		}
 		bindings = append(bindings, mcpParamBinding{
-			PublicName: p.PublicInputName(),
-			WireName:   p.Name,
-			Location:   loc,
+			PublicName:         p.PublicInputName(),
+			WireName:           p.Name,
+			Location:           loc,
+			RequestContentType: requestContentType,
 		})
 	}
 	for _, p := range endpoint.Body {
 		bindings = append(bindings, mcpParamBinding{
-			PublicName: p.PublicInputName(),
-			WireName:   p.Name,
-			Location:   "body",
+			PublicName:         p.PublicInputName(),
+			WireName:           p.Name,
+			Location:           "body",
+			Format:             multipartBindingFormat(endpoint, p),
+			RequestContentType: requestContentType,
 		})
 	}
 	return bindings
 }
 
+func multipartBindingFormat(endpoint spec.Endpoint, p spec.Param) string {
+	if !endpointUsesMultipart(endpoint) {
+		return ""
+	}
+	return p.Format
+}
+
 func mcpInputName(p spec.Param) string {
 	return p.PublicInputName()
 }
@@ -3129,6 +3150,73 @@ func bodyMap(body []spec.Param, indent string) string {
 	return b.String()
 }
 
+func multipartBodyMaps(body []spec.Param, indent string) string {
+	var b strings.Builder
+	for _, p := range body {
+		id := paramIdent(p)
+		ident := toCamel(id)
+		flag := publicFlagName(p)
+		if isComplexMultipartField(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[%q] = body%s\n", indent, p.Name, ident)
+			fmt.Fprintf(&b, "%s}\n", indent)
+			continue
+		}
+		if isBinaryParam(p) {
+			fmt.Fprintf(&b, "%sif body%s != \"\" {\n", indent, ident)
+			fmt.Fprintf(&b, "%s\tfileFields[%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[%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[%q] = fmt.Sprintf(\"%%v\", body%s)\n", indent, p.Name, ident)
+		fmt.Fprintf(&b, "%s}\n", indent)
+	}
+	return b.String()
+}
+
+func endpointUsesMultipart(endpoint spec.Endpoint) bool {
+	return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "multipart/form-data")
+}
+
+func hasMultipartRequest(apiSpec *spec.APISpec) bool {
+	if apiSpec == nil {
+		return false
+	}
+	var walk func(resources map[string]spec.Resource) bool
+	walk = func(resources map[string]spec.Resource) bool {
+		for _, resource := range resources {
+			for _, endpoint := range resource.Endpoints {
+				if endpointUsesMultipart(endpoint) {
+					return true
+				}
+			}
+			if walk(resource.SubResources) {
+				return true
+			}
+		}
+		return false
+	}
+	return walk(apiSpec.Resources)
+}
+
+func isBinaryParam(p spec.Param) bool {
+	return strings.EqualFold(strings.TrimSpace(p.Format), "binary")
+}
+
+func isComplexMultipartField(p spec.Param) bool {
+	return p.Type == "object" || p.Type == "array"
+}
+
 func isJSONStringParam(p spec.Param) bool {
 	if p.Type != "string" {
 		return false
diff --git a/internal/generator/multipart_test.go b/internal/generator/multipart_test.go
new file mode 100644
index 00000000..d0544fb1
--- /dev/null
+++ b/internal/generator/multipart_test.go
@@ -0,0 +1,89 @@
+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"
+)
+
+func TestGenerateMultipartRequestBodyUsesMultipartClient(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("uploadapi")
+	apiSpec.Resources = map[string]spec.Resource{
+		"assets": {
+			Description: "Manage assets",
+			Endpoints: map[string]spec.Endpoint{
+				"list": {
+					Method:      "GET",
+					Path:        "/assets",
+					Description: "List assets",
+				},
+				"upload": {
+					Method:             "POST",
+					Path:               "/assets",
+					Description:        "Upload an asset",
+					RequestContentType: "multipart/form-data",
+					Body: []spec.Param{
+						{Name: "assetData", Type: "string", Format: "binary", Required: true, Description: "Asset file"},
+						{Name: "filename", Type: "string", Required: true, Description: "File name"},
+						{Name: "metadata", Type: "object", Description: "Metadata as JSON"},
+					},
+				},
+			},
+		},
+		"avatars": {
+			Description: "Manage avatars",
+			Endpoints: map[string]spec.Endpoint{
+				"upload": {
+					Method:             "POST",
+					Path:               "/avatars",
+					Description:        "Upload an avatar",
+					RequestContentType: "multipart/form-data",
+					Body: []spec.Param{
+						{Name: "image", Type: "string", Format: "binary", Required: true, Description: "Avatar image"},
+						{Name: "label", Type: "string", Description: "Label"},
+					},
+				},
+			},
+		},
+	}
+
+	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) PostMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error)`)
+	assert.Contains(t, clientSrc, `writer.CreateFormFile(fieldName, filepath.Base(filePath))`)
+	assert.Contains(t, clientSrc, `req.Header.Set("Content-Type", contentType)`)
+
+	endpointSrc := readGeneratedFile(t, outputDir, "internal", "cli", "assets_upload.go")
+	assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "asset-data")`)
+	assert.Contains(t, endpointSrc, `return fmt.Errorf("required flag \"%s\" not set", "filename")`)
+	assert.Contains(t, endpointSrc, `fileFields["assetData"] = bodyAssetData`)
+	assert.Contains(t, endpointSrc, `fields["filename"] = bodyFilename`)
+	assert.Contains(t, endpointSrc, `fields["metadata"] = bodyMetadata`)
+	assert.Contains(t, endpointSrc, `c.PostMultipart(path, fields, fileFields)`)
+	assert.NotContains(t, endpointSrc, `"stdin"`)
+
+	promotedSrc := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_avatars.go")
+	assert.Contains(t, promotedSrc, `return fmt.Errorf("required flag \"%s\" not set", "image")`)
+	assert.Contains(t, promotedSrc, `fileFields["image"] = bodyImage`)
+	assert.Contains(t, promotedSrc, `fields["label"] = bodyLabel`)
+	assert.Contains(t, promotedSrc, `c.PostMultipart(path, fields, fileFields)`)
+	assert.NotContains(t, promotedSrc, `"stdin"`)
+
+	mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+	assert.Contains(t, mcpSrc, `makeAPIHandler("POST", "/assets", []mcpParamBinding`)
+	assert.Contains(t, mcpSrc, `Format: "binary"`)
+	assert.Contains(t, mcpSrc, `RequestContentType: "multipart/form-data"`)
+	assert.Contains(t, mcpSrc, `multipartFileFields[binding.WireName] = fmt.Sprintf("%v", v)`)
+	assert.Contains(t, mcpSrc, `data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)`)
+
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+}
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 79d40acd..f3f06812 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -14,6 +14,9 @@ import (
 	"fmt"
 	"io"
 	"math"
+{{- if .HasMultipartRequest}}
+	"mime/multipart"
+{{- end}}
 	"net/http"
 {{- if .HasAuthCommand}}
 	"net/url"
@@ -451,6 +454,17 @@ func (c *Client) PostWithHeaders(path string, body any, headers map[string]strin
 	return c.do("POST", path, nil, body, headers)
 }
 
+{{- if .HasMultipartRequest }}
+func (c *Client) PostMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+	return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PostMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+{{ end }}
+
 func (c *Client) Delete(path string) (json.RawMessage, int, error) {
 	return c.do("DELETE", path, nil, nil, nil)
 }
@@ -467,6 +481,17 @@ func (c *Client) PutWithHeaders(path string, body any, headers map[string]string
 	return c.do("PUT", path, nil, body, headers)
 }
 
+{{- if .HasMultipartRequest }}
+func (c *Client) PutMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+	return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PutMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+{{ end }}
+
 func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
 	return c.do("PATCH", path, nil, body, nil)
 }
@@ -475,6 +500,59 @@ func (c *Client) PatchWithHeaders(path string, body any, headers map[string]stri
 	return c.do("PATCH", path, nil, body, headers)
 }
 
+{{- if .HasMultipartRequest }}
+func (c *Client) PatchMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+	return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PatchMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+	return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+type multipartRequestBody struct {
+	Fields     map[string]string
+	FileFields map[string]string
+}
+
+func encodeMultipartBody(body multipartRequestBody) ([]byte, string, error) {
+	var buf bytes.Buffer
+	writer := multipart.NewWriter(&buf)
+	for fieldName, value := range body.Fields {
+		if err := writer.WriteField(fieldName, value); err != nil {
+			_ = writer.Close()
+			return nil, "", fmt.Errorf("writing multipart field %q: %w", fieldName, err)
+		}
+	}
+	for fieldName, filePath := range body.FileFields {
+		file, err := os.Open(filePath)
+		if err != nil {
+			_ = writer.Close()
+			return nil, "", fmt.Errorf("opening multipart file field %q (%q): %w", fieldName, filePath, err)
+		}
+		part, err := writer.CreateFormFile(fieldName, filepath.Base(filePath))
+		if err != nil {
+			_ = file.Close()
+			_ = writer.Close()
+			return nil, "", fmt.Errorf("creating multipart file field %q (%q): %w", fieldName, filePath, err)
+		}
+		if _, err := io.Copy(part, file); err != nil {
+			_ = file.Close()
+			_ = writer.Close()
+			return nil, "", fmt.Errorf("copying multipart file field %q (%q): %w", fieldName, filePath, err)
+		}
+		if err := file.Close(); err != nil {
+			_ = writer.Close()
+			return nil, "", fmt.Errorf("closing multipart file field %q (%q): %w", fieldName, filePath, err)
+		}
+	}
+	if err := writer.Close(); err != nil {
+		return nil, "", fmt.Errorf("finalizing multipart body: %w", err)
+	}
+	return buf.Bytes(), writer.FormDataContentType(), 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) {
@@ -543,12 +621,31 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
 {{- end}}
 
 	var bodyBytes []byte
+{{- if .HasMultipartRequest}}
+	var contentType string
+{{- end}}
 	if body != nil {
+{{- if .HasMultipartRequest}}
+		if multipartBody, ok := body.(multipartRequestBody); ok {
+			b, ct, err := encodeMultipartBody(multipartBody)
+			if err != nil {
+				return nil, 0, err
+			}
+			bodyBytes = b
+			contentType = ct
+		} else {
+{{- end}}
 		b, err := json.Marshal(body)
 		if err != nil {
 			return nil, 0, fmt.Errorf("marshaling body: %w", err)
 		}
 		bodyBytes = b
+{{- if .HasMultipartRequest}}
+		contentType = "application/json"
+{{- end}}
+{{- if .HasMultipartRequest}}
+		}
+{{- end}}
 	}
 {{- if .HasGraphQLPersistedQueries}}
 	bodyBytes = c.applyPersistedQueryBodyOverrides(bodyBytes)
@@ -643,7 +740,11 @@ 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}}
+			req.Header.Set("Content-Type", contentType)
+{{- else}}
 			req.Header.Set("Content-Type", "application/json")
+{{- end}}
 		}
 
 		if params != nil {
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 27e5ccb5..8fe455fc 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -1,6 +1,7 @@
 // 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.
 {{- $isGraphQLEndpoint := and (isGraphQL .APISpec) (eq .Endpoint.Path "/graphql") (or (eq .EndpointName "list") (eq .EndpointName "get"))}}
+{{- $isMultipart := endpointUsesMultipart .Endpoint}}
 
 package cli
 
@@ -10,7 +11,7 @@ import (
 {{- end}}
 	"encoding/json"
 	"fmt"
-{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
 	"io"
 {{- end}}
 	"os"
@@ -36,7 +37,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- if .Endpoint.Pagination}}
 	var flagAll bool
 {{- end}}
-{{- if or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
 	var stdinBody bool
 {{- end}}
 {{- if .IsAsync}}
@@ -102,6 +103,15 @@ 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}}
+{{- range .Endpoint.Body}}
+{{- if and .Required (not .Default)}}
+			if !{{flagChangedExpr .}} && !flags.dryRun {
+				return fmt.Errorf("required flag \"%s\" not set", "{{publicFlagName .}}")
+			}
+{{- end}}
+{{- end}}
+{{- else}}
 			if !stdinBody {
 {{- range .Endpoint.Body}}
 {{- if and .Required (not .Default)}}
@@ -111,6 +121,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 {{- end}}
 			}
+{{- end}}
 {{- end}}
 			c, err := flags.newClient()
 			if err != nil {
@@ -252,6 +263,16 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- end}}
 {{- end}}
 {{- else if eq .Endpoint.Method "POST"}}
+{{- if $isMultipart}}
+			fields := map[string]string{}
+			fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+			data, statusCode, err := c.PostMultipartWithHeaders(path, fields, fileFields, headerOverrides)
+{{- else}}
+			data, statusCode, err := c.PostMultipart(path, fields, fileFields)
+{{- end}}
+{{- else}}
 			var body map[string]any
 			if stdinBody {
 				stdinData, err := io.ReadAll(os.Stdin)
@@ -271,6 +292,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- else}}
 			data, statusCode, err := c.Post(path, body)
 {{- end}}
+{{- end}}
 {{- else if eq .Endpoint.Method "DELETE"}}
 {{- if .Endpoint.HeaderOverrides}}
 			data, statusCode, err := c.DeleteWithHeaders(path, headerOverrides)
@@ -278,6 +300,16 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			data, statusCode, err := c.Delete(path)
 {{- end}}
 {{- else if eq .Endpoint.Method "PUT"}}
+{{- if $isMultipart}}
+			fields := map[string]string{}
+			fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+			data, statusCode, err := c.PutMultipartWithHeaders(path, fields, fileFields, headerOverrides)
+{{- else}}
+			data, statusCode, err := c.PutMultipart(path, fields, fileFields)
+{{- end}}
+{{- else}}
 			var body map[string]any
 			if stdinBody {
 				stdinData, err := io.ReadAll(os.Stdin)
@@ -297,7 +329,18 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 {{- else}}
 			data, statusCode, err := c.Put(path, body)
 {{- end}}
+{{- end}}
 {{- else if eq .Endpoint.Method "PATCH"}}
+{{- if $isMultipart}}
+			fields := map[string]string{}
+			fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if .Endpoint.HeaderOverrides}}
+			data, statusCode, err := c.PatchMultipartWithHeaders(path, fields, fileFields, headerOverrides)
+{{- else}}
+			data, statusCode, err := c.PatchMultipart(path, fields, fileFields)
+{{- end}}
+{{- else}}
 			var body map[string]any
 			if stdinBody {
 				stdinData, err := io.ReadAll(os.Stdin)
@@ -318,6 +361,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
 			data, statusCode, err := c.Patch(path, body)
 {{- end}}
 {{- end}}
+{{- end}}
 {{- if eq .Endpoint.Method "DELETE"}}
 			if err != nil {
 				return classifyDeleteError(err, flags)
@@ -522,7 +566,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 or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")}}
+{{- if and (or (eq .Endpoint.Method "POST") (eq .Endpoint.Method "PUT") (eq .Endpoint.Method "PATCH")) (not $isMultipart)}}
 	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 3a9bab1e..1aebe721 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -1,5 +1,6 @@
 // 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}}
 
 package cli
 
@@ -174,12 +175,19 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
 {{- else if eq (upper .Endpoint.Method) "DELETE"}}
 			data, _, err := c.Delete(path)
 {{- else if or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")}}
+{{- if $isMultipart}}
+			fields := map[string]string{}
+			fileFields := map[string]string{}
+{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+			data, _, err := c.{{pascal (lower .Endpoint.Method)}}Multipart(path, fields, fileFields)
+{{- else}}
 			// HasStore + non-GET falls through to a live API call here
 			// rather than through resolveRead (GET-only internally); a
 			// 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)
+{{- end}}
 {{- else}}
 			// HEAD/OPTIONS and unknown verbs fall back to GET so generation
 			// stays compileable. Spec authors who genuinely need these verbs
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 831f8f2f..1e68eecd 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -1,6 +1,7 @@
 // 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.
 {{- $canonicalEnvVar := .Auth.CanonicalEnvVar}}
+{{- $hasMultipartRequest := hasMultipartRequest .APISpec}}
 
 package mcp
 
@@ -73,9 +74,9 @@ func RegisterTools(s *server.MCPServer) {
 {{- end}}
 		),
 {{- if $.HasTierRouting}}
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 {{- else}}
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveEndpointPath $resource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 {{- end}}
 	)
 {{- end}}
@@ -117,9 +118,9 @@ func RegisterTools(s *server.MCPServer) {
 {{- end}}
 		),
 {{- if $.HasTierRouting}}
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 {{- else}}
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, []mcpParamBinding{ {{- range mcpParamBindings $endpoint (effectiveSubEndpointPath $resource $subResource $endpoint)}}{PublicName: {{printf "%q" .PublicName}}, WireName: {{printf "%q" .WireName}}, Location: {{printf "%q" .Location}}{{if .Format}}, Format: {{printf "%q" .Format}}{{end}}{{if .RequestContentType}}, RequestContentType: {{printf "%q" .RequestContentType}}{{end}}},{{end}} }, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 {{- end}}
 	)
 {{- end}}
@@ -179,7 +180,23 @@ type mcpParamBinding struct {
 	PublicName string
 	WireName   string
 	Location   string
+{{- if $hasMultipartRequest}}
+	Format             string
+	RequestContentType string
+{{- end}}
+}
+
+{{- if $hasMultipartRequest}}
+func mcpMultipartFieldValue(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}}
@@ -209,8 +226,18 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 		pathParams := make(map[string]bool, len(positionalParams))
 		params := make(map[string]string)
 		bodyArgs := make(map[string]any)
+{{- if $hasMultipartRequest}}
+		multipartFields := make(map[string]string)
+		multipartFileFields := make(map[string]string)
+		multipart := false
+{{- end}}
 		for _, binding := range bindings {
 			knownArgs[binding.PublicName] = true
+{{- if $hasMultipartRequest}}
+			if strings.EqualFold(binding.RequestContentType, "multipart/form-data") {
+				multipart = true
+			}
+{{- end}}
 			v, ok := args[binding.PublicName]
 			if !ok {
 				continue
@@ -222,6 +249,15 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 				path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
 			case "body":
 				bodyArgs[binding.WireName] = v
+{{- if $hasMultipartRequest}}
+				if multipart {
+					if strings.EqualFold(binding.Format, "binary") {
+						multipartFileFields[binding.WireName] = fmt.Sprintf("%v", v)
+					} else {
+						multipartFields[binding.WireName] = mcpMultipartFieldValue(v)
+					}
+				}
+{{- end}}
 			default:
 				params[binding.WireName] = fmt.Sprintf("%v", v)
 			}
@@ -244,6 +280,11 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 			switch method {
 			case "POST", "PUT", "PATCH":
 				bodyArgs[k] = v
+{{- if $hasMultipartRequest}}
+				if multipart {
+					multipartFields[k] = mcpMultipartFieldValue(v)
+				}
+{{- end}}
 			default:
 				params[k] = fmt.Sprintf("%v", v)
 			}
@@ -254,12 +295,30 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
 		case "GET":
 			data, err = c.Get(path, params)
 		case "POST":
+{{- if $hasMultipartRequest}}
+			if multipart {
+				data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)
+				break
+			}
+{{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Post(path, body)
 		case "PUT":
+{{- if $hasMultipartRequest}}
+			if multipart {
+				data, _, err = c.PutMultipart(path, multipartFields, multipartFileFields)
+				break
+			}
+{{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Put(path, body)
 		case "PATCH":
+{{- if $hasMultipartRequest}}
+			if multipart {
+				data, _, err = c.PatchMultipart(path, multipartFields, multipartFileFields)
+				break
+			}
+{{- end}}
 			body, _ := json.Marshal(bodyArgs)
 			data, _, err = c.Patch(path, body)
 		case "DELETE":
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index e7f75398..87759170 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1651,7 +1651,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			}
 
 			params := mapParameters(pathItem, op)
-			body := mapRequestBody(op.RequestBody, method, path)
+			body, requestContentType := mapRequestBody(op.RequestBody, method, path)
 
 			// Deduplicate body params that collide with query/path params by flag name
 			if len(body) > 0 && len(params) > 0 {
@@ -1669,12 +1669,13 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			}
 
 			endpoint := spec.Endpoint{
-				Method:      strings.ToUpper(method),
-				Path:        path,
-				BaseURL:     operationServerBaseURL(out.BaseURL, pathItem, op),
-				Description: description,
-				Params:      params,
-				Body:        body,
+				Method:             strings.ToUpper(method),
+				Path:               path,
+				BaseURL:            operationServerBaseURL(out.BaseURL, pathItem, op),
+				Description:        description,
+				Params:             params,
+				Body:               body,
+				RequestContentType: requestContentType,
 			}
 			endpoint.Tier = readTierExtension(op.Extensions, fmt.Sprintf("%s %q", strings.ToUpper(method), path))
 			if endpoint.Tier == "" {
@@ -2376,29 +2377,26 @@ func mergeParameters(pathItem *openapi3.PathItem, op *openapi3.Operation) []*ope
 	return merged
 }
 
-func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) []spec.Param {
+func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string) ([]spec.Param, string) {
 	requestBody := requestBodyValue(requestBodyRef)
 	if requestBody == nil || requestBody.Content == nil {
-		return nil
+		return nil, ""
 	}
 
-	media := requestBody.Content.Get("application/json")
-	if media == nil {
-		media = firstJSONMediaType(requestBody.Content)
-	}
+	requestContentType, media := requestBodyMediaType(requestBody.Content)
 	if media == nil || media.Schema == nil || media.Schema.Value == nil {
-		return nil
+		return nil, ""
 	}
 
 	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
+		return nil, ""
 	}
 
 	if len(properties) == 0 {
-		return nil
+		return nil, ""
 	}
 
 	names := make([]string, 0, len(properties))
@@ -2449,7 +2447,36 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
 		body = append(body, param)
 	}
 
-	return body
+	return body, requestContentType
+}
+
+func requestBodyMediaType(content openapi3.Content) (string, *openapi3.MediaType) {
+	if content == nil {
+		return "", nil
+	}
+	if media := content.Get("application/json"); media != nil {
+		return "application/json", media
+	}
+
+	contentTypes := sortedContentTypes(content)
+	for _, contentType := range contentTypes {
+		if strings.Contains(strings.ToLower(contentType), "json") {
+			return contentType, content[contentType]
+		}
+	}
+	for _, contentType := range contentTypes {
+		if strings.EqualFold(contentType, "multipart/form-data") {
+			return contentType, content[contentType]
+		}
+	}
+	for _, contentType := range contentTypes {
+		media := content[contentType]
+		if media != nil && media.Schema != nil {
+			return contentType, media
+		}
+	}
+
+	return "", nil
 }
 
 func bodyParamSchema(schema *openapi3.Schema) *openapi3.Schema {
@@ -3262,31 +3289,13 @@ func refComponentName(ref string) string {
 	return ref[i+1:]
 }
 
-func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
-	if content == nil {
-		return nil
-	}
-
+func sortedContentTypes(content openapi3.Content) []string {
 	contentTypes := make([]string, 0, len(content))
 	for contentType := range content {
 		contentTypes = append(contentTypes, contentType)
 	}
 	sort.Strings(contentTypes)
-
-	for _, contentType := range contentTypes {
-		if strings.Contains(strings.ToLower(contentType), "json") {
-			return content[contentType]
-		}
-	}
-
-	for _, contentType := range contentTypes {
-		media := content[contentType]
-		if media != nil && media.Schema != nil {
-			return media
-		}
-	}
-
-	return nil
+	return contentTypes
 }
 
 func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (string, string) {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 3c43e5b0..1fe4eaba 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -4363,6 +4363,55 @@ paths:
 	assert.Contains(t, err.Error(), "transport")
 }
 
+func TestParseMultipartRequestBodyPreservesContentType(t *testing.T) {
+	t.Parallel()
+	data := []byte(`
+openapi: 3.0.3
+info:
+  title: Upload API
+  version: 1.0.0
+servers:
+  - url: https://api.example.com
+paths:
+  /assets:
+    post:
+      operationId: uploadAsset
+      summary: Upload asset
+      requestBody:
+        required: true
+        content:
+          multipart/form-data:
+            schema:
+              type: object
+              required: [assetData, filename]
+              properties:
+                assetData:
+                  type: string
+                  format: binary
+                  description: Asset file
+                filename:
+                  type: string
+                  description: File name
+      responses:
+        "201":
+          description: created
+`)
+
+	parsed, err := Parse(data)
+	require.NoError(t, err)
+
+	endpoint := findParsedEndpointByPath(t, parsed, "POST", "/assets")
+	assert.Equal(t, "multipart/form-data", endpoint.RequestContentType)
+	require.Len(t, endpoint.Body, 2)
+	byName := map[string]spec.Param{}
+	for _, param := range endpoint.Body {
+		byName[param.Name] = param
+	}
+	assert.Equal(t, "binary", byName["assetData"].Format)
+	assert.True(t, byName["assetData"].Required)
+	assert.True(t, byName["filename"].Required)
+}
+
 func findParsedEndpointByPath(t *testing.T, parsed *spec.APISpec, method, path string) spec.Endpoint {
 	t.Helper()
 	for _, resource := range parsed.Resources {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 8e0cd6ae..4cf8b3a9 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -916,21 +916,22 @@ 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"`
-	Response        ResponseDef       `yaml:"response" json:"response"`
-	ResponseFormat  string            `yaml:"response_format,omitempty" json:"response_format,omitempty"` // json (default) or html
-	HTMLExtract     *HTMLExtract      `yaml:"html_extract,omitempty" json:"html_extract,omitempty"`       // extraction options when response_format is html
-	Pagination      *Pagination       `yaml:"pagination" json:"pagination"`
-	ResponsePath    string            `yaml:"response_path,omitempty" json:"response_path,omitempty"`       // path to extract data array from response (e.g., "data", "results.items")
-	Meta            map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"`                         // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
-	HeaderOverrides []RequiredHeader  `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
-	NoAuth          bool              `yaml:"no_auth,omitempty" json:"no_auth,omitempty"`                   // true when the endpoint does not require authentication
-	Tier            string            `yaml:"tier,omitempty" json:"tier,omitempty"`
+	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"`
+	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
+	HTMLExtract        *HTMLExtract      `yaml:"html_extract,omitempty" json:"html_extract,omitempty"`       // extraction options when response_format is html
+	Pagination         *Pagination       `yaml:"pagination" json:"pagination"`
+	ResponsePath       string            `yaml:"response_path,omitempty" json:"response_path,omitempty"`       // path to extract data array from response (e.g., "data", "results.items")
+	Meta               map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"`                         // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
+	HeaderOverrides    []RequiredHeader  `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
+	NoAuth             bool              `yaml:"no_auth,omitempty" json:"no_auth,omitempty"`                   // true when the endpoint does not require authentication
+	Tier               string            `yaml:"tier,omitempty" json:"tier,omitempty"`
 	// IDField is the resolved primary-key field name for items returned by this
 	// endpoint, populated either by a path-item-level `x-resource-id` extension
 	// or, for OpenAPI specs, by walking the response schema (id → name → first

← 8cf5459c fix(cli): block vendor-prefix secrets during publish (#852)  ·  back to Cli Printing Press  ·  fix(cli): handle empty sync pages (#903) d94d89dc →