[object Object]

← 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

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 →