← back to Cli Printing Press
fix(cli): route in:query params to URL on PUT/DELETE/POST/PATCH handlers (#1279)
b8488ee422472772141c9137b869ae5cbee3492c · 2026-05-13 11:45:14 -0700 · Trevin Chow
* fix(cli): route in:query params to URL on PUT/DELETE/POST/PATCH handlers
The PUT, DELETE, POST, and PATCH handler templates ignored OpenAPI
parameters declared `in: query` — only the GET path built a params
map. Non-GET endpoints with query parameters silently dropped them,
so e.g. Spotify's `PUT /me/library?uris=...` always 400'd with
"Missing required field: uris". The bug also tripped MCP intent
classification, which routed every query param into the JSON body.
Add an `endpointHasQueryFlags` template helper, four new client
variants per write verb (`*WithParams`, `*WithParamsAndHeaders` for
POST/PUT/PATCH; `DeleteWithParams`, `DeleteWithParamsAndHeaders` for
DELETE), and gate the new variants on the helper so endpoints
without query flags keep the existing call shape. The underlying
`do(method, path, params, body, headers)` already plumbed params
through to `req.URL.Query()`, so this wires through what was already
supported, instead of adding a new code path.
Extend the golden fixture for `/projects/{projectId}/tasks/{taskId}`
to declare an `in: query` `notify` flag so the regen path covers the
new branch (params map built, `PatchWithParams` called, MCP location
classified as "query").
Fixes #1275
* fix(cli): drop ticket number from golden fixture comment
AGENTS.md prohibits ticket numbers in code comments. The regression
guard is still load-bearing on the prose — only the #1275 reference
goes.
* fix(cli): extend in:query routing to multipart/form non-GET handlers
The first pass covered JSON-body POST/PUT/DELETE/PATCH. Multipart
and form-encoded write verbs still routed only the body fields and
silently dropped any in:query flag, so e.g. a file-upload endpoint
with `?overwrite=true` would 400 the same way the JSON-body case
used to.
Add 12 client variants (`PostMultipartWithParams`,
`PostMultipartWithParamsAndHeaders`, `PostFormWithParams`,
`PostFormWithParamsAndHeaders`, and the same for Put and Patch),
gate handler emission on `endpointHasQueryFlags` in the multipart
and form branches of `command_endpoint.go.tmpl`, and route the
promoted-command multipart/form paths through the new variants when
`$hasQueryFlags`. The underlying `do(method, path, params, body,
headers)` already plumbed params through to `req.URL.Query()`.
Add a multipart fixture endpoint (`PUT /projects/{projectId}/avatar`
with an `in:query overwrite` flag) to cover the new branch end-to-end.
* fix(cli): route in:query params on MCP write-verb dispatch
The CLI handler templates were fixed in the prior commits, but the
MCP surface had the same shape: mcp_tools.go.tmpl, mcp_intents, and
mcp_code_orch all built a params map from in:query bindings and then
called the bare client methods, silently dropping the params before
hitting the wire.
Route every non-GET MCP dispatch through the *WithParams client
variants. For mcp_intents and mcp_code_orch, drop the GET/DELETE
gating on the query-population loop so write verbs also forward
their query-shaped flags; body-shaped flags still flow through the
JSON body unchanged (the API picks up whichever it actually reads).
Update two test assertions that pinned the old c.PostForm and
c.PostMultipart call shapes in MCP output.
* fix(cli): preserve body-only design on MCP intent/code-orch write verbs
The prior commit overcorrected: it routed every non-path param into
the URL query string on POST/PUT/PATCH in mcp_intents and
mcp_code_orch, which leaked body params into the URL for endpoints
with no in:query params at all. Strict APIs that reject unexpected
query parameters would 400 the request.
Revert POST/PUT/PATCH in both templates to the bare Post/Put/Patch
calls (everything-to-body). DELETE keeps the WithParams fix because
that branch was strictly drop-broken; restore the GET/DELETE gating
on the query-population loop in mcp_code_orch.
The typed mcp_tools.go.tmpl path is unchanged — it has per-binding
location metadata so it can correctly route in:query bindings to the
URL while keeping in:body fields in the JSON payload.
Files touched
M internal/generator/form_test.goM internal/generator/generator.goM internal/generator/multipart_test.goM internal/generator/templates/client.go.tmplM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/generator/templates/mcp_code_orch.go.tmplM internal/generator/templates/mcp_intents.go.tmplM internal/generator/templates/mcp_tools.go.tmplM 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/projects_avatar.goA testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.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 b8488ee422472772141c9137b869ae5cbee3492c
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed May 13 11:45:14 2026 -0700
fix(cli): route in:query params to URL on PUT/DELETE/POST/PATCH handlers (#1279)
* fix(cli): route in:query params to URL on PUT/DELETE/POST/PATCH handlers
The PUT, DELETE, POST, and PATCH handler templates ignored OpenAPI
parameters declared `in: query` — only the GET path built a params
map. Non-GET endpoints with query parameters silently dropped them,
so e.g. Spotify's `PUT /me/library?uris=...` always 400'd with
"Missing required field: uris". The bug also tripped MCP intent
classification, which routed every query param into the JSON body.
Add an `endpointHasQueryFlags` template helper, four new client
variants per write verb (`*WithParams`, `*WithParamsAndHeaders` for
POST/PUT/PATCH; `DeleteWithParams`, `DeleteWithParamsAndHeaders` for
DELETE), and gate the new variants on the helper so endpoints
without query flags keep the existing call shape. The underlying
`do(method, path, params, body, headers)` already plumbed params
through to `req.URL.Query()`, so this wires through what was already
supported, instead of adding a new code path.
Extend the golden fixture for `/projects/{projectId}/tasks/{taskId}`
to declare an `in: query` `notify` flag so the regen path covers the
new branch (params map built, `PatchWithParams` called, MCP location
classified as "query").
Fixes #1275
* fix(cli): drop ticket number from golden fixture comment
AGENTS.md prohibits ticket numbers in code comments. The regression
guard is still load-bearing on the prose — only the #1275 reference
goes.
* fix(cli): extend in:query routing to multipart/form non-GET handlers
The first pass covered JSON-body POST/PUT/DELETE/PATCH. Multipart
and form-encoded write verbs still routed only the body fields and
silently dropped any in:query flag, so e.g. a file-upload endpoint
with `?overwrite=true` would 400 the same way the JSON-body case
used to.
Add 12 client variants (`PostMultipartWithParams`,
`PostMultipartWithParamsAndHeaders`, `PostFormWithParams`,
`PostFormWithParamsAndHeaders`, and the same for Put and Patch),
gate handler emission on `endpointHasQueryFlags` in the multipart
and form branches of `command_endpoint.go.tmpl`, and route the
promoted-command multipart/form paths through the new variants when
`$hasQueryFlags`. The underlying `do(method, path, params, body,
headers)` already plumbed params through to `req.URL.Query()`.
Add a multipart fixture endpoint (`PUT /projects/{projectId}/avatar`
with an `in:query overwrite` flag) to cover the new branch end-to-end.
* fix(cli): route in:query params on MCP write-verb dispatch
The CLI handler templates were fixed in the prior commits, but the
MCP surface had the same shape: mcp_tools.go.tmpl, mcp_intents, and
mcp_code_orch all built a params map from in:query bindings and then
called the bare client methods, silently dropping the params before
hitting the wire.
Route every non-GET MCP dispatch through the *WithParams client
variants. For mcp_intents and mcp_code_orch, drop the GET/DELETE
gating on the query-population loop so write verbs also forward
their query-shaped flags; body-shaped flags still flow through the
JSON body unchanged (the API picks up whichever it actually reads).
Update two test assertions that pinned the old c.PostForm and
c.PostMultipart call shapes in MCP output.
* fix(cli): preserve body-only design on MCP intent/code-orch write verbs
The prior commit overcorrected: it routed every non-path param into
the URL query string on POST/PUT/PATCH in mcp_intents and
mcp_code_orch, which leaked body params into the URL for endpoints
with no in:query params at all. Strict APIs that reject unexpected
query parameters would 400 the request.
Revert POST/PUT/PATCH in both templates to the bare Post/Put/Patch
calls (everything-to-body). DELETE keeps the WithParams fix because
that branch was strictly drop-broken; restore the GET/DELETE gating
on the query-population loop in mcp_code_orch.
The typed mcp_tools.go.tmpl path is unchanged — it has per-binding
location metadata so it can correctly route in:query bindings to the
URL while keeping in:body fields in the JSON payload.
---
internal/generator/form_test.go | 2 +-
internal/generator/generator.go | 16 ++
internal/generator/multipart_test.go | 2 +-
internal/generator/templates/client.go.tmpl | 80 +++++++++
.../generator/templates/command_endpoint.go.tmpl | 190 +++++++++++++++++++++
.../generator/templates/command_promoted.go.tmpl | 30 +++-
internal/generator/templates/mcp_code_orch.go.tmpl | 6 +-
internal/generator/templates/mcp_intents.go.tmpl | 6 +-
internal/generator/templates/mcp_tools.go.tmpl | 26 +--
.../golden/cases/generate-golden-api/artifacts.txt | 2 +
.../internal/client/client.go | 32 ++++
.../printing-press-rich-auth/internal/mcp/tools.go | 8 +-
.../expected/generate-golden-api/dogfood.json | 8 +-
.../printing-press-golden/.printing-press.json | 4 +-
.../internal/cli/projects_avatar.go | 18 ++
.../internal/cli/projects_avatar_upload-project.go | 121 +++++++++++++
.../internal/cli/projects_tasks_update-project.go | 8 +-
.../printing-press-golden/internal/cli/sync.go | 2 +
.../printing-press-golden/internal/cli/which.go | 1 +
.../internal/client/client.go | 141 ++++++++++++++-
.../printing-press-golden/internal/mcp/tools.go | 72 ++++++--
.../expected/generate-golden-api/scorecard.json | 2 +-
.../mcp-cloudflare/internal/mcp/code_orch.go | 6 +-
.../mcp-cloudflare/internal/mcp/tools.go | 8 +-
.../public-param-golden/internal/mcp/tools.go | 8 +-
.../tier-routing-golden/internal/client/client.go | 32 ++++
.../tier-routing-golden/internal/mcp/tools.go | 8 +-
testdata/golden/fixtures/golden-api.yaml | 43 +++++
28 files changed, 819 insertions(+), 63 deletions(-)
diff --git a/internal/generator/form_test.go b/internal/generator/form_test.go
index 9d0f6e36..6c8940f5 100644
--- a/internal/generator/form_test.go
+++ b/internal/generator/form_test.go
@@ -89,7 +89,7 @@ func TestGenerateFormRequestBodyUsesFormClient(t *testing.T) {
mcpSrc := readGeneratedFile(t, outputDir, "internal", "mcp", "tools.go")
assert.Contains(t, mcpSrc, `RequestContentType: "application/x-www-form-urlencoded"`)
assert.Contains(t, mcpSrc, `formFields := url.Values{}`)
- assert.Contains(t, mcpSrc, `data, _, err = c.PostForm(path, formFields)`)
+ assert.Contains(t, mcpSrc, `data, _, err = c.PostFormWithParams(path, params, formFields)`)
runGoCommand(t, outputDir, "mod", "tidy")
runGoCommand(t, outputDir, "build", "./...")
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 9e406248..6529c138 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -315,6 +315,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
"bodyRequiredChecks": bodyRequiredChecks,
"multipartBodyMaps": multipartBodyMaps,
"endpointUsesMultipart": endpointUsesMultipart,
+ "endpointHasQueryFlags": endpointHasQueryFlags,
"hasMultipartRequest": hasMultipartRequest,
"formBodyMaps": formBodyMaps,
"endpointUsesForm": endpointUsesForm,
@@ -3469,6 +3470,21 @@ func multipartBodyMaps(body []spec.Param, indent string) string {
return b.String()
}
+// endpointHasQueryFlags reports whether the endpoint declares any non-positional,
+// non-path parameters — i.e., flags that should be encoded as URL query string.
+// True for any HTTP method. Used by the non-GET handler template to decide
+// whether to build a params map and route through the *WithParams client
+// variant, so query-shaped params on POST/PUT/DELETE/PATCH reach the URL
+// instead of being silently dropped into the JSON body or omitted.
+func endpointHasQueryFlags(endpoint spec.Endpoint) bool {
+ for _, p := range endpoint.Params {
+ if !p.Positional && !p.PathParam {
+ return true
+ }
+ }
+ return false
+}
+
func endpointUsesMultipart(endpoint spec.Endpoint) bool {
return strings.EqualFold(strings.TrimSpace(endpoint.RequestContentType), "multipart/form-data")
}
diff --git a/internal/generator/multipart_test.go b/internal/generator/multipart_test.go
index d0544fb1..aba8d2e5 100644
--- a/internal/generator/multipart_test.go
+++ b/internal/generator/multipart_test.go
@@ -82,7 +82,7 @@ func TestGenerateMultipartRequestBodyUsesMultipartClient(t *testing.T) {
assert.Contains(t, mcpSrc, `Format: "binary"`)
assert.Contains(t, mcpSrc, `RequestContentType: "multipart/form-data"`)
assert.Contains(t, mcpSrc, `multipartFileFields[binding.WireName] = fmt.Sprintf("%v", v)`)
- assert.Contains(t, mcpSrc, `data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)`)
+ assert.Contains(t, mcpSrc, `data, _, err = c.PostMultipartWithParams(path, params, multipartFields, multipartFileFields)`)
runGoCommand(t, outputDir, "mod", "tidy")
runGoCommand(t, outputDir, "build", "./...")
diff --git a/internal/generator/templates/client.go.tmpl b/internal/generator/templates/client.go.tmpl
index 6c1cb41c..47a19a3d 100644
--- a/internal/generator/templates/client.go.tmpl
+++ b/internal/generator/templates/client.go.tmpl
@@ -468,6 +468,14 @@ func (c *Client) PostWithHeaders(path string, body any, headers map[string]strin
return c.do("POST", path, nil, body, headers)
}
+func (c *Client) PostWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, nil)
+}
+
+func (c *Client) PostWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, headers)
+}
+
{{- if .HasMultipartRequest }}
func (c *Client) PostMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
@@ -477,6 +485,14 @@ func (c *Client) PostMultipartWithHeaders(path string, fields map[string]string,
return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
}
+func (c *Client) PostMultipartWithParams(path string, params map[string]string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PostMultipartWithParamsAndHeaders(path string, params map[string]string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
{{ end }}
{{- if .HasFormRequest }}
func (c *Client) PostForm(path string, fields url.Values) (json.RawMessage, int, error) {
@@ -487,6 +503,14 @@ func (c *Client) PostFormWithHeaders(path string, fields url.Values, headers map
return c.do("POST", path, nil, formRequestBody{Fields: fields}, headers)
}
+func (c *Client) PostFormWithParams(path string, params map[string]string, fields url.Values) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PostFormWithParamsAndHeaders(path string, params map[string]string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, formRequestBody{Fields: fields}, headers)
+}
+
{{ end }}
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
@@ -497,6 +521,14 @@ func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json
return c.do("DELETE", path, nil, nil, headers)
}
+func (c *Client) DeleteWithParams(path string, params map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, nil)
+}
+
+func (c *Client) DeleteWithParamsAndHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, headers)
+}
+
func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
return c.do("PUT", path, nil, body, nil)
}
@@ -505,6 +537,14 @@ func (c *Client) PutWithHeaders(path string, body any, headers map[string]string
return c.do("PUT", path, nil, body, headers)
}
+func (c *Client) PutWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, nil)
+}
+
+func (c *Client) PutWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, headers)
+}
+
{{- if .HasMultipartRequest }}
func (c *Client) PutMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
@@ -514,6 +554,14 @@ func (c *Client) PutMultipartWithHeaders(path string, fields map[string]string,
return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
}
+func (c *Client) PutMultipartWithParams(path string, params map[string]string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PutMultipartWithParamsAndHeaders(path string, params map[string]string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
{{ end }}
{{- if .HasFormRequest }}
func (c *Client) PutForm(path string, fields url.Values) (json.RawMessage, int, error) {
@@ -524,6 +572,14 @@ func (c *Client) PutFormWithHeaders(path string, fields url.Values, headers map[
return c.do("PUT", path, nil, formRequestBody{Fields: fields}, headers)
}
+func (c *Client) PutFormWithParams(path string, params map[string]string, fields url.Values) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PutFormWithParamsAndHeaders(path string, params map[string]string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, formRequestBody{Fields: fields}, headers)
+}
+
{{ end }}
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
@@ -534,6 +590,14 @@ func (c *Client) PatchWithHeaders(path string, body any, headers map[string]stri
return c.do("PATCH", path, nil, body, headers)
}
+func (c *Client) PatchWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, nil)
+}
+
+func (c *Client) PatchWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, headers)
+}
+
{{- if .HasMultipartRequest }}
func (c *Client) PatchMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
@@ -543,6 +607,14 @@ func (c *Client) PatchMultipartWithHeaders(path string, fields map[string]string
return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
}
+func (c *Client) PatchMultipartWithParams(path string, params map[string]string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PatchMultipartWithParamsAndHeaders(path string, params map[string]string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
{{ end }}
{{- if .HasFormRequest }}
func (c *Client) PatchForm(path string, fields url.Values) (json.RawMessage, int, error) {
@@ -553,6 +625,14 @@ func (c *Client) PatchFormWithHeaders(path string, fields url.Values, headers ma
return c.do("PATCH", path, nil, formRequestBody{Fields: fields}, headers)
}
+func (c *Client) PatchFormWithParams(path string, params map[string]string, fields url.Values) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, formRequestBody{Fields: fields}, nil)
+}
+
+func (c *Client) PatchFormWithParamsAndHeaders(path string, params map[string]string, fields url.Values, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, formRequestBody{Fields: fields}, headers)
+}
+
{{ end }}
{{- if .HasMultipartRequest }}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 8ffc98a6..ba4033de 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -258,19 +258,57 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
fields := map[string]string{}
fileFields := map[string]string{}
{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PostMultipartWithParamsAndHeaders(path, params, fields, fileFields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PostMultipartWithParams(path, params, fields, fileFields)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PostMultipartWithHeaders(path, fields, fileFields, headerOverrides)
{{- else}}
data, statusCode, err := c.PostMultipart(path, fields, fileFields)
{{- end}}
+{{- end}}
{{- else if $isForm}}
fields := url.Values{}
{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PostFormWithParamsAndHeaders(path, params, fields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PostFormWithParams(path, params, fields)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PostFormWithHeaders(path, fields, headerOverrides)
{{- else}}
data, statusCode, err := c.PostForm(path, fields)
{{- end}}
+{{- end}}
{{- else}}
var body map[string]any
if stdinBody {
@@ -286,36 +324,112 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
} else {
body = map[string]any{}
{{bodyMapForEndpoint .Endpoint "\t\t\t\t"}} }
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PostWithParamsAndHeaders(path, params, body, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PostWithParams(path, params, body)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PostWithHeaders(path, body, headerOverrides)
{{- else}}
data, statusCode, err := c.Post(path, body)
{{- end}}
{{- end}}
+{{- end}}
{{- else if eq .Endpoint.Method "DELETE"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.DeleteWithParamsAndHeaders(path, params, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.DeleteWithParams(path, params)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.DeleteWithHeaders(path, headerOverrides)
{{- else}}
data, statusCode, err := c.Delete(path)
{{- end}}
+{{- end}}
{{- else if eq .Endpoint.Method "PUT"}}
{{- if $isMultipart}}
fields := map[string]string{}
fileFields := map[string]string{}
{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PutMultipartWithParamsAndHeaders(path, params, fields, fileFields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PutMultipartWithParams(path, params, fields, fileFields)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PutMultipartWithHeaders(path, fields, fileFields, headerOverrides)
{{- else}}
data, statusCode, err := c.PutMultipart(path, fields, fileFields)
{{- end}}
+{{- end}}
{{- else if $isForm}}
fields := url.Values{}
{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PutFormWithParamsAndHeaders(path, params, fields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PutFormWithParams(path, params, fields)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PutFormWithHeaders(path, fields, headerOverrides)
{{- else}}
data, statusCode, err := c.PutForm(path, fields)
{{- end}}
+{{- end}}
{{- else}}
var body map[string]any
if stdinBody {
@@ -331,30 +445,87 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
} else {
body = map[string]any{}
{{bodyMapForEndpoint .Endpoint "\t\t\t\t"}} }
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PutWithParamsAndHeaders(path, params, body, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PutWithParams(path, params, body)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PutWithHeaders(path, body, headerOverrides)
{{- else}}
data, statusCode, err := c.Put(path, body)
{{- end}}
{{- end}}
+{{- end}}
{{- else if eq .Endpoint.Method "PATCH"}}
{{- if $isMultipart}}
fields := map[string]string{}
fileFields := map[string]string{}
{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PatchMultipartWithParamsAndHeaders(path, params, fields, fileFields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PatchMultipartWithParams(path, params, fields, fileFields)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PatchMultipartWithHeaders(path, fields, fileFields, headerOverrides)
{{- else}}
data, statusCode, err := c.PatchMultipart(path, fields, fileFields)
{{- end}}
+{{- end}}
{{- else if $isForm}}
fields := url.Values{}
{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PatchFormWithParamsAndHeaders(path, params, fields, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PatchFormWithParams(path, params, fields)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PatchFormWithHeaders(path, fields, headerOverrides)
{{- else}}
data, statusCode, err := c.PatchForm(path, fields)
{{- end}}
+{{- end}}
{{- else}}
var body map[string]any
if stdinBody {
@@ -370,6 +541,24 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
} else {
body = map[string]any{}
{{bodyMapForEndpoint .Endpoint "\t\t\t\t"}} }
+{{- if endpointHasQueryFlags .Endpoint}}
+ params := map[string]string{}
+{{- range .Endpoint.Params}}
+{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
+ params["{{.Name}}"] = args[{{positionalIndex $.Endpoint .Name}}]
+{{- end}}
+{{- if and (not .Positional) (not .PathParam)}}
+ if flag{{camel (paramIdent .)}} != {{zeroValForParam .Name .Type}} {
+ params["{{.Name}}"] = fmt.Sprintf("%v", flag{{camel (paramIdent .)}})
+ }
+{{- end}}
+{{- end}}
+{{- if .Endpoint.HeaderOverrides}}
+ data, statusCode, err := c.PatchWithParamsAndHeaders(path, params, body, headerOverrides)
+{{- else}}
+ data, statusCode, err := c.PatchWithParams(path, params, body)
+{{- end}}
+{{- else}}
{{- if .Endpoint.HeaderOverrides}}
data, statusCode, err := c.PatchWithHeaders(path, body, headerOverrides)
{{- else}}
@@ -377,6 +566,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
{{- end}}
{{- end}}
+{{- end}}
{{- if eq .Endpoint.Method "DELETE"}}
if err != nil {
return classifyDeleteError(err, flags)
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 01be306b..f8857aa6 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -2,6 +2,10 @@
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
{{- $isMultipart := endpointUsesMultipart .Endpoint}}
{{- $isForm := endpointUsesForm .Endpoint}}
+{{- $hasQueryFlags := endpointHasQueryFlags .Endpoint}}
+{{- $method := upper .Endpoint.Method}}
+{{- $isReadVerb := or (eq $method "GET") (eq $method "HEAD") (eq $method "OPTIONS")}}
+{{- $isWriteVerb := or (eq $method "POST") (eq $method "PUT") (eq $method "PATCH") (eq $method "DELETE")}}
package cli
@@ -152,7 +156,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
}, nil, flagAll, "{{.Endpoint.Pagination.CursorParam}}", "{{.Endpoint.Pagination.NextCursorPath}}", "{{.Endpoint.Pagination.HasMoreField}}")
{{- end}}
{{- else}}
-{{- if or (eq (upper .Endpoint.Method) "GET") (eq (upper .Endpoint.Method) "HEAD") (eq (upper .Endpoint.Method) "OPTIONS")}}
+{{- if or $isReadVerb (and $isWriteVerb $hasQueryFlags)}}
params := map[string]string{}
{{- range .Endpoint.Params}}
{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
@@ -165,29 +169,43 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- end}}
{{- end}}
{{- end}}
-{{- if and .HasStore (eq (upper .Endpoint.Method) "GET")}}
+{{- if and .HasStore (eq $method "GET")}}
data, prov, err := resolveRead(cmd.Context(), c, flags, "{{lower .ResourceName}}", {{if .Endpoint.Pagination}}true{{else}}false{{end}}, path, params, nil)
-{{- else if eq (upper .Endpoint.Method) "GET"}}
+{{- else if eq $method "GET"}}
data, err := c.Get(path, params)
-{{- else if eq (upper .Endpoint.Method) "DELETE"}}
+{{- else if eq $method "DELETE"}}
+{{- if $hasQueryFlags}}
+ data, _, err := c.DeleteWithParams(path, params)
+{{- else}}
data, _, err := c.Delete(path)
-{{- else if or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")}}
+{{- end}}
+{{- else if or (eq $method "POST") (eq $method "PUT") (eq $method "PATCH")}}
{{- if $isMultipart}}
fields := map[string]string{}
fileFields := map[string]string{}
{{multipartBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if $hasQueryFlags}}
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}MultipartWithParams(path, params, fields, fileFields)
+{{- else}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}Multipart(path, fields, fileFields)
+{{- end}}
{{- else if $isForm}}{{/* HeaderOverrides intentionally omitted on promoted form/multipart/JSON paths — parity with the existing multipart promoted branch above. */}}
fields := url.Values{}
{{formBodyMaps .Endpoint.Body "\t\t\t"}}
+{{- if $hasQueryFlags}}
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}FormWithParams(path, params, fields)
+{{- else}}
data, _, err := c.{{pascal (lower .Endpoint.Method)}}Form(path, fields)
+{{- end}}
{{- else}}
// HasStore + non-GET falls through to a live API call here
// rather than through resolveRead (GET-only internally); a
// body-aware cached read helper is filed as #425 for when a
// second store-backed POST-search consumer ships.
body := map[string]any{}
-{{bodyMapForEndpoint .Endpoint "\t\t\t"}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
+{{bodyMapForEndpoint .Endpoint "\t\t\t"}}{{if $hasQueryFlags}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}WithParams(path, params, body)
+{{else}} data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
+{{end}}
{{- end}}
{{- else}}
// HEAD/OPTIONS and unknown verbs fall back to GET so generation
diff --git a/internal/generator/templates/mcp_code_orch.go.tmpl b/internal/generator/templates/mcp_code_orch.go.tmpl
index cf9029dd..d334784b 100644
--- a/internal/generator/templates/mcp_code_orch.go.tmpl
+++ b/internal/generator/templates/mcp_code_orch.go.tmpl
@@ -232,6 +232,10 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
}
}
+ // Code-orch dispatch has no schema metadata at runtime to bucket params
+ // into body vs query, so POST/PUT/PATCH keep the everything-to-body
+ // design. DELETE is the one verb where query routing is unambiguous; the
+ // previous code built `query` but never passed it.
query := map[string]string{}
if ep.Method == "GET" || ep.Method == "DELETE" {
for k, v := range params {
@@ -244,7 +248,7 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
case "GET":
data, err = c.Get(path, query)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, query)
default:
body, mErr := json.Marshal(params)
if mErr != nil {
diff --git a/internal/generator/templates/mcp_intents.go.tmpl b/internal/generator/templates/mcp_intents.go.tmpl
index bef73d39..f26dadb7 100644
--- a/internal/generator/templates/mcp_intents.go.tmpl
+++ b/internal/generator/templates/mcp_intents.go.tmpl
@@ -160,11 +160,15 @@ func callIntentEndpoint(c *client.Client, ref string, params map[string]any) (an
var data json.RawMessage
var err error
+ // Intent dispatch has no schema metadata at runtime to bucket params into
+ // body vs query, so POST/PUT/PATCH keep the everything-to-body design.
+ // DELETE is the one verb where query routing is unambiguous; the previous
+ // code built `query` but never passed it.
switch ep.method {
case "GET":
data, err = c.Get(path, query)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, query)
default:
body, mErr := json.Marshal(params)
if mErr != nil {
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 1fbeba98..f2d8a150 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -358,68 +358,68 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
case "POST":
{{- if $hasMultipartRequest}}
if multipart {
- data, _, err = c.PostMultipart(path, multipartFields, multipartFileFields)
+ data, _, err = c.PostMultipartWithParams(path, params, multipartFields, multipartFileFields)
break
}
{{- end}}
{{- if $hasFormRequest}}
if formEncoded {
- data, _, err = c.PostForm(path, formFields)
+ data, _, err = c.PostFormWithParams(path, params, formFields)
break
}
{{- end}}
{{- if $hasBodyJSONFallback}}
if len(bodyJSONOverride) > 0 {
- data, _, err = c.Post(path, bodyJSONOverride)
+ data, _, err = c.PostWithParams(path, params, bodyJSONOverride)
break
}
{{- end}}
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Post(path, body)
+ data, _, err = c.PostWithParams(path, params, body)
case "PUT":
{{- if $hasMultipartRequest}}
if multipart {
- data, _, err = c.PutMultipart(path, multipartFields, multipartFileFields)
+ data, _, err = c.PutMultipartWithParams(path, params, multipartFields, multipartFileFields)
break
}
{{- end}}
{{- if $hasFormRequest}}
if formEncoded {
- data, _, err = c.PutForm(path, formFields)
+ data, _, err = c.PutFormWithParams(path, params, formFields)
break
}
{{- end}}
{{- if $hasBodyJSONFallback}}
if len(bodyJSONOverride) > 0 {
- data, _, err = c.Put(path, bodyJSONOverride)
+ data, _, err = c.PutWithParams(path, params, bodyJSONOverride)
break
}
{{- end}}
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Put(path, body)
+ data, _, err = c.PutWithParams(path, params, body)
case "PATCH":
{{- if $hasMultipartRequest}}
if multipart {
- data, _, err = c.PatchMultipart(path, multipartFields, multipartFileFields)
+ data, _, err = c.PatchMultipartWithParams(path, params, multipartFields, multipartFileFields)
break
}
{{- end}}
{{- if $hasFormRequest}}
if formEncoded {
- data, _, err = c.PatchForm(path, formFields)
+ data, _, err = c.PatchFormWithParams(path, params, formFields)
break
}
{{- end}}
{{- if $hasBodyJSONFallback}}
if len(bodyJSONOverride) > 0 {
- data, _, err = c.Patch(path, bodyJSONOverride)
+ data, _, err = c.PatchWithParams(path, params, bodyJSONOverride)
break
}
{{- end}}
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Patch(path, body)
+ data, _, err = c.PatchWithParams(path, params, body)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, params)
default:
return mcplib.NewToolResultError("unsupported method: " + method), nil
}
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index c5ebc37c..3bb0f3d5 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -11,6 +11,8 @@ printing-press-golden/internal/cli/projects_list.go
printing-press-golden/internal/cli/projects_create.go
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/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 4134cd5d..e9ceedc7 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
@@ -157,6 +157,14 @@ func (c *Client) PostWithHeaders(path string, body any, headers map[string]strin
return c.do("POST", path, nil, body, headers)
}
+func (c *Client) PostWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, nil)
+}
+
+func (c *Client) PostWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, headers)
+}
+
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
return c.do("DELETE", path, nil, nil, nil)
}
@@ -165,6 +173,14 @@ func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json
return c.do("DELETE", path, nil, nil, headers)
}
+func (c *Client) DeleteWithParams(path string, params map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, nil)
+}
+
+func (c *Client) DeleteWithParamsAndHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, headers)
+}
+
func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
return c.do("PUT", path, nil, body, nil)
}
@@ -173,6 +189,14 @@ func (c *Client) PutWithHeaders(path string, body any, headers map[string]string
return c.do("PUT", path, nil, body, headers)
}
+func (c *Client) PutWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, nil)
+}
+
+func (c *Client) PutWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, headers)
+}
+
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
return c.do("PATCH", path, nil, body, nil)
}
@@ -181,6 +205,14 @@ func (c *Client) PatchWithHeaders(path string, body any, headers map[string]stri
return c.do("PATCH", path, nil, body, headers)
}
+func (c *Client) PatchWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, nil)
+}
+
+func (c *Client) PatchWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, headers)
+}
+
// do executes an HTTP request. headerOverrides, when non-nil, override global
// RequiredHeaders for this specific request (used for per-endpoint API versioning).
func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
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 31e90ab3..47e49c1a 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
@@ -133,15 +133,15 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
data, err = c.Get(path, params)
case "POST":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Post(path, body)
+ data, _, err = c.PostWithParams(path, params, body)
case "PUT":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Put(path, body)
+ data, _, err = c.PutWithParams(path, params, body)
case "PATCH":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Patch(path, body)
+ data, _, err = c.PatchWithParams(path, params, body)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, params)
default:
return mcplib.NewToolResultError("unsupported method: " + method), nil
}
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 56d2563e..f755805e 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": 32
+ "checked": 34
},
"novel_features_check": {
"found": 0,
@@ -54,7 +54,7 @@
"sync_calls_domain": true
},
"print_json_filtered_check": {
- "checked": 32
+ "checked": 34
},
"reimplementation_check": {
"checked": 0,
@@ -71,8 +71,8 @@
"verdict": "WARN",
"wiring_check": {
"command_tree": {
- "defined": 41,
- "registered": 41
+ "defined": 43,
+ "registered": 43
},
"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 7059887f..b1ab3d6f 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,13 +11,13 @@
"mcp_binary": "printing-press-golden-pp-mcp",
"mcp_public_tool_count": 1,
"mcp_ready": "full",
- "mcp_tool_count": 8,
+ "mcp_tool_count": 9,
"owner": "printing-press-golden",
"printer": "printing-press-golden",
"printer_name": "printing-press-golden",
"printing_press_version": "<PRINTING_PRESS_VERSION>",
"schema_version": 1,
- "spec_checksum": "sha256:333b2c06c645b8f394fbbaaedf8126e06919529d7d9305c94c5ebcf1f5a897b5",
+ "spec_checksum": "sha256:2ea5c937580b3011c022fa9fcbbd19cba6be4a38a5303c66905cb9bb736991fb",
"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/projects_avatar.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar.go
new file mode 100644
index 00000000..bb6503f1
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar.go
@@ -0,0 +1,18 @@
+// 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 (
+ "github.com/spf13/cobra"
+)
+
+func newProjectsAvatarCmd(flags *rootFlags) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "avatar",
+ Short: "Manage avatar",
+ }
+
+ cmd.AddCommand(newProjectsAvatarUploadProjectCmd(flags))
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go
new file mode 100644
index 00000000..9bf61373
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_avatar_upload-project.go
@@ -0,0 +1,121 @@
+// 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 newProjectsAvatarUploadProjectCmd(flags *rootFlags) *cobra.Command {
+ var flagOverwrite bool
+ var bodyCaption string
+ var bodyFile string
+
+ cmd := &cobra.Command{
+ Use: "upload-project <projectId>",
+ Aliases: []string{"update"},
+ Short: "Upload project avatar",
+ Example: " printing-press-golden-pp-cli projects avatar upload-project 550e8400-e29b-41d4-a716-446655440000",
+ Annotations: map[string]string{"pp:endpoint": "avatar.upload-project", "pp:method": "PUT", "pp:path": "/projects/{projectId}/avatar"},
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return cmd.Help()
+ }
+ c, err := flags.newClient()
+ if err != nil {
+ return err
+ }
+
+ path := "/projects/{projectId}/avatar"
+ path = replacePathParam(path, "projectId", args[0])
+ fields := map[string]string{}
+ fileFields := map[string]string{}
+ if bodyCaption != "" {
+ fields["caption"] = bodyCaption
+ }
+ if bodyFile != "" {
+ fileFields["file"] = bodyFile
+ }
+
+ params := map[string]string{}
+ if flagOverwrite != false {
+ params["overwrite"] = fmt.Sprintf("%v", flagOverwrite)
+ }
+ data, statusCode, err := c.PutMultipartWithParams(path, params, fields, fileFields)
+ if err != nil {
+ return classifyAPIError(err, flags)
+ }
+ if wantsHumanTable(cmd.OutOrStdout(), flags) {
+ // Check if response contains an array (directly or wrapped in "data")
+ var items []map[string]any
+ if json.Unmarshal(data, &items) == nil && len(items) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), items); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
+ } else {
+ return nil
+ }
+ } else {
+ var wrapped struct {
+ Data []map[string]any `json:"data"`
+ }
+ if json.Unmarshal(data, &wrapped) == nil && len(wrapped.Data) > 0 {
+ if err := printAutoTable(cmd.OutOrStdout(), wrapped.Data); err != nil {
+ fmt.Fprintf(os.Stderr, "warning: table rendering failed, falling back to JSON: %v\n", err)
+ } else {
+ return nil
+ }
+ }
+ }
+ }
+ if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) {
+ if flags.quiet {
+ return nil
+ }
+ // Apply --compact and --select to the API response before wrapping.
+ // --select wins when both are set: explicit field choice trumps the
+ // generic high-gravity allow-list. Otherwise --compact still applies
+ // when --agent is on but the user did not name fields.
+ filtered := data
+ if flags.selectFields != "" {
+ filtered = filterFields(filtered, flags.selectFields)
+ } else if flags.compact {
+ filtered = compactFields(filtered)
+ }
+ envelope := map[string]any{
+ "action": "put",
+ "resource": "avatar",
+ "path": path,
+ "status": statusCode,
+ "success": statusCode >= 200 && statusCode < 300,
+ }
+ if flags.dryRun {
+ envelope["dry_run"] = true
+ envelope["status"] = 0
+ envelope["success"] = false
+ }
+ if len(filtered) > 0 {
+ var parsed any
+ if err := json.Unmarshal(filtered, &parsed); err == nil {
+ envelope["data"] = parsed
+ }
+ }
+ envelopeJSON, err := json.Marshal(envelope)
+ if err != nil {
+ return err
+ }
+ return printOutput(cmd.OutOrStdout(), json.RawMessage(envelopeJSON), true)
+ }
+ return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
+ },
+ }
+ cmd.Flags().BoolVar(&flagOverwrite, "overwrite", false, "Overwrite")
+ cmd.Flags().StringVar(&bodyCaption, "caption", "", "Caption")
+ cmd.Flags().StringVar(&bodyFile, "file", "", "File")
+
+ return cmd
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
index 8ea8114a..86421865 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
@@ -13,6 +13,7 @@ import (
)
func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
+ var flagNotify bool
var bodyCompleted bool
var bodyPriority string
var bodyTitle string
@@ -64,7 +65,11 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
body["title"] = bodyTitle
}
}
- data, statusCode, err := c.Patch(path, body)
+ params := map[string]string{}
+ if flagNotify != false {
+ params["notify"] = fmt.Sprintf("%v", flagNotify)
+ }
+ data, statusCode, err := c.PatchWithParams(path, params, body)
if err != nil {
return classifyAPIError(err, flags)
}
@@ -131,6 +136,7 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
return printOutputWithFlags(cmd.OutOrStdout(), data, flags)
},
}
+ cmd.Flags().BoolVar(&flagNotify, "notify", false, "Notify")
cmd.Flags().BoolVar(&bodyCompleted, "completed", false, "Completed")
cmd.Flags().StringVar(&bodyPriority, "priority", "", "Priority")
cmd.Flags().StringVar(&bodyTitle, "title", "", "Title")
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 78de9fa7..dff2772c 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
@@ -920,6 +920,8 @@ func upsertSingleObject(db *store.Store, resource string, data json.RawMessage)
switch resource {
case "projects":
return db.UpsertProjects(data)
+ case "avatar":
+ return db.UpsertAvatar(data)
case "tasks":
return db.UpsertTasks(data)
case "summary":
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 5692afb3..562f0a79 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
@@ -31,6 +31,7 @@ var whichIndex = []whichEntry{
{Command: "projects create", Description: "Create project", Group: "projects"},
{Command: "projects get", Description: "Get project", Group: "projects"},
{Command: "projects list", Description: "List projects", Group: "projects"},
+ {Command: "projects avatar upload-project", Description: "Upload project avatar", Group: "projects"},
{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"},
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 52ed9525..4f78b1b9 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
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"math"
+ "mime/multipart"
"net/http"
"os"
"path/filepath"
@@ -150,6 +151,29 @@ func (c *Client) PostWithHeaders(path string, body any, headers map[string]strin
return c.do("POST", path, nil, body, headers)
}
+func (c *Client) PostWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, nil)
+}
+
+func (c *Client) PostWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, headers)
+}
+func (c *Client) PostMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PostMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+func (c *Client) PostMultipartWithParams(path string, params map[string]string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PostMultipartWithParamsAndHeaders(path string, params map[string]string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
return c.do("DELETE", path, nil, nil, nil)
}
@@ -158,6 +182,14 @@ func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json
return c.do("DELETE", path, nil, nil, headers)
}
+func (c *Client) DeleteWithParams(path string, params map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, nil)
+}
+
+func (c *Client) DeleteWithParamsAndHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, headers)
+}
+
func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
return c.do("PUT", path, nil, body, nil)
}
@@ -166,6 +198,29 @@ func (c *Client) PutWithHeaders(path string, body any, headers map[string]string
return c.do("PUT", path, nil, body, headers)
}
+func (c *Client) PutWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, nil)
+}
+
+func (c *Client) PutWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, headers)
+}
+func (c *Client) PutMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PutMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+func (c *Client) PutMultipartWithParams(path string, params map[string]string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PutMultipartWithParamsAndHeaders(path string, params map[string]string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
return c.do("PATCH", path, nil, body, nil)
}
@@ -174,18 +229,94 @@ func (c *Client) PatchWithHeaders(path string, body any, headers map[string]stri
return c.do("PATCH", path, nil, body, headers)
}
+func (c *Client) PatchWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, nil)
+}
+
+func (c *Client) PatchWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, headers)
+}
+func (c *Client) PatchMultipart(path string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PatchMultipartWithHeaders(path string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, nil, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+func (c *Client) PatchMultipartWithParams(path string, params map[string]string, fields map[string]string, fileFields map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, nil)
+}
+
+func (c *Client) PatchMultipartWithParamsAndHeaders(path string, params map[string]string, fields map[string]string, fileFields map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, multipartRequestBody{Fields: fields, FileFields: fileFields}, headers)
+}
+
+type multipartRequestBody struct {
+ Fields map[string]string
+ FileFields map[string]string
+}
+
+func encodeMultipartBody(body multipartRequestBody) ([]byte, string, error) {
+ var buf bytes.Buffer
+ writer := multipart.NewWriter(&buf)
+ for fieldName, value := range body.Fields {
+ if err := writer.WriteField(fieldName, value); err != nil {
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("writing multipart field %q: %w", fieldName, err)
+ }
+ }
+ for fieldName, filePath := range body.FileFields {
+ file, err := os.Open(filePath)
+ if err != nil {
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("opening multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ part, err := writer.CreateFormFile(fieldName, filepath.Base(filePath))
+ if err != nil {
+ _ = file.Close()
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("creating multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ if _, err := io.Copy(part, file); err != nil {
+ _ = file.Close()
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("copying multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ if err := file.Close(); err != nil {
+ _ = writer.Close()
+ return nil, "", fmt.Errorf("closing multipart file field %q (%q): %w", fieldName, filePath, err)
+ }
+ }
+ if err := writer.Close(); err != nil {
+ return nil, "", fmt.Errorf("finalizing multipart body: %w", err)
+ }
+ return buf.Bytes(), writer.FormDataContentType(), nil
+}
+
// do executes an HTTP request. headerOverrides, when non-nil, override global
// RequiredHeaders for this specific request (used for per-endpoint API versioning).
func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
targetURL := c.BaseURL + path
var bodyBytes []byte
+ var contentType string
if body != nil {
- b, err := json.Marshal(body)
- if err != nil {
- return nil, 0, fmt.Errorf("marshaling body: %w", err)
+ if multipartBody, ok := body.(multipartRequestBody); ok {
+ b, ct, err := encodeMultipartBody(multipartBody)
+ if err != nil {
+ return nil, 0, err
+ }
+ bodyBytes = b
+ contentType = ct
+ } else {
+ b, err := json.Marshal(body)
+ if err != nil {
+ return nil, 0, fmt.Errorf("marshaling body: %w", err)
+ }
+ bodyBytes = b
+ contentType = "application/json"
}
- bodyBytes = b
}
// Resolve auth material before the dry-run branch so --dry-run can preview
@@ -218,7 +349,7 @@ func (c *Client) do(method, path string, params map[string]string, body any, hea
return nil, 0, fmt.Errorf("creating request: %w", err)
}
if bodyBytes != nil {
- req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Content-Type", contentType)
}
if params != nil {
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 f48f6e6f..ac0cb42d 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
@@ -66,6 +66,17 @@ func RegisterTools(s *server.MCPServer) {
),
makeAPIHandler("GET", "/projects", []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",
+ mcplib.WithDescription("Upload project avatar. Required: projectId. Optional: overwrite, caption, file."),
+ mcplib.WithString("projectId", mcplib.Required(), mcplib.Description("Project id")),
+ mcplib.WithString("overwrite", mcplib.Description("Overwrite")),
+ mcplib.WithString("caption", mcplib.Description("Caption")),
+ mcplib.WithString("file", mcplib.Description("File")),
+ mcplib.WithOpenWorldHintAnnotation(true),
+ ),
+ makeAPIHandler("PUT", "/projects/{projectId}/avatar", []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",
mcplib.WithDescription("List project tasks. Required: projectId. Optional: priority, limit (default: 50), cursor. Returns array of Task."),
@@ -81,15 +92,16 @@ func RegisterTools(s *server.MCPServer) {
)
s.AddTool(
mcplib.NewTool("projects_tasks_update-project",
- mcplib.WithDescription("Update project task. Required: projectId, taskId. Optional: completed, priority, title. Partial update."),
+ mcplib.WithDescription("Update project task. Required: projectId, taskId. Optional: notify, completed, priority (plus 1 more). Partial update."),
mcplib.WithString("projectId", mcplib.Required(), mcplib.Description("Project id")),
mcplib.WithString("taskId", mcplib.Required(), mcplib.Description("Task id")),
+ mcplib.WithString("notify", mcplib.Description("Notify")),
mcplib.WithString("completed", mcplib.Description("Completed")),
mcplib.WithString("priority", mcplib.Description("Priority")),
mcplib.WithString("title", mcplib.Description("Title")),
mcplib.WithOpenWorldHintAnnotation(true),
),
- makeAPIHandler("PATCH", "/projects/{projectId}/tasks/{taskId}", []mcpParamBinding{{PublicName: "projectId", WireName: "projectId", Location: "path"}, {PublicName: "taskId", WireName: "taskId", Location: "path"}, {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}", []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",
@@ -149,9 +161,21 @@ func RegisterTools(s *server.MCPServer) {
}
type mcpParamBinding struct {
- PublicName string
- WireName string
- Location string
+ PublicName string
+ WireName string
+ Location string
+ Format string
+ RequestContentType string
+}
+
+func mcpMultipartFieldValue(v any) string {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ if data, err := json.Marshal(v); err == nil {
+ return string(data)
+ }
+ return fmt.Sprintf("%v", v)
}
// makeAPIHandler creates a generic MCP tool handler for an API endpoint.
@@ -175,8 +199,14 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
pathParams := make(map[string]bool, len(positionalParams))
params := make(map[string]string)
bodyArgs := make(map[string]any)
+ multipartFields := make(map[string]string)
+ multipartFileFields := make(map[string]string)
+ multipart := false
for _, binding := range bindings {
knownArgs[binding.PublicName] = true
+ if strings.EqualFold(binding.RequestContentType, "multipart/form-data") {
+ multipart = true
+ }
v, ok := args[binding.PublicName]
if !ok {
continue
@@ -188,6 +218,13 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
path = strings.Replace(path, placeholder, fmt.Sprintf("%v", v), 1)
case "body":
bodyArgs[binding.WireName] = v
+ if multipart {
+ if strings.EqualFold(binding.Format, "binary") {
+ multipartFileFields[binding.WireName] = fmt.Sprintf("%v", v)
+ } else {
+ multipartFields[binding.WireName] = mcpMultipartFieldValue(v)
+ }
+ }
default:
params[binding.WireName] = fmt.Sprintf("%v", v)
}
@@ -210,6 +247,9 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
switch method {
case "POST", "PUT", "PATCH":
bodyArgs[k] = v
+ if multipart {
+ multipartFields[k] = mcpMultipartFieldValue(v)
+ }
default:
params[k] = fmt.Sprintf("%v", v)
}
@@ -220,16 +260,28 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
case "GET":
data, err = c.Get(path, params)
case "POST":
+ if multipart {
+ data, _, err = c.PostMultipartWithParams(path, params, multipartFields, multipartFileFields)
+ break
+ }
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Post(path, body)
+ data, _, err = c.PostWithParams(path, params, body)
case "PUT":
+ if multipart {
+ data, _, err = c.PutMultipartWithParams(path, params, multipartFields, multipartFileFields)
+ break
+ }
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Put(path, body)
+ data, _, err = c.PutWithParams(path, params, body)
case "PATCH":
+ if multipart {
+ data, _, err = c.PatchMultipartWithParams(path, params, multipartFields, multipartFileFields)
+ break
+ }
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Patch(path, body)
+ data, _, err = c.PatchWithParams(path, params, body)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, params)
default:
return mcplib.NewToolResultError("unsupported method: " + method), nil
}
@@ -439,7 +491,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": 8,
+ "tool_count": 9,
// 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 9f2f4e95..cf1a6a8a 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: 8 tools (1 public, 7 auth-required) — readiness: full"
+ "MCP: 9 tools (1 public, 8 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 c8e7b386..a67ac1ef 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
@@ -202,6 +202,10 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
}
}
+ // Code-orch dispatch has no schema metadata at runtime to bucket params
+ // into body vs query, so POST/PUT/PATCH keep the everything-to-body
+ // design. DELETE is the one verb where query routing is unambiguous; the
+ // previous code built `query` but never passed it.
query := map[string]string{}
if ep.Method == "GET" || ep.Method == "DELETE" {
for k, v := range params {
@@ -214,7 +218,7 @@ func handleCodeOrchExecute(ctx context.Context, req mcplib.CallToolRequest) (*mc
case "GET":
data, err = c.Get(path, query)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, query)
default:
body, mErr := json.Marshal(params)
if mErr != 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 c5f8efeb..6cd93040 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
@@ -127,15 +127,15 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
data, err = c.Get(path, params)
case "POST":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Post(path, body)
+ data, _, err = c.PostWithParams(path, params, body)
case "PUT":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Put(path, body)
+ data, _, err = c.PutWithParams(path, params, body)
case "PATCH":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Patch(path, body)
+ data, _, err = c.PatchWithParams(path, params, body)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, params)
default:
return mcplib.NewToolResultError("unsupported method: " + method), nil
}
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 8401c7f6..dc580b5b 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
@@ -132,15 +132,15 @@ func makeAPIHandler(method, pathTemplate string, bindings []mcpParamBinding, pos
data, err = c.Get(path, params)
case "POST":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Post(path, body)
+ data, _, err = c.PostWithParams(path, params, body)
case "PUT":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Put(path, body)
+ data, _, err = c.PutWithParams(path, params, body)
case "PATCH":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Patch(path, body)
+ data, _, err = c.PatchWithParams(path, params, body)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, params)
default:
return mcplib.NewToolResultError("unsupported method: " + method), nil
}
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 c9d1c95f..01454246 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
@@ -246,6 +246,14 @@ func (c *Client) PostWithHeaders(path string, body any, headers map[string]strin
return c.do("POST", path, nil, body, headers)
}
+func (c *Client) PostWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, nil)
+}
+
+func (c *Client) PostWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("POST", path, params, body, headers)
+}
+
func (c *Client) Delete(path string) (json.RawMessage, int, error) {
return c.do("DELETE", path, nil, nil, nil)
}
@@ -254,6 +262,14 @@ func (c *Client) DeleteWithHeaders(path string, headers map[string]string) (json
return c.do("DELETE", path, nil, nil, headers)
}
+func (c *Client) DeleteWithParams(path string, params map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, nil)
+}
+
+func (c *Client) DeleteWithParamsAndHeaders(path string, params map[string]string, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("DELETE", path, params, nil, headers)
+}
+
func (c *Client) Put(path string, body any) (json.RawMessage, int, error) {
return c.do("PUT", path, nil, body, nil)
}
@@ -262,6 +278,14 @@ func (c *Client) PutWithHeaders(path string, body any, headers map[string]string
return c.do("PUT", path, nil, body, headers)
}
+func (c *Client) PutWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, nil)
+}
+
+func (c *Client) PutWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PUT", path, params, body, headers)
+}
+
func (c *Client) Patch(path string, body any) (json.RawMessage, int, error) {
return c.do("PATCH", path, nil, body, nil)
}
@@ -270,6 +294,14 @@ func (c *Client) PatchWithHeaders(path string, body any, headers map[string]stri
return c.do("PATCH", path, nil, body, headers)
}
+func (c *Client) PatchWithParams(path string, params map[string]string, body any) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, nil)
+}
+
+func (c *Client) PatchWithParamsAndHeaders(path string, params map[string]string, body any, headers map[string]string) (json.RawMessage, int, error) {
+ return c.do("PATCH", path, params, body, headers)
+}
+
// do executes an HTTP request. headerOverrides, when non-nil, override global
// RequiredHeaders for this specific request (used for per-endpoint API versioning).
func (c *Client) do(method, path string, params map[string]string, body any, headerOverrides map[string]string) (json.RawMessage, int, error) {
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 f468b8b1..7522b562 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
@@ -152,15 +152,15 @@ func makeAPIHandler(method, pathTemplate, tier string, bindings []mcpParamBindin
data, err = c.Get(path, params)
case "POST":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Post(path, body)
+ data, _, err = c.PostWithParams(path, params, body)
case "PUT":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Put(path, body)
+ data, _, err = c.PutWithParams(path, params, body)
case "PATCH":
body, _ := json.Marshal(bodyArgs)
- data, _, err = c.Patch(path, body)
+ data, _, err = c.PatchWithParams(path, params, body)
case "DELETE":
- data, _, err = c.Delete(path)
+ data, _, err = c.DeleteWithParams(path, params)
default:
return mcplib.NewToolResultError("unsupported method: " + method), nil
}
diff --git a/testdata/golden/fixtures/golden-api.yaml b/testdata/golden/fixtures/golden-api.yaml
index 858b1567..ab2891db 100644
--- a/testdata/golden/fixtures/golden-api.yaml
+++ b/testdata/golden/fixtures/golden-api.yaml
@@ -242,6 +242,14 @@ paths:
required: true
schema:
type: string
+ # Query param on a non-GET endpoint — regression guard: the
+ # PUT/DELETE/POST/PATCH templates used to drop in:query params on
+ # the floor instead of routing them through the URL query string.
+ - name: notify
+ in: query
+ required: false
+ schema:
+ type: boolean
requestBody:
required: true
content:
@@ -251,6 +259,41 @@ paths:
responses:
"200":
description: OK
+ # /projects/{projectId}/avatar exercises the multipart-body + in:query
+ # combo. Regression guard: the PUT/PATCH/POST multipart branches used to
+ # route only the body fields and silently dropped any query flag.
+ /projects/{projectId}/avatar:
+ put:
+ tags: [projects]
+ operationId: uploadProjectAvatar
+ summary: Upload project avatar
+ parameters:
+ - $ref: "#/components/parameters/ApiVersion"
+ - name: projectId
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: overwrite
+ in: query
+ required: false
+ schema:
+ type: boolean
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ properties:
+ file:
+ type: string
+ format: binary
+ caption:
+ type: string
+ responses:
+ "200":
+ description: OK
# /currencies has no x-resource-id and the response schema has neither `id`
# nor `name` — exercises U3's runtime safety-net path: the generic fallback
# list at UpsertBatch resolves the PK via `code`.
← f5348488 fix(cli): gate provenance diagnostic on wantsHumanTable (#12
·
back to Cli Printing Press
·
fix(cli): hide const-default query flags from --help (#1287) ee3638b2 →