← back to Cli Printing Press
fix(cli): handle binary-only response endpoints (Accept + base64 envelope) (#1574)
d8178077ede31c6873f63ac2ae192edeb0e18c97 · 2026-05-20 00:23:56 +0200 · andreasvainio
* fix(cli): handle binary-only response endpoints (Accept + base64 envelope)
Generated CLIs hardcoded `Accept: application/json` in the HTTP client
when no per-endpoint override was set, and ran every response through
the JSON sanitizer before returning it as `json.RawMessage`. Endpoints
whose only 2xx content type is non-JSON (e.g. `application/octet-stream`
PDF/file downloads) were therefore rejected by the server with HTTP 406,
and even with the right Accept the bytes would be mangled by the
sanitizer. This is a generic generator gap: it hits any spec with a
binary-only success response.
Fix, using existing plumbing with minimal surface:
- openapi parser: detect success responses whose media types are all
non-JSON / non-`*/*` / non-`+json` and pin `Accept` to that type via
the existing per-endpoint header-override path. New `upsertHeaderOverride`
(and a merge in `applyHeaderOverrides`) keeps the Accept override stable
when configured per-endpoint headers also apply. JSON and `*/*`
endpoints are untouched.
- client.go.tmpl: content-type-gated base64 envelope
(`{"_pp_binary":true,...,"data":"<b64>"}`) for genuinely binary
success bodies so they survive the json.RawMessage contract. JSON,
`*/*`, XML and every text/* type (incl. text/html, so
response_format:html CLIs are unaffected) pass through unchanged. The
error path is byte-identical (still sanitized + truncated).
- command_promoted.go.tmpl: promoted (top-level) GET commands now pass
per-endpoint header overrides, matching typed commands. Without this
the Accept override never reached the client for promoted commands.
Golden: extended testdata/golden/fixtures/golden-api.yaml with one
binary-only endpoint per docs/GOLDEN.md (general machine behavior),
froze its generated command file, and regenerated affected expected
fixtures. All 17 golden cases pass; every diff is attributable to the
envelope path or the one added endpoint.
Verification: go build ./..., go vet ./... (changed pkgs), go test ./...
(0 failures), 5 new parser tests, scripts/golden.sh verify (17/17).
Live-verified against a real binary endpoint: `Accept: application/json`
reproduces HTTP 406; `Accept: application/octet-stream` returns the
file (200, correct content-type).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): thread HeaderOverrides through MCP code-orchestration execute
The original binary-response fix covered typed CLI commands and promoted
commands, but the code-orchestration MCP path (<api>_execute) builds
requests from a slim endpoint table and called c.Get/Post/... directly,
so the per-endpoint Accept override never reached binary-only endpoints
— <api>_execute on a PDF/octet-stream endpoint still 406'd while the CLI
worked.
Add HeaderOverrides to codeOrchEndpoint, emit it in the generated
registry from endpoint.HeaderOverrides, and dispatch through
c.*WithHeaders when present. The client's content-type-gated base64
envelope (already in this branch) then handles the binary body.
Golden: generate-mcp-api/code_orch.go updated (struct field + WithHeaders
dispatch; table emission inert when no endpoint has overrides).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): thread header overrides through typed MCP tools
* fix(cli): avoid text response Accept overrides
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
Files touched
M internal/generator/binary_paginated_promoted_test.goM internal/generator/generator_test.goM internal/generator/multipart_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/mcp_code_orch.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/openapi/parser.goA internal/openapi/parser_binary_response_test.goM testdata/golden/cases/generate-golden-api/artifacts.txtM testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.goM testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.goM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.jsonA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/reports_export_report-year.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.goM testdata/golden/expected/generate-golden-api/scorecard.jsonM testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.goM testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.goM testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.goM testdata/golden/fixtures/golden-api.yaml
Diff
commit d8178077ede31c6873f63ac2ae192edeb0e18c97
Author: andreasvainio <vainio@panther.no>
Date: Wed May 20 00:23:56 2026 +0200
fix(cli): handle binary-only response endpoints (Accept + base64 envelope) (#1574)
* fix(cli): handle binary-only response endpoints (Accept + base64 envelope)
Generated CLIs hardcoded `Accept: application/json` in the HTTP client
when no per-endpoint override was set, and ran every response through
the JSON sanitizer before returning it as `json.RawMessage`. Endpoints
whose only 2xx content type is non-JSON (e.g. `application/octet-stream`
PDF/file downloads) were therefore rejected by the server with HTTP 406,
and even with the right Accept the bytes would be mangled by the
sanitizer. This is a generic generator gap: it hits any spec with a
binary-only success response.
Fix, using existing plumbing with minimal surface:
- openapi parser: detect success responses whose media types are all
non-JSON / non-`*/*` / non-`+json` and pin `Accept` to that type via
the existing per-endpoint header-override path. New `upsertHeaderOverride`
(and a merge in `applyHeaderOverrides`) keeps the Accept override stable
when configured per-endpoint headers also apply. JSON and `*/*`
endpoints are untouched.
- client.go.tmpl: content-type-gated base64 envelope
(`{"_pp_binary":true,...,"data":"<b64>"}`) for genuinely binary
success bodies so they survive the json.RawMessage contract. JSON,
`*/*`, XML and every text/* type (incl. text/html, so
response_format:html CLIs are unaffected) pass through unchanged. The
error path is byte-identical (still sanitized + truncated).
- command_promoted.go.tmpl: promoted (top-level) GET commands now pass
per-endpoint header overrides, matching typed commands. Without this
the Accept override never reached the client for promoted commands.
Golden: extended testdata/golden/fixtures/golden-api.yaml with one
binary-only endpoint per docs/GOLDEN.md (general machine behavior),
froze its generated command file, and regenerated affected expected
fixtures. All 17 golden cases pass; every diff is attributable to the
envelope path or the one added endpoint.
Verification: go build ./..., go vet ./... (changed pkgs), go test ./...
(0 failures), 5 new parser tests, scripts/golden.sh verify (17/17).
Live-verified against a real binary endpoint: `Accept: application/json`
reproduces HTTP 406; `Accept: application/octet-stream` returns the
file (200, correct content-type).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): thread HeaderOverrides through MCP code-orchestration execute
The original binary-response fix covered typed CLI commands and promoted
commands, but the code-orchestration MCP path (<api>_execute) builds
requests from a slim endpoint table and called c.Get/Post/... directly,
so the per-endpoint Accept override never reached binary-only endpoints
— <api>_execute on a PDF/octet-stream endpoint still 406'd while the CLI
worked.
Add HeaderOverrides to codeOrchEndpoint, emit it in the generated
registry from endpoint.HeaderOverrides, and dispatch through
c.*WithHeaders when present. The client's content-type-gated base64
envelope (already in this branch) then handles the binary body.
Golden: generate-mcp-api/code_orch.go updated (struct field + WithHeaders
dispatch; table emission inert when no endpoint has overrides).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cli): thread header overrides through typed MCP tools
* fix(cli): avoid text response Accept overrides
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Trevin Chow <trevin@trevinchow.com>
---
.../generator/binary_paginated_promoted_test.go | 45 ++++-
internal/generator/generator_test.go | 6 +-
internal/generator/multipart_test.go | 2 +-
internal/generator/templates/client.go.tmpl | 78 +++++++-
.../generator/templates/command_promoted.go.tmpl | 28 ++-
internal/generator/templates/mcp_code_orch.go.tmpl | 44 ++++-
internal/generator/templates/mcp_tools.go.tmpl | 51 ++---
internal/openapi/parser.go | 86 ++++++++-
internal/openapi/parser_binary_response_test.go | 207 +++++++++++++++++++++
.../golden/cases/generate-golden-api/artifacts.txt | 1 +
.../internal/client/client.go | 78 +++++++-
.../printing-press-rich-auth/internal/mcp/tools.go | 25 ++-
.../expected/generate-golden-api/dogfood.json | 8 +-
.../printing-press-golden/.printing-press.json | 4 +-
.../internal/cli/reports_export_report-year.go | 59 ++++++
.../printing-press-golden/internal/cli/sync.go | 2 +
.../printing-press-golden/internal/cli/which.go | 1 +
.../internal/client/client.go | 78 +++++++-
.../printing-press-golden/internal/mcp/tools.go | 59 ++++--
.../expected/generate-golden-api/scorecard.json | 2 +-
.../mcp-cloudflare/internal/mcp/code_orch.go | 38 +++-
.../mcp-cloudflare/internal/mcp/tools.go | 23 ++-
.../public-param-golden/internal/mcp/tools.go | 27 ++-
.../tier-routing-golden/internal/client/client.go | 78 +++++++-
.../tier-routing-golden/internal/mcp/tools.go | 29 ++-
testdata/golden/fixtures/golden-api.yaml | 20 ++
26 files changed, 951 insertions(+), 128 deletions(-)
diff --git a/internal/generator/binary_paginated_promoted_test.go b/internal/generator/binary_paginated_promoted_test.go
index 1028e4d1..b307480f 100644
--- a/internal/generator/binary_paginated_promoted_test.go
+++ b/internal/generator/binary_paginated_promoted_test.go
@@ -55,8 +55,10 @@ func TestGenerateBinaryPaginatedPromotedThreadsHeader(t *testing.T) {
require.NoError(t, gen.Generate())
endpointSrc := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_voices.go")
- assert.Contains(t, endpointSrc, `headerOverrides := map[string]string{"X-Printing-Press-Binary-Response": "true"}`,
+ assert.Contains(t, endpointSrc, `headerOverrides := map[string]string{`,
"binary paginated promoted must declare headerOverrides")
+ assert.Contains(t, endpointSrc, `"X-Printing-Press-Binary-Response": "true",`,
+ "binary paginated promoted must include the binary sentinel")
assert.Contains(t, endpointSrc, `paginatedGet(c, path, map[string]string{`,
"non-HasStore pagination must use paginatedGet")
assert.NotContains(t, endpointSrc, `}, nil, flagAll,`,
@@ -94,8 +96,47 @@ func TestGenerateBinaryStoreBackedPromotedThreadsHeader(t *testing.T) {
require.NoError(t, gen.Generate())
endpointSrc := readGeneratedFile(t, outputDir, "internal", "cli", "promoted_voices.go")
- assert.Contains(t, endpointSrc, `headerOverrides := map[string]string{"X-Printing-Press-Binary-Response": "true"}`,
+ assert.Contains(t, endpointSrc, `headerOverrides := map[string]string{`,
"store-backed binary GET must declare headerOverrides")
+ assert.Contains(t, endpointSrc, `"X-Printing-Press-Binary-Response": "true",`,
+ "store-backed binary GET must include the binary sentinel")
assert.Contains(t, endpointSrc, `resolveRead(cmd.Context(), c, flags, "voices", false, path, params, headerOverrides)`,
"store-backed binary GET must thread headerOverrides through resolveRead")
}
+
+func TestGenerateBinaryMCPToolsThreadHeaderOverrides(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("audioapi")
+ apiSpec.Resources = map[string]spec.Resource{
+ "voices": {
+ Description: "Voices",
+ Endpoints: map[string]spec.Endpoint{
+ "download": {
+ Method: "GET",
+ Path: "/voices/{voice_id}/download",
+ Description: "Download voice sample",
+ ResponseFormat: spec.ResponseFormatBinary,
+ HeaderOverrides: []spec.RequiredHeader{
+ {Name: "Accept", Value: "application/octet-stream"},
+ },
+ Params: []spec.Param{
+ {Name: "voice_id", Type: "string", Required: true, Positional: true, PathParam: true, Description: "Voice ID"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
+ assert.Contains(t, mcpSrc, `makeAPIHandler("GET", "/voices/{voice_id}/download", true, map[string]string{"Accept": "application/octet-stream"},`,
+ "typed MCP tools must carry per-endpoint header overrides")
+ assert.Contains(t, mcpSrc, `headers[client.BinaryResponseHeader] = "true"`,
+ "typed MCP tools must still add the binary sentinel")
+ assert.Contains(t, mcpSrc, `data, err = c.GetWithHeaders(path, params, headers)`,
+ "typed MCP tools must dispatch through WithHeaders when headers are present")
+}
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 37330043..a3d85a88 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -9389,7 +9389,7 @@ func TestGenerateOperationRoutingPathParamDefault(t *testing.T) {
mcpGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
require.NoError(t, err)
assert.Regexp(t,
- regexp.MustCompile(`makeAPIHandler\("GET",\s*"/graphql/\{pathQueryId\}/Followers",\s*false,\s*\[]mcpParamBinding\{.*WireName: "pathQueryId".*\},\s*\[]string\{[^}]*"pathQueryId"`),
+ regexp.MustCompile(`makeAPIHandler\("GET",\s*"/graphql/\{pathQueryId\}/Followers",\s*false,\s*nil,\s*\[]mcpParamBinding\{.*WireName: "pathQueryId".*\},\s*\[]string\{[^}]*"pathQueryId"`),
string(mcpGo),
"MCP handler must receive the routing path param so it can substitute the URL")
}
@@ -10899,11 +10899,11 @@ func TestGenerateMCPHandlerPreservesQueryPositionals(t *testing.T) {
// Call sites still pass both names — the upstream emit is unchanged;
// the fix lives entirely inside the handler body.
assert.Regexp(t,
- regexp.MustCompile(`makeAPIHandler\("GET",\s*"/search/movie",\s*false,\s*\[]mcpParamBinding\{.*WireName: "query".*\},\s*\[]string\{[^}]*"query"`),
+ regexp.MustCompile(`makeAPIHandler\("GET",\s*"/search/movie",\s*false,\s*nil,\s*\[]mcpParamBinding\{.*WireName: "query".*\},\s*\[]string\{[^}]*"query"`),
tools,
"search call site must still pass `query` in positionalParams (handler decides path vs query at runtime)")
assert.Regexp(t,
- regexp.MustCompile(`makeAPIHandler\("GET",\s*"/movie/\{movieId\}",\s*false,\s*\[]mcpParamBinding\{.*WireName: "movieId".*\},\s*\[]string\{[^}]*"movieId"`),
+ regexp.MustCompile(`makeAPIHandler\("GET",\s*"/movie/\{movieId\}",\s*false,\s*nil,\s*\[]mcpParamBinding\{.*WireName: "movieId".*\},\s*\[]string\{[^}]*"movieId"`),
tools,
"get-by-id call site must pass `movieId` in positionalParams")
diff --git a/internal/generator/multipart_test.go b/internal/generator/multipart_test.go
index 833d1cf9..435f78da 100644
--- a/internal/generator/multipart_test.go
+++ b/internal/generator/multipart_test.go
@@ -78,7 +78,7 @@ func TestGenerateMultipartRequestBodyUsesMultipartClient(t *testing.T) {
assert.NotContains(t, promotedSrc, `"stdin"`)
mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
- assert.Contains(t, mcpSrc, `makeAPIHandler("POST", "/assets", false, []mcpParamBinding`)
+ assert.Contains(t, mcpSrc, `makeAPIHandler("POST", "/assets", false, nil, []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)`)
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 5c909ca8..e630e732 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -9,6 +9,7 @@ import (
"crypto/tls"
{{- end}}
"crypto/sha256"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -1179,9 +1180,6 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if err != nil {
return nil, 0, fmt.Errorf("reading response: %w", err)
}
- if !binaryResponse {
- respBody = sanitizeJSONResponse(respBody)
- }
// Success
if resp.StatusCode < 400 {
@@ -1189,7 +1187,22 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if method != http.MethodGet && !c.DryRun {
c.invalidateCache()
}
- return json.RawMessage(respBody), resp.StatusCode, nil
+ // Non-textual bodies (PDF, zip, image, octet-stream) must not be
+ // run through the JSON sanitizer or returned as raw json.RawMessage
+ // — return a self-describing base64 envelope instead. Textual and
+ // JSON responses fall through to the unchanged path.
+ if isBinaryResponseContentType(resp.Header.Get("Content-Type")) {
+ env, encErr := wrapBinaryResponse(resp.Header.Get("Content-Type"), respBody)
+ if encErr != nil {
+ return nil, 0, encErr
+ }
+ return env, resp.StatusCode, nil
+ }
+ return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
+ }
+
+ if !binaryResponse {
+ respBody = sanitizeJSONResponse(respBody)
}
apiErr := &APIError{
@@ -1714,6 +1727,63 @@ func normalizeBasePath(p string) string {
}
{{end -}}
+// binaryResponseEnvelope wraps a non-textual success body so it survives the
+// json.RawMessage contract every consumer (CLI output, --json, MCP tools)
+// depends on. Without it, raw bytes (PDF, zip, image) are corrupted by
+// sanitizeJSONResponse and emitted as invalid JSON. The _pp_binary
+// discriminator lets callers and agents detect and base64-decode the payload.
+type binaryResponseEnvelope struct {
+ PPBinary bool `json:"_pp_binary"`
+ ContentType string `json:"content_type"`
+ Encoding string `json:"encoding"`
+ Bytes int `json:"bytes"`
+ Data string `json:"data"`
+}
+
+// isBinaryResponseContentType reports whether a successful response with this
+// Content-Type must be base64-wrapped instead of treated as text/JSON. It is
+// deliberately narrow: JSON, */*, XML, and every text/* type (including
+// text/html, so response_format:html CLIs are untouched) pass through
+// unchanged. Only genuinely binary payloads are wrapped.
+func isBinaryResponseContentType(ct string) bool {
+ mt := strings.ToLower(strings.TrimSpace(ct))
+ if i := strings.IndexByte(mt, ';'); i >= 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ if mt == "" {
+ return false
+ }
+ switch {
+ case mt == "application/json", mt == "text/json", mt == "*/*":
+ return false
+ case strings.HasPrefix(mt, "text/"):
+ return false
+ case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
+ return false
+ case mt == "application/xml", mt == "application/xhtml+xml":
+ return false
+ case mt == "application/javascript", mt == "application/ecmascript",
+ mt == "application/x-www-form-urlencoded", mt == "application/graphql":
+ return false
+ }
+ return true
+}
+
+// wrapBinaryResponse marshals body into a self-describing base64 envelope.
+func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
+ out, err := json.Marshal(binaryResponseEnvelope{
+ PPBinary: true,
+ ContentType: ct,
+ Encoding: "base64",
+ Bytes: len(body),
+ Data: base64.StdEncoding.EncodeToString(body),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("encoding binary response: %w", err)
+ }
+ return json.RawMessage(out), nil
+}
+
// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
// response bodies so that downstream JSON parsing succeeds. For clean JSON
// responses these checks are no-ops.
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 153bd68a..6ab59c74 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -131,9 +131,17 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- end}}
{{- end}}
+{{- if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}
+ headerOverrides := map[string]string{
+{{- range .Endpoint.HeaderOverrides}}
+ "{{.Name}}": "{{.Value}}",
+{{- end}}
{{- if .Endpoint.UsesBinaryResponse}}
- headerOverrides := map[string]string{"X-Printing-Press-Binary-Response": "true"}
+ "X-Printing-Press-Binary-Response": "true",
+{{- end}}
+ }
{{- end}}
+
{{- if .Endpoint.Pagination}}
{{- if .HasStore}}
data, prov, err := resolvePaginatedRead(cmd.Context(), c, flags, "{{lower .ResourceName}}", path, map[string]string{
@@ -145,7 +153,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
"{{paramWireName .}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
{{- end}}
{{- end}}
- }, {{if .Endpoint.UsesBinaryResponse}}headerOverrides{{else}}nil{{end}}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
+ }, {{if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}headerOverrides{{else}}nil{{end}}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
{{- else}}
data, err := paginatedGet(c, path, map[string]string{
{{- range .Endpoint.Params}}
@@ -156,7 +164,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
"{{paramWireName .}}": fmt.Sprintf("%v", flag{{camel (paramIdent .)}}),
{{- end}}
{{- end}}
- }, {{if .Endpoint.UsesBinaryResponse}}headerOverrides{{else}}nil{{end}}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
+ }, {{if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}headerOverrides{{else}}nil{{end}}, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
{{- end}}
{{- else}}
params := map[string]string{}
@@ -171,15 +179,15 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- end}}
{{- end}}
{{- if and .HasStore (eq (upper .Endpoint.Method) "GET")}}
- data, prov, err := resolveRead(cmd.Context(), c, flags, "{{lower .ResourceName}}", {{if .Endpoint.Pagination}}true{{else}}false{{end}}, path, params, {{if .Endpoint.UsesBinaryResponse}}headerOverrides{{else}}nil{{end}})
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "{{lower .ResourceName}}", {{if .Endpoint.Pagination}}true{{else}}false{{end}}, path, params, {{if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}headerOverrides{{else}}nil{{end}})
{{- else if eq $method "GET"}}
-{{- if .Endpoint.UsesBinaryResponse}}
+{{- if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}
data, err := c.GetWithHeaders(path, params, headerOverrides)
{{- else}}
data, err := c.Get(path, params)
{{- end}}
{{- else if eq (upper .Endpoint.Method) "DELETE"}}
-{{- if .Endpoint.UsesBinaryResponse}}
+{{- if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}
data, _, err := c.DeleteWithParamsAndHeaders(path, params, headerOverrides)
{{- else}}
data, _, err := c.DeleteWithParams(path, params)
@@ -189,7 +197,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
fields := map[string]string{}
fileFields := map[string]string{}
{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
-{{- if .Endpoint.UsesBinaryResponse}}
+{{- if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}MultipartWithParamsAndHeaders(path, params, fields, fileFields, headerOverrides)
{{- else}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}MultipartWithParams(path, params, fields, fileFields)
@@ -197,7 +205,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- else if $isForm}}
fields := url.Values{}
{{formBodyMaps .Endpoint.Body "\t\t\t"}}
-{{- if .Endpoint.UsesBinaryResponse}}
+{{- if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}FormWithParamsAndHeaders(path, params, fields, headerOverrides)
{{- else}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}FormWithParams(path, params, fields)
@@ -208,7 +216,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{}
-{{bodyMapForEndpoint .Endpoint "\t\t\t"}}{{if .Endpoint.UsesBinaryResponse}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}WithParamsAndHeaders(path, params, body, headerOverrides)
+{{bodyMapForEndpoint .Endpoint "\t\t\t"}}{{if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}WithParamsAndHeaders(path, params, body, headerOverrides)
{{else}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}WithParams(path, params, body)
{{end}}
{{- end}}
@@ -216,7 +224,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
// HEAD/OPTIONS and unknown verbs fall back to GET so generation
// stays compileable. Spec authors who genuinely need these verbs
// should add a typed client method and a dedicated template branch.
-{{- if .Endpoint.UsesBinaryResponse}}
+{{- if or .Endpoint.HeaderOverrides .Endpoint.UsesBinaryResponse}}
data, err := c.GetWithHeaders(path, params, headerOverrides)
{{- else}}
data, err := c.Get(path, params)
diff --git a/internal/generator/templates/mcp_code_orch.go.tmpl b/internal/generator/templates/mcp_code_orch.go.tmpl
index d6444ba1..dc1b70e5 100644
--- a/internal/generator/templates/mcp_code_orch.go.tmpl
+++ b/internal/generator/templates/mcp_code_orch.go.tmpl
@@ -59,7 +59,12 @@ type codeOrchEndpoint struct {
Tier string
Summary string
Positional []string
- keywords []string
+ // HeaderOverrides carries per-endpoint request headers (e.g. an
+ // Accept override for binary-only response endpoints). Without
+ // threading these through, the code-orchestration execute path
+ // sends the client's default Accept and binary endpoints 406.
+ HeaderOverrides map[string]string
+ keywords []string
}
// codeOrchEndpoints is the generator-populated registry covering every
@@ -78,6 +83,9 @@ var codeOrchEndpoints = []codeOrchEndpoint{
{{- end}}
Summary: {{printf "%q" (oneline $endpoint.Description)}},
Positional: []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} },
+{{- if $endpoint.HeaderOverrides}}
+ HeaderOverrides: map[string]string{ {{- range $endpoint.HeaderOverrides}}"{{.Name}}": "{{.Value}}",{{end}} },
+{{- end}}
keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $path}}),
},
{{- end}}
@@ -93,6 +101,9 @@ var codeOrchEndpoints = []codeOrchEndpoint{
{{- end}}
Summary: {{printf "%q" (oneline $endpoint.Description)}},
Positional: []string{ {{- range $endpoint.Params}}{{if .Positional}}"{{.Name}}",{{end}}{{end}} },
+{{- if $endpoint.HeaderOverrides}}
+ HeaderOverrides: map[string]string{ {{- range $endpoint.HeaderOverrides}}"{{.Name}}": "{{.Value}}",{{end}} },
+{{- end}}
keywords: codeOrchKeywords({{printf "%q" $name}}, {{printf "%q" $eName}}, {{printf "%q" (oneline $endpoint.Description)}}, {{printf "%q" $path}}),
},
{{- end}}
@@ -243,18 +254,39 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
}
}
+ hdrs := ep.HeaderOverrides
var data json.RawMessage
switch ep.Method {
case "GET":
- data, err = c.Get(path, query)
+ if len(hdrs) > 0 {
+ data, err = c.GetWithHeaders(path, query, hdrs)
+ } else {
+ data, err = c.Get(path, query)
+ }
case "DELETE":
- data, _, err = c.DeleteWithParams(path, query)
+ if len(hdrs) > 0 {
+ data, _, err = c.DeleteWithParamsAndHeaders(path, query, hdrs)
+ } else {
+ data, _, err = c.DeleteWithParams(path, query)
+ }
case "POST":
- data, _, err = c.Post(path, params)
+ if len(hdrs) > 0 {
+ data, _, err = c.PostWithHeaders(path, params, hdrs)
+ } else {
+ data, _, err = c.Post(path, params)
+ }
case "PUT":
- data, _, err = c.Put(path, params)
+ if len(hdrs) > 0 {
+ data, _, err = c.PutWithHeaders(path, params, hdrs)
+ } else {
+ data, _, err = c.Put(path, params)
+ }
case "PATCH":
- data, _, err = c.Patch(path, params)
+ if len(hdrs) > 0 {
+ data, _, err = c.PatchWithHeaders(path, params, hdrs)
+ } else {
+ data, _, err = c.Patch(path, params)
+ }
default:
return mcplib.NewToolResultError(fmt.Sprintf("unsupported method %q", ep.Method)), nil
}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index c97aea10..2d38143b 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -71,9 +71,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)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, []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}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{printf "%q" (effectiveTier $.APISpec $resource $endpoint)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, {{if $endpoint.HeaderOverrides}}map[string]string{ {{- range $endpoint.HeaderOverrides}}{{printf "%q" .Name}}: {{printf "%q" .Value}},{{end}} }{{else}}nil{{end}}, []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)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, []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}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveEndpointPath $resource $endpoint)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, {{if $endpoint.HeaderOverrides}}map[string]string{ {{- range $endpoint.HeaderOverrides}}{{printf "%q" .Name}}: {{printf "%q" .Value}},{{end}} }{{else}}nil{{end}}, []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}}
@@ -106,9 +106,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)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, []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}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{printf "%q" (effectiveSubTier $.APISpec $resource $subResource $endpoint)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, {{if $endpoint.HeaderOverrides}}map[string]string{ {{- range $endpoint.HeaderOverrides}}{{printf "%q" .Name}}: {{printf "%q" .Value}},{{end}} }{{else}}nil{{end}}, []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)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, []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}} }),
+ makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" (effectiveSubEndpointPath $resource $subResource $endpoint)}}, {{if $endpoint.UsesBinaryResponse}}true{{else}}false{{end}}, {{if $endpoint.HeaderOverrides}}map[string]string{ {{- range $endpoint.HeaderOverrides}}{{printf "%q" .Name}}: {{printf "%q" .Value}},{{end}} }{{else}}nil{{end}}, []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}}
@@ -200,9 +200,9 @@ func mcpFormFieldValue(v any) string {
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
{{- if .HasTierRouting}}
-func makeAPIHandler(method, pathTemplate, tier string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate, tier string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
{{- else}}
-func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
{{- end}}
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
@@ -227,8 +227,17 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
params := make(map[string]string)
bodyArgs := make(map[string]any)
var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
if binaryResponse {
- headers = map[string]string{client.BinaryResponseHeader: "true"}
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
}
{{- if $hasMultipartRequest}}
multipartFields := make(map[string]string)
@@ -335,7 +344,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
var data json.RawMessage
switch method {
case "GET":
- if binaryResponse {
+ if len(headers) > 0 {
data, err = c.GetWithHeaders(path, params, headers)
break
}
@@ -343,7 +352,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
case "POST":
{{- if $hasMultipartRequest}}
if multipart {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostMultipartWithParamsAndHeaders(path, params, multipartFields, multipartFileFields, headers)
break
}
@@ -353,7 +362,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
{{- end}}
{{- if $hasFormRequest}}
if formEncoded {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostFormWithParamsAndHeaders(path, params, formFields, headers)
break
}
@@ -363,7 +372,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
{{- end}}
{{- if $hasBodyJSONFallback}}
if len(bodyJSONOverride) > 0 {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyJSONOverride, headers)
break
}
@@ -371,7 +380,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
break
}
{{- end}}
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
@@ -379,7 +388,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
case "PUT":
{{- if $hasMultipartRequest}}
if multipart {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutMultipartWithParamsAndHeaders(path, params, multipartFields, multipartFileFields, headers)
break
}
@@ -389,7 +398,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
{{- end}}
{{- if $hasFormRequest}}
if formEncoded {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutFormWithParamsAndHeaders(path, params, formFields, headers)
break
}
@@ -399,7 +408,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
{{- end}}
{{- if $hasBodyJSONFallback}}
if len(bodyJSONOverride) > 0 {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyJSONOverride, headers)
break
}
@@ -407,7 +416,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
break
}
{{- end}}
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
@@ -415,7 +424,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
case "PATCH":
{{- if $hasMultipartRequest}}
if multipart {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchMultipartWithParamsAndHeaders(path, params, multipartFields, multipartFileFields, headers)
break
}
@@ -425,7 +434,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
{{- end}}
{{- if $hasFormRequest}}
if formEncoded {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchFormWithParamsAndHeaders(path, params, formFields, headers)
break
}
@@ -435,7 +444,7 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
{{- end}}
{{- if $hasBodyJSONFallback}}
if len(bodyJSONOverride) > 0 {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyJSONOverride, headers)
break
}
@@ -443,13 +452,13 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
break
}
{{- end}}
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PatchWithParams(path, params, bodyArgs)
case "DELETE":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.DeleteWithParamsAndHeaders(path, params, headers)
break
}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 0192be38..e1b76da6 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1571,7 +1571,9 @@ func applyHeaderOverrides(s *spec.APISpec, perEndpoint map[string]map[string]str
for eName, e := range r.Endpoints {
overrides := headerOverridesForPath(e.Path, perEndpoint)
if len(overrides) > 0 {
- e.HeaderOverrides = overrides
+ for _, o := range overrides {
+ e.HeaderOverrides = upsertHeaderOverride(e.HeaderOverrides, o.Name, o.Value)
+ }
r.Endpoints[eName] = e
}
}
@@ -1579,7 +1581,9 @@ func applyHeaderOverrides(s *spec.APISpec, perEndpoint map[string]map[string]str
for eName, e := range sub.Endpoints {
overrides := headerOverridesForPath(e.Path, perEndpoint)
if len(overrides) > 0 {
- e.HeaderOverrides = overrides
+ for _, o := range overrides {
+ e.HeaderOverrides = upsertHeaderOverride(e.HeaderOverrides, o.Name, o.Value)
+ }
sub.Endpoints[eName] = e
}
}
@@ -2209,6 +2213,14 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
endpoint.Critical = pathCritical
endpoint.Walker = readWalkerExtension(op.Extensions, fmt.Sprintf("%s %q", strings.ToUpper(method), path))
+ // Binary-only success responses (e.g. PDF/octet-stream downloads)
+ // would otherwise receive the default Accept: application/json and
+ // be rejected with HTTP 406. Pin Accept to what the server
+ // produces; rides the existing per-endpoint header-override path.
+ if accept := binaryResponseAcceptType(op); accept != "" {
+ endpoint.HeaderOverrides = upsertHeaderOverride(endpoint.HeaderOverrides, "Accept", accept)
+ }
+
targetEndpoints[endpointName] = endpoint
// Update descriptions
@@ -3429,6 +3441,76 @@ func binaryContentType(contentType string) bool {
}
}
+// binaryResponseAcceptType inspects the operation's selected success response.
+// When every declared media type is concrete and binary-enveloped by the
+// generated client, the server answers the client's default Accept:
+// application/json with HTTP 406. It returns the media type the client must
+// send instead (application/octet-stream when the response offers it, otherwise
+// the lexicographically-first concrete type). Returns "" for JSON, wildcard,
+// text, XML, empty, or mixed responses so the existing application/json default
+// and non-binary response path stay untouched — the vast majority of endpoints.
+func binaryResponseAcceptType(op *openapi3.Operation) string {
+ if op == nil || op.Responses == nil {
+ return ""
+ }
+ success := selectSuccessResponse(op.Responses)
+ if success == nil || success.Value == nil || len(success.Value.Content) == 0 {
+ return ""
+ }
+ concrete := make([]string, 0, len(success.Value.Content))
+ for ct := range success.Value.Content {
+ mt := strings.ToLower(strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]))
+ if !binaryAcceptContentType(mt) {
+ return ""
+ }
+ concrete = append(concrete, mt)
+ }
+ if len(concrete) == 0 {
+ return ""
+ }
+ sort.Strings(concrete)
+ for _, mt := range concrete {
+ if mt == "application/octet-stream" {
+ return mt
+ }
+ }
+ return concrete[0]
+}
+
+func binaryAcceptContentType(mt string) bool {
+ if mt == "" {
+ return false
+ }
+ switch {
+ case mt == "application/json", mt == "text/json", mt == "*/*":
+ return false
+ case strings.HasPrefix(mt, "text/"):
+ return false
+ case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
+ return false
+ case mt == "application/xml", mt == "application/xhtml+xml":
+ return false
+ case mt == "application/javascript", mt == "application/ecmascript",
+ mt == "application/x-www-form-urlencoded", mt == "application/graphql":
+ return false
+ }
+ return true
+}
+
+// upsertHeaderOverride returns headers with name set to value: replacing an
+// existing case-insensitive match in place, or appending a new entry. Keeps a
+// binary-response Accept override stable when a later pass (applyHeaderOverrides)
+// merges per-endpoint configured headers onto the same endpoint.
+func upsertHeaderOverride(headers []spec.RequiredHeader, name, value string) []spec.RequiredHeader {
+ for i := range headers {
+ if strings.EqualFold(headers[i].Name, name) {
+ headers[i].Value = value
+ return headers
+ }
+ }
+ return append(headers, spec.RequiredHeader{Name: name, Value: value})
+}
+
// readPathItemResourceID reads the `x-resource-id` extension from a path item
// and returns the resolved field name. Accepts only string values; non-string
// values (numbers, booleans, malformed YAML) emit a warning and return "".
diff --git a/internal/openapi/parser_binary_response_test.go b/internal/openapi/parser_binary_response_test.go
new file mode 100644
index 00000000..47ca2f94
--- /dev/null
+++ b/internal/openapi/parser_binary_response_test.go
@@ -0,0 +1,207 @@
+package openapi
+
+import (
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/spec"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// binaryResponseSpec exercises the four shapes that matter for binary-only
+// response detection: JSON-only (no override), octet-stream-only (override),
+// pdf-only (override to the concrete type), text/XML-only (no override because
+// the client does not binary-wrap them), and a mixed octet-stream+JSON response
+// (no override — JSON is reachable with the default Accept).
+const binaryResponseSpec = `
+openapi: 3.0.0
+info:
+ title: binresp
+ version: "1"
+paths:
+ /widgets:
+ get:
+ operationId: listWidgets
+ responses:
+ "200":
+ description: ok
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ type: object
+ properties:
+ id: { type: string }
+ /widgets/{id}/content:
+ get:
+ operationId: downloadWidgetContent
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: byte
+ /widgets/{id}/report:
+ get:
+ operationId: widgetReport
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/pdf:
+ schema:
+ type: string
+ format: byte
+ /widgets/{id}/export:
+ get:
+ operationId: exportWidget
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/octet-stream:
+ schema: { type: string, format: byte }
+ application/json:
+ schema: { type: object }
+ /widgets/{id}/csv:
+ get:
+ operationId: exportWidgetCSV
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: ok
+ content:
+ text/csv:
+ schema: { type: string }
+ /widgets/{id}/xml:
+ get:
+ operationId: exportWidgetXML
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ responses:
+ "200":
+ description: ok
+ content:
+ application/xml:
+ schema: { type: string }
+`
+
+func acceptOverride(e spec.Endpoint) (string, bool) {
+ for _, h := range e.HeaderOverrides {
+ if h.Name == "Accept" {
+ return h.Value, true
+ }
+ }
+ return "", false
+}
+
+func endpointByPath(s *spec.APISpec, path string) (spec.Endpoint, bool) {
+ for _, r := range s.Resources {
+ for _, e := range r.Endpoints {
+ if e.Path == path {
+ return e, true
+ }
+ }
+ for _, sub := range r.SubResources {
+ for _, e := range sub.Endpoints {
+ if e.Path == path {
+ return e, true
+ }
+ }
+ }
+ }
+ return spec.Endpoint{}, false
+}
+
+func TestParseBinaryOnlyResponseEmitsAcceptOverride(t *testing.T) {
+ t.Parallel()
+
+ parsed, err := Parse([]byte(binaryResponseSpec))
+ require.NoError(t, err)
+
+ t.Run("octet-stream-only gets Accept override", func(t *testing.T) {
+ e, ok := endpointByPath(parsed, "/widgets/{id}/content")
+ require.True(t, ok, "expected /widgets/{id}/content endpoint")
+ v, has := acceptOverride(e)
+ require.True(t, has, "binary-only endpoint must carry an Accept header override")
+ assert.Equal(t, "application/octet-stream", v)
+ })
+
+ t.Run("non-octet binary type is pinned to the concrete type", func(t *testing.T) {
+ e, ok := endpointByPath(parsed, "/widgets/{id}/report")
+ require.True(t, ok)
+ v, has := acceptOverride(e)
+ require.True(t, has)
+ assert.Equal(t, "application/pdf", v)
+ })
+
+ t.Run("JSON-only endpoint gets no Accept override", func(t *testing.T) {
+ e, ok := endpointByPath(parsed, "/widgets")
+ require.True(t, ok)
+ _, has := acceptOverride(e)
+ assert.False(t, has, "JSON endpoints must keep the default application/json Accept")
+ })
+
+ t.Run("mixed octet-stream+JSON keeps the JSON default", func(t *testing.T) {
+ e, ok := endpointByPath(parsed, "/widgets/{id}/export")
+ require.True(t, ok)
+ _, has := acceptOverride(e)
+ assert.False(t, has, "a JSON-reachable response must not be forced to octet-stream")
+ })
+
+ t.Run("text response gets no Accept override", func(t *testing.T) {
+ e, ok := endpointByPath(parsed, "/widgets/{id}/csv")
+ require.True(t, ok)
+ _, has := acceptOverride(e)
+ assert.False(t, has, "text responses must not be forced into the binary response path")
+ })
+
+ t.Run("XML response gets no Accept override", func(t *testing.T) {
+ e, ok := endpointByPath(parsed, "/widgets/{id}/xml")
+ require.True(t, ok)
+ _, has := acceptOverride(e)
+ assert.False(t, has, "XML responses must not be forced into the binary response path")
+ })
+}
+
+func TestUpsertHeaderOverride(t *testing.T) {
+ t.Parallel()
+
+ base := []spec.RequiredHeader{{Name: "X-Api-Version", Value: "2"}}
+
+ appended := upsertHeaderOverride(base, "Accept", "application/octet-stream")
+ assert.Len(t, appended, 2)
+ v, ok := acceptOverride(spec.Endpoint{HeaderOverrides: appended})
+ assert.True(t, ok)
+ assert.Equal(t, "application/octet-stream", v)
+
+ replaced := upsertHeaderOverride(appended, "accept", "application/pdf")
+ assert.Len(t, replaced, 2, "case-insensitive name match must replace, not append")
+ v, _ = acceptOverride(spec.Endpoint{HeaderOverrides: replaced})
+ assert.Equal(t, "application/pdf", v)
+}
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index 80aa4946..e47fce8a 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -14,6 +14,7 @@ printing-press-golden/internal/cli/projects_tasks_list-project.go
printing-press-golden/internal/cli/projects_tasks_update-project.go
printing-press-golden/internal/cli/projects_avatar.go
printing-press-golden/internal/cli/projects_avatar_upload-project.go
+printing-press-golden/internal/cli/reports_export_report-year.go
printing-press-golden/internal/cli/promoted_public.go
printing-press-golden/internal/cliutil/text.go
printing-press-golden/internal/cliutil/ratelimit.go
diff --git a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
index 3884fc83..fee0d978 100644
--- a/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api-oauth2-cc/printing-press-oauth2-cc/internal/client/client.go
@@ -6,6 +6,7 @@ package client
import (
"bytes"
"crypto/sha256"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -458,9 +459,6 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if err != nil {
return nil, 0, fmt.Errorf("reading response: %w", err)
}
- if !binaryResponse {
- respBody = sanitizeJSONResponse(respBody)
- }
// Success
if resp.StatusCode < 400 {
@@ -468,7 +466,22 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if method != http.MethodGet && !c.DryRun {
c.invalidateCache()
}
- return json.RawMessage(respBody), resp.StatusCode, nil
+ // Non-textual bodies (PDF, zip, image, octet-stream) must not be
+ // run through the JSON sanitizer or returned as raw json.RawMessage
+ // — return a self-describing base64 envelope instead. Textual and
+ // JSON responses fall through to the unchanged path.
+ if isBinaryResponseContentType(resp.Header.Get("Content-Type")) {
+ env, encErr := wrapBinaryResponse(resp.Header.Get("Content-Type"), respBody)
+ if encErr != nil {
+ return nil, 0, encErr
+ }
+ return env, resp.StatusCode, nil
+ }
+ return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
+ }
+
+ if !binaryResponse {
+ respBody = sanitizeJSONResponse(respBody)
}
apiErr := &APIError{
@@ -721,6 +734,63 @@ func (c *Client) refreshAccessToken() error {
return nil
}
+// binaryResponseEnvelope wraps a non-textual success body so it survives the
+// json.RawMessage contract every consumer (CLI output, --json, MCP tools)
+// depends on. Without it, raw bytes (PDF, zip, image) are corrupted by
+// sanitizeJSONResponse and emitted as invalid JSON. The _pp_binary
+// discriminator lets callers and agents detect and base64-decode the payload.
+type binaryResponseEnvelope struct {
+ PPBinary bool `json:"_pp_binary"`
+ ContentType string `json:"content_type"`
+ Encoding string `json:"encoding"`
+ Bytes int `json:"bytes"`
+ Data string `json:"data"`
+}
+
+// isBinaryResponseContentType reports whether a successful response with this
+// Content-Type must be base64-wrapped instead of treated as text/JSON. It is
+// deliberately narrow: JSON, */*, XML, and every text/* type (including
+// text/html, so response_format:html CLIs are untouched) pass through
+// unchanged. Only genuinely binary payloads are wrapped.
+func isBinaryResponseContentType(ct string) bool {
+ mt := strings.ToLower(strings.TrimSpace(ct))
+ if i := strings.IndexByte(mt, ';'); i >= 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ if mt == "" {
+ return false
+ }
+ switch {
+ case mt == "application/json", mt == "text/json", mt == "*/*":
+ return false
+ case strings.HasPrefix(mt, "text/"):
+ return false
+ case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
+ return false
+ case mt == "application/xml", mt == "application/xhtml+xml":
+ return false
+ case mt == "application/javascript", mt == "application/ecmascript",
+ mt == "application/x-www-form-urlencoded", mt == "application/graphql":
+ return false
+ }
+ return true
+}
+
+// wrapBinaryResponse marshals body into a self-describing base64 envelope.
+func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
+ out, err := json.Marshal(binaryResponseEnvelope{
+ PPBinary: true,
+ ContentType: ct,
+ Encoding: "base64",
+ Bytes: len(body),
+ Data: base64.StdEncoding.EncodeToString(body),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("encoding binary response: %w", err)
+ }
+ return json.RawMessage(out), nil
+}
+
// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
// response bodies so that downstream JSON parsing succeeds. For clean JSON
// responses these checks are no-ops.
diff --git a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
index efe0916a..0d67d533 100644
--- a/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-golden-api-rich-auth/printing-press-rich-auth/internal/mcp/tools.go
@@ -32,7 +32,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items", false, []mcpParamBinding{}, []string{}),
+ makeAPIHandler("GET", "/items", false, nil, []mcpParamBinding{}, []string{}),
)
// SQL tool — ad-hoc analysis on synced data without API calls
s.AddTool(
@@ -68,7 +68,7 @@ type mcpParamBinding struct {
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -89,8 +89,17 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
params := make(map[string]string)
bodyArgs := make(map[string]any)
var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
if binaryResponse {
- headers = map[string]string{client.BinaryResponseHeader: "true"}
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
}
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
@@ -135,31 +144,31 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
var data json.RawMessage
switch method {
case "GET":
- if binaryResponse {
+ if len(headers) > 0 {
data, err = c.GetWithHeaders(path, params, headers)
break
}
data, err = c.Get(path, params)
case "POST":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PostWithParams(path, params, bodyArgs)
case "PUT":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PutWithParams(path, params, bodyArgs)
case "PATCH":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PatchWithParams(path, params, bodyArgs)
case "DELETE":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.DeleteWithParamsAndHeaders(path, params, headers)
break
}
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 0f4c8cb0..cfa577e0 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -35,7 +35,7 @@
"state": "runtime_walking"
},
"naming_check": {
- "checked": 34
+ "checked": 36
},
"novel_features_check": {
"found": 0,
@@ -56,7 +56,7 @@
"sync_resources_present": true
},
"print_json_filtered_check": {
- "checked": 34
+ "checked": 36
},
"reimplementation_check": {
"checked": 0,
@@ -75,8 +75,8 @@
"verdict": "WARN",
"wiring_check": {
"command_tree": {
- "defined": 43,
- "registered": 43
+ "defined": 45,
+ "registered": 45
},
"config_consistency": {
"consistent": true
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json b/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json
index e37a040d..0c5fd65b 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json
@@ -11,14 +11,14 @@
"mcp_binary": "printing-press-golden-pp-mcp",
"mcp_public_tool_count": 1,
"mcp_ready": "full",
- "mcp_tool_count": 9,
+ "mcp_tool_count": 10,
"owner": "printing-press-golden",
"printer": "printing-press-golden",
"printer_name": "printing-press-golden",
"printing_press_version": "<PRINTING_PRESS_VERSION>",
"run_id": "<RUN_ID>",
"schema_version": 1,
- "spec_checksum": "sha256:2ea5c937580b3011c022fa9fcbbd19cba6be4a38a5303c66905cb9bb736991fb",
+ "spec_checksum": "sha256:edbb503a934acade9d2455b09e41c7750ec04985dbc0bb2e5611872736009565",
"spec_format": "openapi3",
"spec_path": "testdata/golden/fixtures/golden-api.yaml",
"spec_url": "file://testdata/golden/fixtures/golden-api.yaml"
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/reports_export_report-year.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/reports_export_report-year.go
new file mode 100644
index 00000000..0d03c458
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/reports_export_report-year.go
@@ -0,0 +1,59 @@
+// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/spf13/cobra"
+)
+
+func newReportsExportReportYearCmd(flags *rootFlags) *cobra.Command {
+ var flagYear int
+
+ cmd := &cobra.Command{
+ Use: "report-year",
+ Aliases: []string{"get"},
+ Short: "Download the annual report as a binary file",
+ Example: " printing-press-golden-pp-cli reports export report-year --year 42",
+ Annotations: map[string]string{"pp:endpoint": "export.report-year", "pp:method": "GET", "pp:path": "/reports/{year}/export", "mcp:read-only": "true"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if !cmd.Flags().Changed("year") && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "year")
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/reports/{year}/export"
+ path = replacePathParam(path, "year", fmt.Sprintf("%v", flagYear))
+ headerOverrides := map[string]string{
+ "Accept": "application/octet-stream",
+ "X-Printing-Press-Binary-Response": "true",
+ }
+ params := map[string]string{}
+ data, prov, err := resolveRead(cmd.Context(), c, flags, "export", false, path, params, headerOverrides)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ _ = json.Valid
+ _ = os.Stderr
+ _ = prov
+ if flags.quiet {
+ return nil
+ }
+ if flags.asJSON || flags.csv || flags.compact || flags.plain || flags.selectFields != "" {
+ return fmt.Errorf("binary response cannot be rendered as structured output; redirect stdout or use --deliver file:<path>")
+ }
+ _, err = cmd.OutOrStdout().Write(data)
+ return err
+ },
+ }
+ cmd.Flags().IntVar(&flagYear, "year", 0, "Year")
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index 33651807..a295f141 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -1022,6 +1022,8 @@ func upsertSingleObject(db *store.Store, resource string, data json.RawMessage)
return db.UpsertAvatar(data)
case "tasks":
return db.UpsertTasks(data)
+ case "export":
+ return db.UpsertExport(data)
case "summary":
return db.UpsertSummary(data)
default:
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go
index 562f0a79..c55720e1 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/which.go
@@ -35,6 +35,7 @@ var whichIndex = []whichEntry{
{Command: "projects tasks list-project", Description: "List project tasks", Group: "projects"},
{Command: "projects tasks update-project", Description: "Update project task", Group: "projects"},
{Command: "public get-status", Description: "Get public service status", Group: "public"},
+ {Command: "reports export report-year", Description: "Download the annual report as a binary file", Group: "reports"},
{Command: "reports summary get-report-year", Description: "Get a report summary for a year", Group: "reports"},
}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
index 9d260005..21cb2f31 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/client/client.go
@@ -6,6 +6,7 @@ package client
import (
"bytes"
"crypto/sha256"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -551,9 +552,6 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if err != nil {
return nil, 0, fmt.Errorf("reading response: %w", err)
}
- if !binaryResponse {
- respBody = sanitizeJSONResponse(respBody)
- }
// Success
if resp.StatusCode < 400 {
@@ -561,7 +559,22 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if method != http.MethodGet && !c.DryRun {
c.invalidateCache()
}
- return json.RawMessage(respBody), resp.StatusCode, nil
+ // Non-textual bodies (PDF, zip, image, octet-stream) must not be
+ // run through the JSON sanitizer or returned as raw json.RawMessage
+ // — return a self-describing base64 envelope instead. Textual and
+ // JSON responses fall through to the unchanged path.
+ if isBinaryResponseContentType(resp.Header.Get("Content-Type")) {
+ env, encErr := wrapBinaryResponse(resp.Header.Get("Content-Type"), respBody)
+ if encErr != nil {
+ return nil, 0, encErr
+ }
+ return env, resp.StatusCode, nil
+ }
+ return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
+ }
+
+ if !binaryResponse {
+ respBody = sanitizeJSONResponse(respBody)
}
apiErr := &APIError{
@@ -651,6 +664,63 @@ func (c *Client) authHeader() (string, error) {
return c.Config.AuthHeader(), nil
}
+// binaryResponseEnvelope wraps a non-textual success body so it survives the
+// json.RawMessage contract every consumer (CLI output, --json, MCP tools)
+// depends on. Without it, raw bytes (PDF, zip, image) are corrupted by
+// sanitizeJSONResponse and emitted as invalid JSON. The _pp_binary
+// discriminator lets callers and agents detect and base64-decode the payload.
+type binaryResponseEnvelope struct {
+ PPBinary bool `json:"_pp_binary"`
+ ContentType string `json:"content_type"`
+ Encoding string `json:"encoding"`
+ Bytes int `json:"bytes"`
+ Data string `json:"data"`
+}
+
+// isBinaryResponseContentType reports whether a successful response with this
+// Content-Type must be base64-wrapped instead of treated as text/JSON. It is
+// deliberately narrow: JSON, */*, XML, and every text/* type (including
+// text/html, so response_format:html CLIs are untouched) pass through
+// unchanged. Only genuinely binary payloads are wrapped.
+func isBinaryResponseContentType(ct string) bool {
+ mt := strings.ToLower(strings.TrimSpace(ct))
+ if i := strings.IndexByte(mt, ';'); i >= 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ if mt == "" {
+ return false
+ }
+ switch {
+ case mt == "application/json", mt == "text/json", mt == "*/*":
+ return false
+ case strings.HasPrefix(mt, "text/"):
+ return false
+ case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
+ return false
+ case mt == "application/xml", mt == "application/xhtml+xml":
+ return false
+ case mt == "application/javascript", mt == "application/ecmascript",
+ mt == "application/x-www-form-urlencoded", mt == "application/graphql":
+ return false
+ }
+ return true
+}
+
+// wrapBinaryResponse marshals body into a self-describing base64 envelope.
+func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
+ out, err := json.Marshal(binaryResponseEnvelope{
+ PPBinary: true,
+ ContentType: ct,
+ Encoding: "base64",
+ Bytes: len(body),
+ Data: base64.StdEncoding.EncodeToString(body),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("encoding binary response: %w", err)
+ }
+ return json.RawMessage(out), nil
+}
+
// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
// response bodies so that downstream JSON parsing succeeds. For clean JSON
// responses these checks are no-ops.
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
index 615a7001..aa2007f4 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
@@ -32,7 +32,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/currencies", false, []mcpParamBinding{}, []string{}),
+ makeAPIHandler("GET", "/currencies", false, nil, []mcpParamBinding{}, []string{}),
)
s.AddTool(
mcplib.NewTool("projects_create",
@@ -43,7 +43,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("POST", "/projects", false, []mcpParamBinding{{PublicName: "name", WireName: "name", Location: "body"}, {PublicName: "owner_email", WireName: "owner_email", Location: "body"}, {PublicName: "visibility", WireName: "visibility", Location: "body"}}, []string{}),
+ makeAPIHandler("POST", "/projects", false, nil, []mcpParamBinding{{PublicName: "name", WireName: "name", Location: "body"}, {PublicName: "owner_email", WireName: "owner_email", Location: "body"}, {PublicName: "visibility", WireName: "visibility", Location: "body"}}, []string{}),
)
s.AddTool(
mcplib.NewTool("projects_get",
@@ -53,7 +53,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/projects/{projectId}", false, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}}, []string{"projectId"}),
+ makeAPIHandler("GET", "/projects/{projectId}", false, nil, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}}, []string{"projectId"}),
)
s.AddTool(
mcplib.NewTool("projects_list",
@@ -65,7 +65,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/projects", false, []mcpParamBinding{{PublicName: "status", WireName: "status", Location: "query"}, {PublicName: "limit", WireName: "limit", Location: "query"}, {PublicName: "cursor", WireName: "cursor", Location: "query"}}, []string{}),
+ makeAPIHandler("GET", "/projects", false, nil, []mcpParamBinding{{PublicName: "status", WireName: "status", Location: "query"}, {PublicName: "limit", WireName: "limit", Location: "query"}, {PublicName: "cursor", WireName: "cursor", Location: "query"}}, []string{}),
)
s.AddTool(
mcplib.NewTool("projects_avatar_upload-project",
@@ -76,7 +76,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithString("file", mcplib.Description("File")),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("PUT", "/projects/{projectId}/avatar", false, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path", RequestContentType: "multipart/form-data"}, {PublicName: "overwrite", WireName: "overwrite", Location: "query", RequestContentType: "multipart/form-data"}, {PublicName: "caption", WireName: "caption", Location: "body", RequestContentType: "multipart/form-data"}, {PublicName: "file", WireName: "file", Location: "body", Format: "binary", RequestContentType: "multipart/form-data"}}, []string{"projectId"}),
+ makeAPIHandler("PUT", "/projects/{projectId}/avatar", false, nil, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path", RequestContentType: "multipart/form-data"}, {PublicName: "overwrite", WireName: "overwrite", Location: "query", RequestContentType: "multipart/form-data"}, {PublicName: "caption", WireName: "caption", Location: "body", RequestContentType: "multipart/form-data"}, {PublicName: "file", WireName: "file", Location: "body", Format: "binary", RequestContentType: "multipart/form-data"}}, []string{"projectId"}),
)
s.AddTool(
mcplib.NewTool("projects_tasks_list-project",
@@ -89,7 +89,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/projects/{projectId}/tasks", false, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}, {PublicName: "priority", WireName: "priority", Location: "query"}, {PublicName: "limit", WireName: "limit", Location: "query"}, {PublicName: "cursor", WireName: "cursor", Location: "query"}}, []string{"projectId"}),
+ makeAPIHandler("GET", "/projects/{projectId}/tasks", false, nil, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}, {PublicName: "priority", WireName: "priority", Location: "query"}, {PublicName: "limit", WireName: "limit", Location: "query"}, {PublicName: "cursor", WireName: "cursor", Location: "query"}}, []string{"projectId"}),
)
s.AddTool(
mcplib.NewTool("projects_tasks_update-project",
@@ -102,7 +102,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithString("title", mcplib.Description("Title")),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("PATCH", "/projects/{projectId}/tasks/{taskId}", false, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}, {PublicName: "taskId", WireName: "taskId", Location: "path"}, {PublicName: "notify", WireName: "notify", Location: "query"}, {PublicName: "completed", WireName: "completed", Location: "body"}, {PublicName: "priority", WireName: "priority", Location: "body"}, {PublicName: "title", WireName: "title", Location: "body"}}, []string{"projectId", "taskId"}),
+ makeAPIHandler("PATCH", "/projects/{projectId}/tasks/{taskId}", false, nil, []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}, {PublicName: "taskId", WireName: "taskId", Location: "path"}, {PublicName: "notify", WireName: "notify", Location: "query"}, {PublicName: "completed", WireName: "completed", Location: "body"}, {PublicName: "priority", WireName: "priority", Location: "body"}, {PublicName: "title", WireName: "title", Location: "body"}}, []string{"projectId", "taskId"}),
)
s.AddTool(
mcplib.NewTool("public_get-status",
@@ -111,7 +111,17 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/public/status", false, []mcpParamBinding{}, []string{}),
+ makeAPIHandler("GET", "/public/status", false, nil, []mcpParamBinding{}, []string{}),
+ )
+ s.AddTool(
+ mcplib.NewTool("reports_export_report-year",
+ mcplib.WithDescription("Download the annual report as a binary file. Required: year."),
+ mcplib.WithNumber("year", mcplib.Required(), mcplib.Description("Year")),
+ mcplib.WithReadOnlyHintAnnotation(true),
+ mcplib.WithDestructiveHintAnnotation(false),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("GET", "/reports/{year}/export", true, map[string]string{"Accept": "application/octet-stream"}, []mcpParamBinding{{PublicName: "year", WireName: "year", Location: "path"}}, []string{"year"}),
)
s.AddTool(
mcplib.NewTool("reports_summary_get-report-year",
@@ -121,7 +131,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/reports/{year}/summary", false, []mcpParamBinding{{PublicName: "year", WireName: "year", Location: "path"}}, []string{"year"}),
+ makeAPIHandler("GET", "/reports/{year}/summary", false, nil, []mcpParamBinding{{PublicName: "year", WireName: "year", Location: "path"}}, []string{"year"}),
)
// Search tool — faster than iterating list endpoints for finding specific items
s.AddTool(
@@ -180,7 +190,7 @@ func mcpMultipartFieldValue(v any) string {
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -201,8 +211,17 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
params := make(map[string]string)
bodyArgs := make(map[string]any)
var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
if binaryResponse {
- headers = map[string]string{client.BinaryResponseHeader: "true"}
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
}
multipartFields := make(map[string]string)
multipartFileFields := make(map[string]string)
@@ -263,55 +282,55 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
var data json.RawMessage
switch method {
case "GET":
- if binaryResponse {
+ if len(headers) > 0 {
data, err = c.GetWithHeaders(path, params, headers)
break
}
data, err = c.Get(path, params)
case "POST":
if multipart {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostMultipartWithParamsAndHeaders(path, params, multipartFields, multipartFileFields, headers)
break
}
data, _, err = c.PostMultipartWithParams(path, params, multipartFields, multipartFileFields)
break
}
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PostWithParams(path, params, bodyArgs)
case "PUT":
if multipart {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutMultipartWithParamsAndHeaders(path, params, multipartFields, multipartFileFields, headers)
break
}
data, _, err = c.PutMultipartWithParams(path, params, multipartFields, multipartFileFields)
break
}
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PutWithParams(path, params, bodyArgs)
case "PATCH":
if multipart {
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchMultipartWithParamsAndHeaders(path, params, multipartFields, multipartFileFields, headers)
break
}
data, _, err = c.PatchMultipartWithParams(path, params, multipartFields, multipartFileFields)
break
}
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PatchWithParams(path, params, bodyArgs)
case "DELETE":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.DeleteWithParamsAndHeaders(path, params, headers)
break
}
@@ -533,7 +552,7 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
"api": "printing-press-golden",
"description": "Purpose-built fixture for golden generation coverage.",
"archetype": "project-management",
- "tool_count": 9,
+ "tool_count": 10,
// tool_surface tells agents which surface a capability lives on.
"tool_surface": "MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion printing-press-golden-pp-cli binary.",
"auth": map[string]any{
diff --git a/testdata/golden/expected/generate-golden-api/scorecard.json b/testdata/golden/expected/generate-golden-api/scorecard.json
index 94d88284..8a385895 100644
--- a/testdata/golden/expected/generate-golden-api/scorecard.json
+++ b/testdata/golden/expected/generate-golden-api/scorecard.json
@@ -3,7 +3,7 @@
"api_name": "printing-press-golden",
"competitor_scores": null,
"gap_report": [
- "MCP: 9 tools (1 public, 8 auth-required) — readiness: full"
+ "MCP: 10 tools (1 public, 9 auth-required) — readiness: full"
],
"overall_grade": "A",
"steinberger": {
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go
index 07aa5132..fadf6179 100644
--- a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/code_orch.go
@@ -59,7 +59,12 @@ type codeOrchEndpoint struct {
Tier string
Summary string
Positional []string
- keywords []string
+ // HeaderOverrides carries per-endpoint request headers (e.g. an
+ // Accept override for binary-only response endpoints). Without
+ // threading these through, the code-orchestration execute path
+ // sends the client's default Accept and binary endpoints 406.
+ HeaderOverrides map[string]string
+ keywords []string
}
// codeOrchEndpoints is the generator-populated registry covering every
@@ -213,18 +218,39 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
}
}
+ hdrs := ep.HeaderOverrides
var data json.RawMessage
switch ep.Method {
case "GET":
- data, err = c.Get(path, query)
+ if len(hdrs) > 0 {
+ data, err = c.GetWithHeaders(path, query, hdrs)
+ } else {
+ data, err = c.Get(path, query)
+ }
case "DELETE":
- data, _, err = c.DeleteWithParams(path, query)
+ if len(hdrs) > 0 {
+ data, _, err = c.DeleteWithParamsAndHeaders(path, query, hdrs)
+ } else {
+ data, _, err = c.DeleteWithParams(path, query)
+ }
case "POST":
- data, _, err = c.Post(path, params)
+ if len(hdrs) > 0 {
+ data, _, err = c.PostWithHeaders(path, params, hdrs)
+ } else {
+ data, _, err = c.Post(path, params)
+ }
case "PUT":
- data, _, err = c.Put(path, params)
+ if len(hdrs) > 0 {
+ data, _, err = c.PutWithHeaders(path, params, hdrs)
+ } else {
+ data, _, err = c.Put(path, params)
+ }
case "PATCH":
- data, _, err = c.Patch(path, params)
+ if len(hdrs) > 0 {
+ data, _, err = c.PatchWithHeaders(path, params, hdrs)
+ } else {
+ data, _, err = c.Patch(path, params)
+ }
default:
return mcplib.NewToolResultError(fmt.Sprintf("unsupported method %q", ep.Method)), nil
}
diff --git a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go
index 8264a877..5fd9a50b 100644
--- a/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-mcp-api/mcp-cloudflare/internal/mcp/tools.go
@@ -62,7 +62,7 @@ type mcpParamBinding struct {
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -83,8 +83,17 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
params := make(map[string]string)
bodyArgs := make(map[string]any)
var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
if binaryResponse {
- headers = map[string]string{client.BinaryResponseHeader: "true"}
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
}
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
@@ -129,31 +138,31 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
var data json.RawMessage
switch method {
case "GET":
- if binaryResponse {
+ if len(headers) > 0 {
data, err = c.GetWithHeaders(path, params, headers)
break
}
data, err = c.Get(path, params)
case "POST":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PostWithParams(path, params, bodyArgs)
case "PUT":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PutWithParams(path, params, bodyArgs)
case "PATCH":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PatchWithParams(path, params, bodyArgs)
case "DELETE":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.DeleteWithParamsAndHeaders(path, params, headers)
break
}
diff --git a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go
index 4c14a760..ccfe183f 100644
--- a/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-public-param-names/public-param-golden/internal/mcp/tools.go
@@ -30,7 +30,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("POST", "/stores", false, []mcpParamBinding{{PublicName: "store-code", WireName: "store_code", Location: "body"}}, []string{}),
+ makeAPIHandler("POST", "/stores", false, nil, []mcpParamBinding{{PublicName: "store-code", WireName: "store_code", Location: "body"}}, []string{}),
)
s.AddTool(
mcplib.NewTool("stores_find",
@@ -41,7 +41,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/power/store-locator", false, []mcpParamBinding{{PublicName: "address", WireName: "s", Location: "query"}, {PublicName: "city", WireName: "c", Location: "query"}}, []string{}),
+ makeAPIHandler("GET", "/power/store-locator", false, nil, []mcpParamBinding{{PublicName: "address", WireName: "s", Location: "query"}, {PublicName: "city", WireName: "c", Location: "query"}}, []string{}),
)
// Context tool — front-loaded domain knowledge for agents.
@@ -67,7 +67,7 @@ type mcpParamBinding struct {
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -88,8 +88,17 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
params := make(map[string]string)
bodyArgs := make(map[string]any)
var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
if binaryResponse {
- headers = map[string]string{client.BinaryResponseHeader: "true"}
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
}
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
@@ -134,31 +143,31 @@ func makeAPIHandler(method, pathTemplate string, binaryResponse bool, bindings [
var data json.RawMessage
switch method {
case "GET":
- if binaryResponse {
+ if len(headers) > 0 {
data, err = c.GetWithHeaders(path, params, headers)
break
}
data, err = c.Get(path, params)
case "POST":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PostWithParams(path, params, bodyArgs)
case "PUT":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PutWithParams(path, params, bodyArgs)
case "PATCH":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PatchWithParams(path, params, bodyArgs)
case "DELETE":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.DeleteWithParamsAndHeaders(path, params, headers)
break
}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
index 57bd9595..a7ef5dc1 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/client/client.go
@@ -6,6 +6,7 @@ package client
import (
"bytes"
"crypto/sha256"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -549,9 +550,6 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if err != nil {
return nil, 0, fmt.Errorf("reading response: %w", err)
}
- if !binaryResponse {
- respBody = sanitizeJSONResponse(respBody)
- }
// Success
if resp.StatusCode < 400 {
@@ -559,7 +557,22 @@ func (c *Client) doInternal(method, path string, params map[string]string, body
if method != http.MethodGet && !c.DryRun {
c.invalidateCache()
}
- return json.RawMessage(respBody), resp.StatusCode, nil
+ // Non-textual bodies (PDF, zip, image, octet-stream) must not be
+ // run through the JSON sanitizer or returned as raw json.RawMessage
+ // — return a self-describing base64 envelope instead. Textual and
+ // JSON responses fall through to the unchanged path.
+ if isBinaryResponseContentType(resp.Header.Get("Content-Type")) {
+ env, encErr := wrapBinaryResponse(resp.Header.Get("Content-Type"), respBody)
+ if encErr != nil {
+ return nil, 0, encErr
+ }
+ return env, resp.StatusCode, nil
+ }
+ return json.RawMessage(sanitizeJSONResponse(respBody)), resp.StatusCode, nil
+ }
+
+ if !binaryResponse {
+ respBody = sanitizeJSONResponse(respBody)
}
apiErr := &APIError{
@@ -659,6 +672,63 @@ func (c *Client) authHeader() (string, error) {
return c.Config.AuthHeader(), nil
}
+// binaryResponseEnvelope wraps a non-textual success body so it survives the
+// json.RawMessage contract every consumer (CLI output, --json, MCP tools)
+// depends on. Without it, raw bytes (PDF, zip, image) are corrupted by
+// sanitizeJSONResponse and emitted as invalid JSON. The _pp_binary
+// discriminator lets callers and agents detect and base64-decode the payload.
+type binaryResponseEnvelope struct {
+ PPBinary bool `json:"_pp_binary"`
+ ContentType string `json:"content_type"`
+ Encoding string `json:"encoding"`
+ Bytes int `json:"bytes"`
+ Data string `json:"data"`
+}
+
+// isBinaryResponseContentType reports whether a successful response with this
+// Content-Type must be base64-wrapped instead of treated as text/JSON. It is
+// deliberately narrow: JSON, */*, XML, and every text/* type (including
+// text/html, so response_format:html CLIs are untouched) pass through
+// unchanged. Only genuinely binary payloads are wrapped.
+func isBinaryResponseContentType(ct string) bool {
+ mt := strings.ToLower(strings.TrimSpace(ct))
+ if i := strings.IndexByte(mt, ';'); i >= 0 {
+ mt = strings.TrimSpace(mt[:i])
+ }
+ if mt == "" {
+ return false
+ }
+ switch {
+ case mt == "application/json", mt == "text/json", mt == "*/*":
+ return false
+ case strings.HasPrefix(mt, "text/"):
+ return false
+ case strings.HasSuffix(mt, "+json"), strings.HasSuffix(mt, "+xml"):
+ return false
+ case mt == "application/xml", mt == "application/xhtml+xml":
+ return false
+ case mt == "application/javascript", mt == "application/ecmascript",
+ mt == "application/x-www-form-urlencoded", mt == "application/graphql":
+ return false
+ }
+ return true
+}
+
+// wrapBinaryResponse marshals body into a self-describing base64 envelope.
+func wrapBinaryResponse(ct string, body []byte) (json.RawMessage, error) {
+ out, err := json.Marshal(binaryResponseEnvelope{
+ PPBinary: true,
+ ContentType: ct,
+ Encoding: "base64",
+ Bytes: len(body),
+ Data: base64.StdEncoding.EncodeToString(body),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("encoding binary response: %w", err)
+ }
+ return json.RawMessage(out), nil
+}
+
// sanitizeJSONResponse strips known JSONP/XSSI prefixes and UTF-8 BOM from
// response bodies so that downstream JSON parsing succeeds. For clean JSON
// responses these checks are no-ops.
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
index d9c8b975..f1957530 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/mcp/tools.go
@@ -32,7 +32,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items/enterprise", "enterprise", false, []mcpParamBinding{}, []string{}),
+ makeAPIHandler("GET", "/items/enterprise", "enterprise", false, nil, []mcpParamBinding{}, []string{}),
)
s.AddTool(
mcplib.NewTool("items_list",
@@ -41,7 +41,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items", "free", false, []mcpParamBinding{}, []string{}),
+ makeAPIHandler("GET", "/items", "free", false, nil, []mcpParamBinding{}, []string{}),
)
s.AddTool(
mcplib.NewTool("items_premium",
@@ -50,7 +50,7 @@ func RegisterTools(s *server.MCPServer) {
mcplib.WithDestructiveHintAnnotation(false),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("GET", "/items/premium", "paid", false, []mcpParamBinding{}, []string{}),
+ makeAPIHandler("GET", "/items/premium", "paid", false, nil, []mcpParamBinding{}, []string{}),
)
// SQL tool — ad-hoc analysis on synced data without API calls
s.AddTool(
@@ -86,7 +86,7 @@ type mcpParamBinding struct {
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
-func makeAPIHandler(method, pathTemplate, tier string, binaryResponse bool, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
+func makeAPIHandler(method, pathTemplate, tier string, binaryResponse bool, headerOverrides map[string]string, bindings []mcpParamBinding, positionalParams []string) server.ToolHandlerFunc {
return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
c, err := newMCPClient()
if err != nil {
@@ -108,8 +108,17 @@ func makeAPIHandler(method, pathTemplate, tier string, binaryResponse bool, bind
params := make(map[string]string)
bodyArgs := make(map[string]any)
var headers map[string]string
+ if len(headerOverrides) > 0 {
+ headers = make(map[string]string, len(headerOverrides)+1)
+ for k, v := range headerOverrides {
+ headers[k] = v
+ }
+ }
if binaryResponse {
- headers = map[string]string{client.BinaryResponseHeader: "true"}
+ if headers == nil {
+ headers = map[string]string{}
+ }
+ headers[client.BinaryResponseHeader] = "true"
}
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
@@ -154,31 +163,31 @@ func makeAPIHandler(method, pathTemplate, tier string, binaryResponse bool, bind
var data json.RawMessage
switch method {
case "GET":
- if binaryResponse {
+ if len(headers) > 0 {
data, err = c.GetWithHeaders(path, params, headers)
break
}
data, err = c.Get(path, params)
case "POST":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PostWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PostWithParams(path, params, bodyArgs)
case "PUT":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PutWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PutWithParams(path, params, bodyArgs)
case "PATCH":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.PatchWithParamsAndHeaders(path, params, bodyArgs, headers)
break
}
data, _, err = c.PatchWithParams(path, params, bodyArgs)
case "DELETE":
- if binaryResponse {
+ if len(headers) > 0 {
data, _, err = c.DeleteWithParamsAndHeaders(path, params, headers)
break
}
diff --git a/testdata/golden/fixtures/golden-api.yaml b/testdata/golden/fixtures/golden-api.yaml
index ab2891db..9fa5b96c 100644
--- a/testdata/golden/fixtures/golden-api.yaml
+++ b/testdata/golden/fixtures/golden-api.yaml
@@ -335,3 +335,23 @@ paths:
responses:
"200":
description: OK
+ /reports/{year}/export:
+ get:
+ tags: [reports]
+ operationId: exportReportYear
+ summary: Download the annual report as a binary file
+ parameters:
+ - $ref: "#/components/parameters/ApiVersion"
+ - name: year
+ in: path
+ required: true
+ schema:
+ type: integer
+ responses:
+ "200":
+ description: OK
+ content:
+ application/octet-stream:
+ schema:
+ type: string
+ format: byte
← 1a243810 feat(cli): add Quo (formerly OpenPhone) catalog entry and Op
·
back to Cli Printing Press
·
fix(cli): redact shipcheck api key in json (#1673) 4093ef3e →