← back to Cli Printing Press
feat(cli): generator emits mcp:read-only on read-only novel commands (#401)
852591596a8d2f175c1a586bc554b57252b1a411 · 2026-04-29 19:53:40 -0700 · Trevin Chow
* feat(cli): generator emits mcp:read-only on read-only novel commands
When the generator emits a novel command whose only effect is reading
from the API, the local store, or the CLI tree itself, mark it
mcp:read-only so the runtime walker sets readOnlyHint on the resulting
MCP tool. MCP hosts skip the per-call permission prompt for these,
which makes agent flows substantially less noisy.
Annotated templates and their rationale:
- analytics.go.tmpl: aggregation queries on the local store
- agent_context.go.tmpl: emits CLI introspection JSON to stdout
- api_discovery.go.tmpl: walks the cobra tree and prints help
- tail.go.tmpl: polls API read endpoints, NDJSON to stdout
- jobs.go.tmpl: list and get subcommands (skip prune — mutates ledger)
- channel_workflow.go.tmpl: status subcommand (skip workflow parent
and archive — archive mutates the local store)
Templates intentionally NOT annotated, because they can mutate state
or write user-visible files outside the local cache:
- export.go.tmpl: --output writes user files
- share_commands.go.tmpl: export writes a snapshot dir, import/publish/
subscribe touch git or local cache
- import.go.tmpl, sync.go.tmpl, graphql_sync.go.tmpl, feedback.go.tmpl
Existing endpoint-mirror commands (command_endpoint, command_promoted)
already carry pp:endpoint and let the runtime walker classify by HTTP
method, so they don't need this annotation.
The TestGenerateAgentContextCommand fixture asserted exact column
alignment, which gofmt shifts when the longest field name in a
struct literal grows. Switched the impacted assertion to a regex so
future field additions don't break the test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): parameterized test locks mcp:read-only annotation contract
Add TestTemplatesEmitReadOnlyAnnotation that reads each annotated
template and counts the literal mcp:read-only annotations against the
expected number per template. Catches regressions where a future
template edit accidentally drops the annotation from any of the seven
sites this PR touches.
Document the rule in AGENTS.md "Tool safety annotations": template
authors set mcp:read-only when the rendered command's only effect is
reading from the API, the local store, or the CLI tree itself; skip
when the command can mutate state or write user-visible files outside
the local cache.
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 AGENTS.mdM internal/generator/generator_test.goM internal/generator/templates/agent_context.go.tmplM internal/generator/templates/analytics.go.tmplM internal/generator/templates/api_discovery.go.tmplM internal/generator/templates/channel_workflow.go.tmplM internal/generator/templates/jobs.go.tmplM internal/generator/templates/tail.go.tmpl
Diff
commit 852591596a8d2f175c1a586bc554b57252b1a411
Author: Trevin Chow <trevin@trevinchow.com>
Date: Wed Apr 29 19:53:40 2026 -0700
feat(cli): generator emits mcp:read-only on read-only novel commands (#401)
* feat(cli): generator emits mcp:read-only on read-only novel commands
When the generator emits a novel command whose only effect is reading
from the API, the local store, or the CLI tree itself, mark it
mcp:read-only so the runtime walker sets readOnlyHint on the resulting
MCP tool. MCP hosts skip the per-call permission prompt for these,
which makes agent flows substantially less noisy.
Annotated templates and their rationale:
- analytics.go.tmpl: aggregation queries on the local store
- agent_context.go.tmpl: emits CLI introspection JSON to stdout
- api_discovery.go.tmpl: walks the cobra tree and prints help
- tail.go.tmpl: polls API read endpoints, NDJSON to stdout
- jobs.go.tmpl: list and get subcommands (skip prune — mutates ledger)
- channel_workflow.go.tmpl: status subcommand (skip workflow parent
and archive — archive mutates the local store)
Templates intentionally NOT annotated, because they can mutate state
or write user-visible files outside the local cache:
- export.go.tmpl: --output writes user files
- share_commands.go.tmpl: export writes a snapshot dir, import/publish/
subscribe touch git or local cache
- import.go.tmpl, sync.go.tmpl, graphql_sync.go.tmpl, feedback.go.tmpl
Existing endpoint-mirror commands (command_endpoint, command_promoted)
already carry pp:endpoint and let the runtime walker classify by HTTP
method, so they don't need this annotation.
The TestGenerateAgentContextCommand fixture asserted exact column
alignment, which gofmt shifts when the longest field name in a
struct literal grows. Switched the impacted assertion to a regex so
future field additions don't break the test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(cli): parameterized test locks mcp:read-only annotation contract
Add TestTemplatesEmitReadOnlyAnnotation that reads each annotated
template and counts the literal mcp:read-only annotations against the
expected number per template. Catches regressions where a future
template edit accidentally drops the annotation from any of the seven
sites this PR touches.
Document the rule in AGENTS.md "Tool safety annotations": template
authors set mcp:read-only when the rendered command's only effect is
reading from the API, the local store, or the CLI tree itself; skip
when the command can mutate state or write user-visible files outside
the local cache.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
AGENTS.md | 2 +
internal/generator/generator_test.go | 56 +++++++++++++++++++++-
internal/generator/templates/agent_context.go.tmpl | 5 +-
internal/generator/templates/analytics.go.tmpl | 5 +-
internal/generator/templates/api_discovery.go.tmpl | 5 +-
.../generator/templates/channel_workflow.go.tmpl | 5 +-
internal/generator/templates/jobs.go.tmpl | 12 +++--
internal/generator/templates/tail.go.tmpl | 5 +-
8 files changed, 79 insertions(+), 16 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9468fc14..ac798e31 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -61,6 +61,8 @@ The generator emits annotations automatically:
- **Built-in tools:** `context`, `sql`, `search` are read-only (no open-world; they read local data).
- **Shell-out tools (runtime walker):** no annotations by default — the walker can't infer from a Cobra command alone. Opt into read-only with `cmd.Annotations["mcp:read-only"] = "true"` for novel commands that don't mutate external state (read-only API queries, cache lookups, derivations).
+When adding a generator template that emits a novel command whose only effect is reading from the API, the local store, or the CLI tree itself, set `Annotations: map[string]string{"mcp:read-only": "true"}` in the rendered Cobra literal. Skip the annotation when the command can mutate external state (writes via API, store updates, git pushes) or write to user-visible files outside the local cache (e.g., commands accepting `--output <file>` or `--repo <dir>`). The polish skill catches misses heuristically per CLI, but template-time emission is the single-source path.
+
Wrong annotations are worse than missing ones: the host trusts the claim and stops asking. A `readOnlyHint: true` on a mutating tool is a real bug; a missing annotation is just a permission prompt.
## Build, Test & Lint
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 3e46dff5..9539c404 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "regexp"
"strings"
"testing"
@@ -397,12 +398,20 @@ func TestGenerateAgentContextCommand(t *testing.T) {
"func newAgentContextCmd",
"agentContextSchemaVersion",
`"schema_version"`,
- `Use: "agent-context"`,
"collectAgentCommands",
`"pretty"`,
} {
assert.Contains(t, src, snippet, "agent_context.go missing %q", snippet)
}
+ // Field/value pair matched as a regex so column alignment from gofmt
+ // (which shifts when the longest field name in the literal grows)
+ // doesn't break the assertion.
+ assert.Regexp(t, `Use:\s+"agent-context"`, src, `agent_context.go missing Use: "agent-context"`)
+ // agent-context only reads CLI tree state and emits JSON to stdout.
+ // The runtime walker uses this annotation to set readOnlyHint on
+ // the resulting MCP tool so hosts skip the per-call permission prompt.
+ assert.Regexp(t, `Annotations:\s+map\[string\]string\{"mcp:read-only":\s*"true"\}`, src,
+ "agent_context.go must carry mcp:read-only annotation")
// The subcommand must be registered in root.go so the CLI picks it up.
rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
@@ -4102,3 +4111,48 @@ func TestToKebab_SnakeCaseInput(t *testing.T) {
})
}
}
+
+// TestTemplatesEmitReadOnlyAnnotation locks in the contract that
+// novel-command templates whose only effect is reading from the API,
+// the local store, or the CLI tree itself emit the mcp:read-only Cobra
+// annotation. The runtime walker uses this annotation to set
+// readOnlyHint on the resulting MCP tool so hosts skip the per-call
+// permission prompt. See AGENTS.md "Tool safety annotations".
+//
+// Templates intentionally NOT in this list because they mutate state
+// or write user-visible files outside the local cache:
+// export.go.tmpl (--output writes user files), share_commands.go.tmpl
+// (snapshot dirs, git pushes), import/sync/feedback/graphql_sync (writes).
+func TestTemplatesEmitReadOnlyAnnotation(t *testing.T) {
+ t.Parallel()
+ annotationRE := regexp.MustCompile(`Annotations:\s+map\[string\]string\{"mcp:read-only":\s*"true"\}`)
+
+ cases := []struct {
+ template string
+ // minMatches is the number of distinct cobra.Command literals
+ // inside the template that should carry the annotation. Templates
+ // emitting one command have minMatches=1; multi-command templates
+ // list the read-only subcommands' count.
+ minMatches int
+ why string
+ }{
+ {"analytics.go.tmpl", 1, "analytics queries on the local store"},
+ {"agent_context.go.tmpl", 1, "walks cobra tree, emits introspection JSON"},
+ {"api_discovery.go.tmpl", 1, "walks cobra tree, prints help"},
+ {"tail.go.tmpl", 1, "polls API GETs, NDJSON to stdout"},
+ {"jobs.go.tmpl", 2, "list and get; prune is omitted (mutates ledger)"},
+ {"channel_workflow.go.tmpl", 1, "status only; workflow parent and archive omitted"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.template, func(t *testing.T) {
+ t.Parallel()
+ data, err := os.ReadFile(filepath.Join("templates", tc.template))
+ require.NoError(t, err)
+ matches := annotationRE.FindAllString(string(data), -1)
+ assert.Len(t, matches, tc.minMatches,
+ "%s should carry exactly %d mcp:read-only annotation(s) — %s",
+ tc.template, tc.minMatches, tc.why)
+ })
+ }
+}
diff --git a/internal/generator/templates/agent_context.go.tmpl b/internal/generator/templates/agent_context.go.tmpl
index fe8dfa30..f1891459 100644
--- a/internal/generator/templates/agent_context.go.tmpl
+++ b/internal/generator/templates/agent_context.go.tmpl
@@ -74,8 +74,9 @@ type agentContextFlag struct {
func newAgentContextCmd(rootCmd *cobra.Command) *cobra.Command {
var pretty bool
cmd := &cobra.Command{
- Use: "agent-context",
- Short: "Emit structured JSON describing this CLI for agents",
+ Use: "agent-context",
+ Short: "Emit structured JSON describing this CLI for agents",
+ Annotations: map[string]string{"mcp:read-only": "true"},
Long: `Outputs a machine-readable description of commands, flags, and auth so
agents can introspect this CLI at runtime without parsing --help or
reading source. Schema is versioned via schema_version.`,
diff --git a/internal/generator/templates/analytics.go.tmpl b/internal/generator/templates/analytics.go.tmpl
index d86c511b..fcac487d 100644
--- a/internal/generator/templates/analytics.go.tmpl
+++ b/internal/generator/templates/analytics.go.tmpl
@@ -20,8 +20,9 @@ func newAnalyticsCmd(flags *rootFlags) *cobra.Command {
var limit int
cmd := &cobra.Command{
- Use: "analytics",
- Short: "Run analytics queries on locally synced data",
+ Use: "analytics",
+ Short: "Run analytics queries on locally synced data",
+ Annotations: map[string]string{"mcp:read-only": "true"},
Long: `Analyze locally synced data with count, group-by, and summary operations.
Data must be synced first with the sync command.`,
Example: ` # Count records by type
diff --git a/internal/generator/templates/api_discovery.go.tmpl b/internal/generator/templates/api_discovery.go.tmpl
index 987855ab..a6a4b96f 100644
--- a/internal/generator/templates/api_discovery.go.tmpl
+++ b/internal/generator/templates/api_discovery.go.tmpl
@@ -13,8 +13,9 @@ import (
func newAPICmd(flags *rootFlags) *cobra.Command {
cmd := &cobra.Command{
- Use: "api [interface]",
- Short: "Browse all API endpoints by interface name",
+ Use: "api [interface]",
+ Short: "Browse all API endpoints by interface name",
+ Annotations: map[string]string{"mcp:read-only": "true"},
Long: `Browse and call any API endpoint using the raw interface names.
The friendly top-level commands cover the most common operations.
diff --git a/internal/generator/templates/channel_workflow.go.tmpl b/internal/generator/templates/channel_workflow.go.tmpl
index fa194c3d..678289d5 100644
--- a/internal/generator/templates/channel_workflow.go.tmpl
+++ b/internal/generator/templates/channel_workflow.go.tmpl
@@ -145,8 +145,9 @@ func newWorkflowStatusCmd(flags *rootFlags) *cobra.Command {
var dbPath string
cmd := &cobra.Command{
- Use: "status",
- Short: "Show local archive status and sync state for all resources",
+ Use: "status",
+ Short: "Show local archive status and sync state for all resources",
+ Annotations: map[string]string{"mcp:read-only": "true"},
Example: ` # Show archive status
{{.Name}}-pp-cli workflow status
diff --git a/internal/generator/templates/jobs.go.tmpl b/internal/generator/templates/jobs.go.tmpl
index 61eecdcc..4bfde525 100644
--- a/internal/generator/templates/jobs.go.tmpl
+++ b/internal/generator/templates/jobs.go.tmpl
@@ -228,8 +228,9 @@ func newJobsListCmd(flags *rootFlags) *cobra.Command {
var limit int
var all bool
cmd := &cobra.Command{
- Use: "list",
- Short: "List recent async jobs",
+ Use: "list",
+ Short: "List recent async jobs",
+ Annotations: map[string]string{"mcp:read-only": "true"},
RunE: func(cmd *cobra.Command, _ []string) error {
rows, err := readJobRows()
if err != nil {
@@ -265,9 +266,10 @@ func newJobsListCmd(flags *rootFlags) *cobra.Command {
func newJobsGetCmd(flags *rootFlags) *cobra.Command {
return &cobra.Command{
- Use: "get <job-id>",
- Short: "Show the latest state row for a job",
- Args: cobra.ExactArgs(1),
+ Use: "get <job-id>",
+ Short: "Show the latest state row for a job",
+ Annotations: map[string]string{"mcp:read-only": "true"},
+ Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
rows, err := readJobRows()
if err != nil {
diff --git a/internal/generator/templates/tail.go.tmpl b/internal/generator/templates/tail.go.tmpl
index 7c4270ed..ca82bb9a 100644
--- a/internal/generator/templates/tail.go.tmpl
+++ b/internal/generator/templates/tail.go.tmpl
@@ -20,8 +20,9 @@ func newTailCmd(flags *rootFlags) *cobra.Command {
var follow bool
cmd := &cobra.Command{
- Use: "tail [resource]",
- Short: "Stream live changes by polling the API at regular intervals",
+ Use: "tail [resource]",
+ Short: "Stream live changes by polling the API at regular intervals",
+ Annotations: map[string]string{"mcp:read-only": "true"},
Long: `Tail streams live data changes by polling the API at configurable intervals.
Events are emitted as NDJSON to stdout for piping to other tools.
Gracefully shuts down on SIGTERM/SIGINT.
← 717a4de3 test(cli): cover EndpointTemplateVars runtime substitution +
·
back to Cli Printing Press
·
fix(cli): mcp-sync bumps mcp-go pin when migrating to cobrat 7670fe98 →