← back to Cli Printing Press
fix(cli): emit mcp:read-only + plumb body fields in promoted commands (#426, #427) (#449)
6aee620512ba397f65f8217247515fb77a8452ba · 2026-04-30 15:45:31 -0700 · Trevin Chow
* fix(cli): emit mcp:read-only and plumb body fields in promoted commands
Closes #426 and #427 — both close out the POST-as-query story from
retro #423 WU-1 and touch the same generator/template surface, so they
land together.
#426: emit mcp:read-only annotation on read-classified endpoints
- Plumb IsReadOnly = !endpointIsWriteCommand(endpoint, name) through
endpointTemplateData and the promoted-command anonymous struct.
- command_endpoint.go.tmpl and command_promoted.go.tmpl now emit
Annotations["mcp:read-only"] = "true" when IsReadOnly is true.
- Net effect: the cobratree runtime walker, which already keys on
this annotation via isMCPReadOnly, now correctly marks POST-as-
query endpoints (POST /search-all, POST /graphql, etc.) with the
MCP readOnlyHint. Hosts skip the per-call permission prompt for
reads and continue to require it for genuine mutations.
- Existing TestEndpointIsWriteCommand classifier coverage carries
forward; new TestMCPReadOnlyAnnotationEmission table-tests the
annotation emission across POST-search, GET, POST-create, and
DELETE shapes — annotation present iff classifier returns read.
#427: plumb body fields as flags in promoted commands
- command_promoted.go.tmpl now declares a body* var and registers a
--field flag per Endpoint.Body entry, mirroring the typed-endpoint
template's pattern. Required-body checks happen in RunE (verify-
friendly, no MarkFlagRequired).
- The POST/PUT/PATCH branch builds a JSON body map from the body*
flags and passes it to c.Post/Put/Patch — instead of forwarding
`params` (which only carried URL/query/path params) as the request
body. Before this change, every spec-declared body field was
silently dropped; promoted commands could never actually invoke a
mutation correctly.
- Object/array body fields parse as JSON (matching typed template's
shape); scalar fields go through directly. Path/query params still
flow through `path :=` substitution and `params :=` for GET.
- The unconditional `params :=` declaration is now gated to
GET/HEAD/OPTIONS branches that actually use it, preventing a
declared-and-not-used compile error on body-only POST endpoints.
New TestPromotedCommandPlumbsBodyFields asserts the four key
emission contracts: per-field var declaration, per-field flag
registration, in-RunE required check, and body-not-params passed
to the client.
Side effect on internal/pipeline/mcpsync: the endpoint annotation
regex and one test assertion now accept the optional
`, "mcp:read-only": "true"` suffix on the same Annotations line.
mcp-sync continues to backfill any unannotated endpoint command,
just with the slightly wider expected pattern.
Golden fixtures refreshed for the 3 new annotation sites in
generate-golden-api emitted code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): extract endpointIsReadCommand helper
Findings from /simplify on PR #449.
The IsReadOnly field in endpointTemplateData and the promoted-command
anonymous struct was assigned via !endpointIsWriteCommand(...) at three
call sites. The double-take of "read-only command = NOT a write command"
made the call sites read awkwardly, and a future definition shift (e.g.
a new annotation that forces read-only regardless of verb) would need
three coordinated updates.
Extract a one-line endpointIsReadCommand wrapper next to the existing
endpointIsWriteCommand. The three call sites now read positively
(IsReadOnly: endpointIsReadCommand(...)) and the negation lives in one
place.
Skipped from /simplify findings:
- Body-map duplication (now 4 sites across 2 templates after #449
added the 4th). Real but bounded: extracting to a {{define}} partial
needs parsing-architecture changes since templates are parsed
independently today. Filed as #450.
- Caching endpointIsWriteCommand on spec.Endpoint — function is cheap,
caching adds invalidation surface.
- Test brittleness in TestPromotedCommandPlumbsBodyFields — agent
flagged 8 require.Contains assertions but allowed they're acceptable
at current scale.
- "mcp:read-only" string literal — established pattern across ~10
template sites; importing cobratree's const would create a worse
coupling than the literal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M internal/generator/endpoint_is_write_test.goM internal/generator/generator.goM internal/generator/templates/command_endpoint.go.tmplM internal/generator/templates/command_promoted.go.tmplM internal/pipeline/mcpsync/sync.goM internal/pipeline/mcpsync/sync_test.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
Diff
commit 6aee620512ba397f65f8217247515fb77a8452ba
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu Apr 30 15:45:31 2026 -0700
fix(cli): emit mcp:read-only + plumb body fields in promoted commands (#426, #427) (#449)
* fix(cli): emit mcp:read-only and plumb body fields in promoted commands
Closes #426 and #427 — both close out the POST-as-query story from
retro #423 WU-1 and touch the same generator/template surface, so they
land together.
#426: emit mcp:read-only annotation on read-classified endpoints
- Plumb IsReadOnly = !endpointIsWriteCommand(endpoint, name) through
endpointTemplateData and the promoted-command anonymous struct.
- command_endpoint.go.tmpl and command_promoted.go.tmpl now emit
Annotations["mcp:read-only"] = "true" when IsReadOnly is true.
- Net effect: the cobratree runtime walker, which already keys on
this annotation via isMCPReadOnly, now correctly marks POST-as-
query endpoints (POST /search-all, POST /graphql, etc.) with the
MCP readOnlyHint. Hosts skip the per-call permission prompt for
reads and continue to require it for genuine mutations.
- Existing TestEndpointIsWriteCommand classifier coverage carries
forward; new TestMCPReadOnlyAnnotationEmission table-tests the
annotation emission across POST-search, GET, POST-create, and
DELETE shapes — annotation present iff classifier returns read.
#427: plumb body fields as flags in promoted commands
- command_promoted.go.tmpl now declares a body* var and registers a
--field flag per Endpoint.Body entry, mirroring the typed-endpoint
template's pattern. Required-body checks happen in RunE (verify-
friendly, no MarkFlagRequired).
- The POST/PUT/PATCH branch builds a JSON body map from the body*
flags and passes it to c.Post/Put/Patch — instead of forwarding
`params` (which only carried URL/query/path params) as the request
body. Before this change, every spec-declared body field was
silently dropped; promoted commands could never actually invoke a
mutation correctly.
- Object/array body fields parse as JSON (matching typed template's
shape); scalar fields go through directly. Path/query params still
flow through `path :=` substitution and `params :=` for GET.
- The unconditional `params :=` declaration is now gated to
GET/HEAD/OPTIONS branches that actually use it, preventing a
declared-and-not-used compile error on body-only POST endpoints.
New TestPromotedCommandPlumbsBodyFields asserts the four key
emission contracts: per-field var declaration, per-field flag
registration, in-RunE required check, and body-not-params passed
to the client.
Side effect on internal/pipeline/mcpsync: the endpoint annotation
regex and one test assertion now accept the optional
`, "mcp:read-only": "true"` suffix on the same Annotations line.
mcp-sync continues to backfill any unannotated endpoint command,
just with the slightly wider expected pattern.
Golden fixtures refreshed for the 3 new annotation sites in
generate-golden-api emitted code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(cli): extract endpointIsReadCommand helper
Findings from /simplify on PR #449.
The IsReadOnly field in endpointTemplateData and the promoted-command
anonymous struct was assigned via !endpointIsWriteCommand(...) at three
call sites. The double-take of "read-only command = NOT a write command"
made the call sites read awkwardly, and a future definition shift (e.g.
a new annotation that forces read-only regardless of verb) would need
three coordinated updates.
Extract a one-line endpointIsReadCommand wrapper next to the existing
endpointIsWriteCommand. The three call sites now read positively
(IsReadOnly: endpointIsReadCommand(...)) and the negation lives in one
place.
Skipped from /simplify findings:
- Body-map duplication (now 4 sites across 2 templates after #449
added the 4th). Real but bounded: extracting to a {{define}} partial
needs parsing-architecture changes since templates are parsed
independently today. Filed as #450.
- Caching endpointIsWriteCommand on spec.Endpoint — function is cheap,
caching adds invalidation surface.
- Test brittleness in TestPromotedCommandPlumbsBodyFields — agent
flagged 8 require.Contains assertions but allowed they're acceptable
at current scale.
- "mcp:read-only" string literal — established pattern across ~10
template sites; importing cobratree's const would create a worse
coupling than the literal.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
internal/generator/endpoint_is_write_test.go | 162 +++++++++++++++++++++
internal/generator/generator.go | 20 +++
.../generator/templates/command_endpoint.go.tmpl | 2 +-
.../generator/templates/command_promoted.go.tmpl | 51 ++++++-
internal/pipeline/mcpsync/sync.go | 2 +-
internal/pipeline/mcpsync/sync_test.go | 2 +-
.../internal/cli/projects_list.go | 2 +-
.../internal/cli/projects_tasks_list-project.go | 2 +-
.../internal/cli/promoted_public.go | 2 +-
9 files changed, 234 insertions(+), 11 deletions(-)
diff --git a/internal/generator/endpoint_is_write_test.go b/internal/generator/endpoint_is_write_test.go
index 818d3798..ed02dd01 100644
--- a/internal/generator/endpoint_is_write_test.go
+++ b/internal/generator/endpoint_is_write_test.go
@@ -369,3 +369,165 @@ func TestHasWriteCommands_GenuineMutationsStayTrue(t *testing.T) {
assert.True(t, hasWriteCommands(resources),
"a POST endpoint with a write-shape operationId and body should classify as write")
}
+
+// TestMCPReadOnlyAnnotationEmission verifies the cobratree readOnlyHint
+// cascade. When endpointIsWriteCommand returns false (POST search,
+// POST GraphQL, etc.), the generated cobra command must carry
+// Annotations["mcp:read-only"] = "true" so the runtime cobratree
+// walker marks the MCP tool with readOnlyHint and hosts skip the
+// per-call permission prompt. When the endpoint genuinely mutates
+// state, the annotation must be absent.
+func TestMCPReadOnlyAnnotationEmission(t *testing.T) {
+ cases := []struct {
+ name string
+ apiName string
+ resourceName string
+ endpointName string
+ endpoint spec.Endpoint
+ wantReadOnly bool
+ }{
+ {
+ name: "POST search endpoint emits mcp:read-only annotation",
+ apiName: "search-readonly",
+ resourceName: "queries",
+ endpointName: "searchAll",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/search-all",
+ Description: "Search collections by free text",
+ Body: []spec.Param{{Name: "queryText", Type: "string"}},
+ },
+ wantReadOnly: true,
+ },
+ {
+ name: "GET list endpoint emits mcp:read-only annotation",
+ apiName: "get-readonly",
+ resourceName: "items",
+ endpointName: "listItems",
+ endpoint: spec.Endpoint{
+ Method: "GET",
+ Path: "/items",
+ Description: "List items",
+ },
+ wantReadOnly: true,
+ },
+ {
+ name: "POST create endpoint omits mcp:read-only annotation",
+ apiName: "post-mutation",
+ resourceName: "users",
+ endpointName: "createUser",
+ endpoint: spec.Endpoint{
+ Method: "POST",
+ Path: "/users",
+ Description: "Create a new user",
+ Body: []spec.Param{
+ {Name: "name", Type: "string"},
+ {Name: "email", Type: "string"},
+ },
+ },
+ wantReadOnly: false,
+ },
+ {
+ name: "DELETE endpoint omits mcp:read-only annotation",
+ apiName: "delete-mutation",
+ resourceName: "users",
+ endpointName: "deleteUser",
+ endpoint: spec.Endpoint{
+ Method: "DELETE",
+ Path: "/users/{id}",
+ Description: "Delete a user",
+ },
+ wantReadOnly: false,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec(tc.apiName)
+ apiSpec.Resources = map[string]spec.Resource{
+ tc.resourceName: {
+ Description: tc.resourceName,
+ Endpoints: map[string]spec.Endpoint{tc.endpointName: tc.endpoint},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), tc.apiName+"-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ src := readPromotedCommandFile(t, outputDir)
+ marker := `"mcp:read-only": "true"`
+ if tc.wantReadOnly {
+ require.Contains(t, src, marker,
+ "%s endpoint should emit mcp:read-only annotation so the cobratree walker marks the MCP tool readOnlyHint", tc.endpointName)
+ } else {
+ require.NotContains(t, src, marker,
+ "%s endpoint must NOT emit mcp:read-only annotation — false positive would tell hosts to skip permission prompts on a real mutation", tc.endpointName)
+ }
+ })
+ }
+}
+
+// TestPromotedCommandPlumbsBodyFields verifies that promoted commands
+// emit a per-body-field flag, build a JSON body map from those flags,
+// and pass the body (not URL params) to the client's Post/Put/Patch
+// method. Before this change the promoted template forwarded `params`
+// (built from query/path Params) as the request body for non-GET
+// verbs, which silently dropped every spec-declared body field.
+func TestPromotedCommandPlumbsBodyFields(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := minimalSpec("body-plumb")
+ apiSpec.Resources = map[string]spec.Resource{
+ "widgets": {
+ Description: "widgets",
+ Endpoints: map[string]spec.Endpoint{
+ "createWidget": {
+ Method: "POST",
+ Path: "/widgets",
+ Description: "Create a widget",
+ Body: []spec.Param{
+ {Name: "name", Type: "string", Required: true},
+ {Name: "color", Type: "string"},
+ {Name: "tags", Type: "array"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), "body-plumb-pp-cli")
+ require.NoError(t, New(apiSpec, outputDir).Generate())
+
+ src := readPromotedCommandFile(t, outputDir)
+
+ // 1. Each body field declares a body* var.
+ require.Contains(t, src, "var bodyName string",
+ "promoted command must declare a body var for each body param")
+ require.Contains(t, src, "var bodyColor string")
+ require.Contains(t, src, "var bodyTags string")
+
+ // 2. Each body field registers a --flag.
+ require.Contains(t, src, `cmd.Flags().StringVar(&bodyName, "name"`,
+ "promoted command must register a flag per body param")
+ require.Contains(t, src, `cmd.Flags().StringVar(&bodyColor, "color"`)
+ require.Contains(t, src, `cmd.Flags().StringVar(&bodyTags, "tags"`)
+
+ // 3. Required body field is enforced (not via cobra's MarkFlagRequired,
+ // which breaks --dry-run probes — the verify-friendly RunE pattern
+ // uses an in-RunE check instead).
+ require.Contains(t, src, `cmd.Flags().Changed("name")`,
+ "required body field must be checked in RunE, not via MarkFlagRequired")
+
+ // 4. The RunE builds a body map from the body* vars and passes it
+ // to c.Post — not `params`, which is what the OLD template did.
+ require.Contains(t, src, `body := map[string]any{}`,
+ "promoted command must build a body map from body flags")
+ require.Contains(t, src, `body["name"] = bodyName`,
+ "body map must use the spec-declared field name, not the camelCased flag var")
+ require.Contains(t, src, `c.Post(path, body)`,
+ "promoted command must pass the body map to c.Post, not the params map")
+ require.NotContains(t, src, `c.Post(path, params)`,
+ "promoted command must NOT pass params (URL/path params) as the request body — that was the bug")
+}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index f71befd3..a900435e 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -606,6 +606,11 @@ type endpointTemplateData struct {
HasStore bool
IsAsync bool
Async AsyncJobInfo
+ // IsReadOnly mirrors !endpointIsWriteCommand(endpoint, name). The
+ // emitted command sets Annotations["mcp:read-only"] = "true" when
+ // it's true so the cobratree MCP walker marks the tool with
+ // readOnlyHint and hosts skip the per-call permission prompt.
+ IsReadOnly bool
*spec.APISpec
}
@@ -863,6 +868,17 @@ func endpointIsWriteCommand(endpoint spec.Endpoint, opName string) bool {
return !bodyIsAllFilterShape(endpoint.Body)
}
+// endpointIsReadCommand is the inverse of endpointIsWriteCommand.
+// Templates use this through the IsReadOnly field on their data
+// structs to decide whether to emit Annotations["mcp:read-only"].
+// Centralizing the negation keeps "read-only command = not a write
+// command" declared in one place; a future definition shift (e.g. a
+// new annotation that forces read-only regardless of verb) only needs
+// one update site.
+func endpointIsReadCommand(endpoint spec.Endpoint, opName string) bool {
+ return !endpointIsWriteCommand(endpoint, opName)
+}
+
// camelCaseTokens splits "getOrCreate" → ["get", "Or", "Create"] and
// "searchAll" → ["search", "All"]. Non-letter runes (digits, separators)
// stay attached to the preceding token.
@@ -1467,6 +1483,7 @@ func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool
HasStore: g.VisionSet.Store,
IsAsync: isAsync,
Async: asyncInfo,
+ IsReadOnly: endpointIsReadCommand(endpoint, eName),
APISpec: g.Spec,
}
epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+eName)+".go")
@@ -1527,6 +1544,7 @@ func (g *Generator) renderResourceCommands(promotedResourceNames map[string]bool
HasStore: g.VisionSet.Store,
IsAsync: isAsync,
Async: asyncInfo,
+ IsReadOnly: endpointIsReadCommand(endpoint, eName),
APISpec: g.Spec,
}
epPath := filepath.Join("internal", "cli", safeResourceFileStem(name+"_"+subName+"_"+eName)+".go")
@@ -1917,6 +1935,7 @@ func (g *Generator) renderPromotedCommandFiles(promotedCommands []PromotedComman
HasStore bool
Resource spec.Resource
FuncPrefix string
+ IsReadOnly bool
*spec.APISpec
}{
PromotedName: pc.PromotedName,
@@ -1926,6 +1945,7 @@ func (g *Generator) renderPromotedCommandFiles(promotedCommands []PromotedComman
HasStore: g.VisionSet.Store,
Resource: resource,
FuncPrefix: pc.ResourceName,
+ IsReadOnly: endpointIsReadCommand(pc.Endpoint, pc.EndpointName),
APISpec: g.Spec,
}
promotedPath := filepath.Join("internal", "cli", "promoted_"+pc.PromotedName+".go")
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index 137ae79c..7ba753f5 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -48,7 +48,7 @@ func new{{camel .FuncPrefix}}{{camel .EndpointName}}Cmd(flags *rootFlags) *cobra
{{- end}}
Short: "{{oneline .Endpoint.Description}}",
Example: "{{exampleLine .CommandPath .EndpointName .Endpoint}}",
- Annotations: map[string]string{"pp:endpoint": "{{.ResourceName}}.{{.EndpointName}}"},
+ Annotations: map[string]string{"pp:endpoint": "{{.ResourceName}}.{{.EndpointName}}"{{if .IsReadOnly}}, "mcp:read-only": "true"{{end}}},
RunE: func(cmd *cobra.Command, args []string) error {
{{- if positionalArgs .Endpoint}}
if len(args) == 0 {
diff --git a/internal/generator/templates/command_promoted.go.tmpl b/internal/generator/templates/command_promoted.go.tmpl
index 0d3471f9..dd756cb8 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -17,6 +17,9 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
var flag{{camel (paramIdent .)}} {{goTypeForParam .Name .Type}}
{{- end}}
{{- end}}
+{{- range .Endpoint.Body}}
+ var body{{camel (paramIdent .)}} {{goType .Type}}
+{{- end}}
{{- if .Endpoint.Pagination}}
var flagAll bool
{{- end}}
@@ -26,7 +29,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
Short: "{{oneline .Endpoint.Description}}",
Long: "Shortcut for '{{.ResourceName}} {{.EndpointName}}'. {{oneline .Endpoint.Description}}",
Example: " {{modulePath}} {{.PromotedName}}",
- Annotations: map[string]string{"pp:endpoint": "{{.ResourceName}}.{{.EndpointName}}"},
+ Annotations: map[string]string{"pp:endpoint": "{{.ResourceName}}.{{.EndpointName}}"{{if .IsReadOnly}}, "mcp:read-only": "true"{{end}}},
RunE: func(cmd *cobra.Command, args []string) error {
{{- range .Endpoint.Params}}
{{- if and .Required (not .Positional) (not .Default)}}
@@ -35,6 +38,13 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
}
{{- end}}
{{- end}}
+{{- range .Endpoint.Body}}
+{{- if and .Required (not .Default)}}
+ if !cmd.Flags().Changed("{{flagName (paramIdent .)}}") && !flags.dryRun {
+ return fmt.Errorf("required flag \"%s\" not set", "{{flagName (paramIdent .)}}")
+ }
+{{- end}}
+{{- end}}
{{- range .Endpoint.Params}}
{{- if and .Enum (not .Positional) (eq .Type "string")}}
if cmd.Flags().Changed("{{flagName (paramIdent .)}}") {
@@ -126,6 +136,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 .HasStore (eq (upper .Endpoint.Method) "GET") (eq (upper .Endpoint.Method) "HEAD") (eq (upper .Endpoint.Method) "OPTIONS")}}
params := map[string]string{}
{{- range $i, $p := .Endpoint.Params}}
{{- if and .Positional (not (pathContainsParam $.Endpoint.Path .Name))}}
@@ -137,6 +148,7 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
}
{{- end}}
{{- end}}
+{{- end}}
{{- if .HasStore}}
// resolveRead is GET-only internally, so HasStore + non-GET should
// be marked Hidden in the printed CLI and reached via the typed
@@ -147,10 +159,36 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
{{- else if eq (upper .Endpoint.Method) "DELETE"}}
data, _, err := c.Delete(path)
{{- else if or (eq (upper .Endpoint.Method) "POST") (eq (upper .Endpoint.Method) "PUT") (eq (upper .Endpoint.Method) "PATCH")}}
- // params (built from query/path Params) is sent as the request body.
- // Rich body shapes should use the typed `<resource> <endpoint>`
- // command, which declares each body field as a flag.
- data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, params)
+ // Build the JSON body from --field flags. Query/path params
+ // are intentionally not folded into the body here — the typed
+ // `<resource> <endpoint>` command is the place to combine
+ // query params with body for non-GET verbs. Promoted commands
+ // cover the common case: body-only POST/PUT/PATCH.
+ body := map[string]any{}
+{{- range .Endpoint.Body}}
+{{- if or (eq .Type "object") (eq .Type "array")}}
+ if body{{camel (paramIdent .)}} != "" {
+ var parsed{{camel (paramIdent .)}} any
+ if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+ return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
+ }
+ body["{{.Name}}"] = parsed{{camel (paramIdent .)}}
+ }
+{{- else if jsonStringParam .}}
+ if body{{camel (paramIdent .)}} != "" {
+ var parsed{{camel (paramIdent .)}} any
+ if err := json.Unmarshal([]byte(body{{camel (paramIdent .)}}), &parsed{{camel (paramIdent .)}}); err != nil {
+ return fmt.Errorf("parsing --{{flagName (paramIdent .)}} JSON: %w", err)
+ }
+ body["{{.Name}}"] = body{{camel (paramIdent .)}}
+ }
+{{- else}}
+ if body{{camel (paramIdent .)}} != {{zeroVal .Type}} {
+ body["{{.Name}}"] = body{{camel (paramIdent .)}}
+ }
+{{- end}}
+{{- end}}
+ data, _, err := c.{{pascal (lower .Endpoint.Method)}}(path, body)
{{- else}}
// HEAD/OPTIONS and unknown verbs fall back to GET so generation
// stays compileable. Spec authors who genuinely need these verbs
@@ -240,6 +278,9 @@ func new{{camel .PromotedName}}PromotedCmd(flags *rootFlags) *cobra.Command {
cmd.Flags().{{cobraFlagFuncForParam .Name .Type}}(&flag{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultValForParam .}}, "{{oneline .Description}}{{enumDescriptionHint .Enum}}")
{{- end}}
{{- end}}
+{{- range .Endpoint.Body}}
+ cmd.Flags().{{cobraFlagFunc .Type}}(&body{{camel (paramIdent .)}}, "{{flagName (paramIdent .)}}", {{defaultVal .}}, "{{oneline .Description}}")
+{{- end}}
{{- if .Endpoint.Pagination}}
cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages")
{{- end}}
diff --git a/internal/pipeline/mcpsync/sync.go b/internal/pipeline/mcpsync/sync.go
index 140967bb..6021b157 100644
--- a/internal/pipeline/mcpsync/sync.go
+++ b/internal/pipeline/mcpsync/sync.go
@@ -31,7 +31,7 @@ var (
errAnnotationSoftFail = errors.New("endpoint annotation skipped")
)
-var endpointAnnotationLine = regexp.MustCompile(`(?m)^\s*Annotations: map\[string\]string\{"pp:endpoint": "[^"]+"\},\s*$`)
+var endpointAnnotationLine = regexp.MustCompile(`(?m)^\s*Annotations: map\[string\]string\{"pp:endpoint": "[^"]+"(?:, "mcp:read-only": "true")?\},\s*$`)
type Result struct {
Changed bool
diff --git a/internal/pipeline/mcpsync/sync_test.go b/internal/pipeline/mcpsync/sync_test.go
index 9c245a52..5bfa7934 100644
--- a/internal/pipeline/mcpsync/sync_test.go
+++ b/internal/pipeline/mcpsync/sync_test.go
@@ -71,7 +71,7 @@ func RegisterNovelFeatureTools() { shellOutToCLI("projects list") }
updatedEndpoint, err := os.ReadFile(endpointPath)
require.NoError(t, err)
- assert.Contains(t, string(updatedEndpoint), `Annotations: map[string]string{"pp:endpoint": "projects.list"}`)
+ assert.Contains(t, string(updatedEndpoint), `Annotations: map[string]string{"pp:endpoint": "projects.list", "mcp:read-only": "true"}`)
updatedTools, err := os.ReadFile(filepath.Join(cliDir, "internal", "mcp", "tools.go"))
require.NoError(t, err)
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go
index 6502bb03..c25b028a 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_list.go
@@ -21,7 +21,7 @@ func newProjectsListCmd(flags *rootFlags) *cobra.Command {
Use: "list",
Short: "List projects",
Example: " printing-press-golden-pp-cli projects list",
- Annotations: map[string]string{"pp:endpoint": "projects.list"},
+ Annotations: map[string]string{"pp:endpoint": "projects.list", "mcp:read-only": "true"},
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("status") {
allowedStatus := []string{ "draft", "active", "archived" }
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go
index 6ca07192..656112f6 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_list-project.go
@@ -22,7 +22,7 @@ func newProjectsTasksListProjectCmd(flags *rootFlags) *cobra.Command {
Aliases: []string{"get"},
Short: "List project tasks",
Example: " printing-press-golden-pp-cli projects tasks list-project example-value",
- Annotations: map[string]string{"pp:endpoint": "tasks.list-project"},
+ Annotations: map[string]string{"pp:endpoint": "tasks.list-project", "mcp:read-only": "true"},
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
index c718bc6b..558b7814 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/promoted_public.go
@@ -18,7 +18,7 @@ func newPublicPromotedCmd(flags *rootFlags) *cobra.Command {
Short: "Get public service status",
Long: "Shortcut for 'public get-status'. Get public service status",
Example: " printing-press-golden-pp-cli public",
- Annotations: map[string]string{"pp:endpoint": "public.get-status"},
+ Annotations: map[string]string{"pp:endpoint": "public.get-status", "mcp:read-only": "true"},
RunE: func(cmd *cobra.Command, args []string) error {
c, err := flags.newClient()
if err != nil {
← 1e4798f1 fix(cli): migrate 15 templates off flags.printJSON + dogfood
·
back to Cli Printing Press
·
fix(skills): add Phase 3 starter templates for novel feature 00f83219 →