[object Object]

← back to Cli Printing Press

feat(cli): MCP tool surface mirrors Cobra tree at runtime, with mcp-sync backfill (#367)

358ff782ccf9240514e15f9531850c04927b08a2 · 2026-04-28 16:27:55 -0700 · Trevin Chow

* Refactor MCP mirror generation

* fix(cli): restore canonical framework command set in cobratree

Plan KTD-3 listed 16 framework commands the runtime walker must skip
(about, agent-context, api, auth, completion, doctor, export, feedback,
help, import, load, orphans, profile, reconcile, search, sql, stale,
sync, version, which, workflow, analytics). The implementation reduced
the set to {completion, help}, leaking every diagnostic, configuration,
and ops command into every printed CLI's MCP surface as a shell-out tool
duplicating the typed sync/sql/search MCP tools and adding noise like
shell-out doctor / version / agent-context.

Restores the full set with a comment explaining why each entry is
present (curated absence of operator/diagnostic surfaces) and how to
extend it (any new generator-emitted top-level command must be added
here too).

Locks the runtime-walking PASS state in dogfood-verdict-matrix's pass
fixture so future regressions in MCPSurfaceParityCheck classification
fail goldens. Adds three regression tests around mcpsync's
ensureRootCmdExport: idempotency on already-migrated root.go, refusal
on hand-edited files (marker stripped), and fail-closed behavior when
the generated Execute shape no longer matches the regex.

Adds an Agent-Native Surface section to AGENTS.md that articulates the
underlying principle: agent-facing surface != user-facing surface,
defaults flip toward exposure (per agent-native parity), curation
happens via three rules (pp:endpoint annotation, frameworkCommands
skip list, mcp:hidden per-command opt-out). The framework-set bug
shipped because the underlying principle wasn't documented; future
agents adding generator-emitted commands now have a clear rule about
the two-place update requirement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): mcp-sync handles library CLIs + drop cli_binary from MCPB schema

Three machine fixes surfaced by running mcp-sync against ESPN end-to-end
and dragging the resulting .mcpb into Claude Desktop:

1. Hand-edited file annotation backfill is now soft-fail. mcp-sync's
   ensureEndpointAnnotations previously aborted the whole migration
   when any CLI file lacked the generator's don't-edit marker. The
   walker registers any unannotated user-facing command as a shell-out
   tool regardless, so endpoint annotation is an optimization
   (typed schema) rather than a correctness requirement. Now the
   migration warns and continues; the affected commands ship as
   shell-out instead of typed.

2. mcp-sync reads the cli's go.mod and overrides the generator's
   modulePath so regenerated MCP source uses the actual import paths
   the rest of the CLI was built against. The bare default
   "<api>-pp-cli" matches standalone-publish layouts but library
   checkouts declare full repo paths
   (github.com/mvanhorn/printing-press-library/library/<cat>/<api>);
   the previous default broke every library-CLI migration at compile
   time. New Generator.ModulePath field carries the override.

3. Dropped cli_binary from MCPB manifest emission. Claude Desktop's
   v0.3 schema validator rejects unknown top-level keys with
   "Failed to preview extension: Invalid manifest: Unrecognized
   key(s) in object: 'cli_binary'", blocking drag-drop install of
   every .mcpb shipped to date. The field was documentation-only —
   the MCP server's siblingCLIPath() helper already finds the
   companion CLI binary by walking from os.Args[0]. Moved the
   binary-name source of truth from manifest.cli_binary to
   .printing-press.json's cli_name (read via the new
   ReadCLIBinaryName helper). BundleParams gains a CLIBinaryName
   field so the bundler still knows what to call the second binary
   inside the zip without round-tripping through the manifest.

Goldens regenerated to drop cli_binary from the emitted manifest.
2195 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): trim framework skip set + regen manifest.json in mcp-sync

ESPN's first drag-drop install in Claude Desktop surfaced a real
contract bug: the agent had typed sql/search MCP tools but no way
to populate the local store the agent saw an empty database and
diagnosed it correctly: "based on the about response is probably the
companion CLI's sync command — which I can't run from here."

Root cause: the framework skip set was too aggressive. It included
sync, stale, orphans, reconcile, load, export, import, workflow, and
analytics as "operator commands." Excluding sync while exposing the
typed sql tool is a broken contract — sql returns empty results until
something populates the store, and only sync does.

Trims the framework set from 22 entries to 13. Removed: sync, stale,
orphans, reconcile, load, export, import, workflow, analytics. The
runtime walker now exposes them as shell-out tools by default.
Remaining 13 entries split into two clear categories per the new
classification rule:

  - typed equivalent already covers the capability (sql, search,
    about/agent-context/api covered by typed tools);
  - non-functional via MCP (auth, completion, doctor, version,
    feedback, profile, which, help).

Also fixes a related bug surfaced by the same install: mcp-sync did
not regenerate manifest.json, so library CLIs whose stale manifests
included the now-removed cli_binary key still failed Claude Desktop's
strict schema validator. Migration now calls WriteMCPBManifest after
the MCP source rewrite so the manifest schema is current too.

AGENTS.md "Agent-Native Surface" section rewritten with the corrected
classification rule and a worked example using sync/sql to make the
"broken contract" failure mode concrete. Also flips the default
framing: "no entry in the framework set means the runtime walker
exposes it; only add to the set if you have a clear typed-equivalent
or non-functional reason." Skipping by default was the underlying
shape of this bug.

Goldens regenerated for the trimmed set; 2195 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): preserve existing manifest.json description through regeneration

ESPN's third drag-drop test surfaced that mcp-sync overwrote the rich
description from PR #145's codemod ("Live scores, standings, news, and
game history across 17 sports from ESPN") with the derived default
("ESPN API surface as MCP tools.").

Root cause: WriteMCPBManifest reads .printing-press.json, which on
older library CLIs (printed pre-2.x) has no description field. The
old codemod baked the description from registry.json directly into
manifest.json. mcp-sync's regeneration loses that path.

Fix: manifestDescription now consults the existing manifest.json on
disk when CLIManifest.Description is empty. Resolution order is now:

  .printing-press.json description
    -> existing manifest.json description (if not the derived default)
    -> derived "<displayName> API surface as MCP tools."

Detecting the derived default by string comparison prevents the
fallback chain from latching onto a stale derived form and never
advancing past it.

This also preserves any hand-edited descriptions through future
mcp-sync runs, which is a friendlier behavior than a strict
template-rerun. New prints still get the canonical
.printing-press.json source path; only library-backfill prints
benefit from the existing-manifest fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): preserve manifest display_name through regeneration

ESPN's fourth retest after the description fix showed the manifest's
display_name regressing from "ESPN" to "espn" (lowercase API slug).
Same shape of bug as the description regression: WriteMCPBManifest
reads .printing-press.json into CLIManifest, ESPN's old print left
DisplayName null, so buildMCPBManifest fell through to the
m.APIName fallback.

Mirrors the description-preservation fix from the previous commit:
when CLIManifest.DisplayName is empty, peek at the existing
manifest.json and preserve its display_name unless it's a known
derived form (the bare API slug or a title-cased slug from the older
EffectiveDisplayName fallback). Real brand names — "ESPN", "Cal.com",
"Company GOAT", "PokéAPI" — flow through; derived stand-ins don't.

Also threaded display_name preservation into mcp-sync's parsed spec
so the regenerated main_mcp.go's NewMCPServer first arg picks up the
right brand. Both surfaces (manifest.json display_name and the MCP
server protocol identity) now consistently report "ESPN".

After this fix, ESPN's bundled .mcpb shows:
  display_name: "ESPN"
  description: "Live scores, standings, news, and game history..."
  Server name (initialize response): "ESPN"
  Tool count: 41 (with sync exposed)

The library backfill PR will rely on this preservation for every CLI
whose .printing-press.json lacks DisplayName. Task #7 tracks the
parallel cleanup: backfilling spec.yaml display_name during the sweep
so the canonical source-of-truth chain is repaired permanently.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): emit MCP tool safety annotations from spec method + opt-in flag

Claude Desktop's permission UI buckets every tool as "Write/delete tools"
when annotations are missing, defaulting to per-call permission prompts.
ESPN's first install showed all 41 tools in the write/delete bucket
even though every endpoint is GET.

Two emit paths now:

1. Typed endpoint mirrors (one per spec endpoint):
   - GET   -> readOnlyHint: true, destructiveHint: false, openWorldHint: true
   - DELETE -> destructiveHint: true, openWorldHint: true
   - POST  -> destructiveHint: false, openWorldHint: true (additive)
   - PUT/PATCH -> openWorldHint: true (default destructive=true conservative)

2. Built-in MCP tools the template registers separately:
   - context, sql, search -> readOnlyHint: true, destructiveHint: false
   - These read local domain context, the local DB, or the local FTS
     index, none of which mutate external state.

3. Runtime-walker shell-out tools default to no overrides because the
   walker can't infer semantics from a Cobra command alone. Authors
   opt their command into the read-only hint via a new annotation:
   cmd.Annotations["mcp:read-only"] = "true". The walker reads it and
   emits readOnlyHint: true + destructiveHint: false. The annotation is
   parallel to mcp:hidden — same Cobra-native shape, declared at
   command construction.

mcp-go's defaults (destructiveHint: true) needed an explicit override
on every read-only tool path. Without that, Claude Desktop saw
readOnlyHint: true but ALSO destructiveHint: true and still demanded
write/delete permission. Now read-only tools cleanly carry both flags.

AGENTS.md gains a "Tool safety annotations" subsection under
Agent-Native Surface explaining the four MCP hint flags, what the
generator emits automatically, and when to use mcp:read-only.
SKILL.md Phase 3 instructs agents authoring novel commands to add the
annotation on read-only operations.

ESPN typed endpoint mirrors and built-in tools now correctly classify
as read-only after install. Shell-out novel commands (boxscore,
compare, today, etc.) still ship without the annotation because ESPN's
source predates this work — a follow-up sweep is tracked as Task #8.

Goldens regenerated; 2195 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): mcp-sync refreshes manifest even when MCP source already migrated

When mcp-sync detected the MCP source was already on the runtime walker
template, it returned "already up to date" and skipped manifest.json
regeneration entirely. That meant edits to .printing-press.json's
description, display_name, or other metadata silently failed to
propagate to the bundled manifest until someone manually deleted
manifest.json.

The migration of MCP source files (tools.go, cobratree/) and the
refresh of metadata files (manifest.json, tools-manifest.json) are two
separate concerns. mcp-sync now skips only the source migration when
already migrated, and always refreshes the metadata. Result message
distinguishes the two cases.

Also threads through the description preservation chain so the bundled
manifest tracks .printing-press.json updates.

Surfaced while updating company-goat's description from the SEC-Form-D-
narrow framing to the multi-source-synthesis framing. Without this fix,
spec.yaml updates required deleting manifest.json before re-running
mcp-sync, which is a discipline trap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): mcp-sync rewrites all bare &flags callsites, not just (&flags)

Hit while migrating hackernews. Compile failure:

  cannot use &flags (value of type **rootFlags) as *rootFlags value
  in argument to autoRefreshIfStale

Cause: mcp-sync's ensureRootCmdExport refactors `flags` from a struct
value to a *rootFlags pointer parameter, then needs to rewrite bare
`&flags` references (passing the whole struct's address) to plain
`flags` while leaving `&flags.someField` (field-address) alone. The
prior strings.ReplaceAll only matched literal `(&flags)` — single-arg
or trailing-arg callsites — and missed multi-arg shapes:

  autoRefreshIfStale(cmd.Context(), &flags, resources)   // missed
  doSomething(&flags, "x", "y")                           // missed
  finalize("a", &flags)                                   // hit (trailing)

Fix is a regex `&flags([^.\w]|$)` that matches `&flags` followed by
any non-`.`-non-word character (or end of string). Captures and
preserves the trailing character so commas and close-parens stay
where they were.

Verified:
  - `&flags.asJSON` stays untouched (field access)
  - `(&flags)`, `(&flags, x)`, `(x, &flags)`, `(x, &flags, y)` all
    rewrite cleanly
  - hackernews migration now builds (was the third real-world CLI
    surfacing this bug after pagliacci-pizza and dub didn't hit it)

Regression test added covering all four callsite shapes plus the
field-access negative case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): mcp-sync removes stale handlers.go from older templates

Older generator templates split MCP handlers across tools.go and
handlers.go (handleContext, handleSQL, handleSync, makeAPIHandler,
newMCPClient, dbPath). The current template emits everything in
tools.go. Leaving the stale handlers.go in place during regen
produces "handleContext redeclared in this block" build errors,
blocking food52's library migration.

Adds removeStaleMCPHandlersFile to mcp-sync's Sync flow, called
before GenerateMCPSurfaceOnly so the regenerated tools.go doesn't
collide with leftovers. Marker check preserves the safety net:

  - Generator-marked handlers.go -> auto-delete
  - No marker (potentially hand-edited) -> refuse, error message
    points at --force
  - Absent -> no-op

Considered upgrading to function-name fingerprinting (auto-detect
generator shape regardless of marker) but rejected: only one library
CLI (food52) currently hits this, and food52's handlers.go happens
to lack the marker because the older template version that emitted
it didn't stamp companion files consistently. Fingerprinting is ~80
LOC plus an ongoing maintenance burden (the known-helper-name set
drifts silently as generator adds new helpers). For one occurrence
the manual `rm` is cheaper than auto-detection complexity. If we
see this recur across 5+ CLIs during the library sweep, fingerprint
becomes worth it; not before.

Four regression tests cover happy path, no-op-when-absent,
hand-edit refusal, and --force override.

2200 tests pass.

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 358ff782ccf9240514e15f9531850c04927b08a2
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue Apr 28 16:27:55 2026 -0700

    feat(cli): MCP tool surface mirrors Cobra tree at runtime, with mcp-sync backfill (#367)
    
    * Refactor MCP mirror generation
    
    * fix(cli): restore canonical framework command set in cobratree
    
    Plan KTD-3 listed 16 framework commands the runtime walker must skip
    (about, agent-context, api, auth, completion, doctor, export, feedback,
    help, import, load, orphans, profile, reconcile, search, sql, stale,
    sync, version, which, workflow, analytics). The implementation reduced
    the set to {completion, help}, leaking every diagnostic, configuration,
    and ops command into every printed CLI's MCP surface as a shell-out tool
    duplicating the typed sync/sql/search MCP tools and adding noise like
    shell-out doctor / version / agent-context.
    
    Restores the full set with a comment explaining why each entry is
    present (curated absence of operator/diagnostic surfaces) and how to
    extend it (any new generator-emitted top-level command must be added
    here too).
    
    Locks the runtime-walking PASS state in dogfood-verdict-matrix's pass
    fixture so future regressions in MCPSurfaceParityCheck classification
    fail goldens. Adds three regression tests around mcpsync's
    ensureRootCmdExport: idempotency on already-migrated root.go, refusal
    on hand-edited files (marker stripped), and fail-closed behavior when
    the generated Execute shape no longer matches the regex.
    
    Adds an Agent-Native Surface section to AGENTS.md that articulates the
    underlying principle: agent-facing surface != user-facing surface,
    defaults flip toward exposure (per agent-native parity), curation
    happens via three rules (pp:endpoint annotation, frameworkCommands
    skip list, mcp:hidden per-command opt-out). The framework-set bug
    shipped because the underlying principle wasn't documented; future
    agents adding generator-emitted commands now have a clear rule about
    the two-place update requirement.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): mcp-sync handles library CLIs + drop cli_binary from MCPB schema
    
    Three machine fixes surfaced by running mcp-sync against ESPN end-to-end
    and dragging the resulting .mcpb into Claude Desktop:
    
    1. Hand-edited file annotation backfill is now soft-fail. mcp-sync's
       ensureEndpointAnnotations previously aborted the whole migration
       when any CLI file lacked the generator's don't-edit marker. The
       walker registers any unannotated user-facing command as a shell-out
       tool regardless, so endpoint annotation is an optimization
       (typed schema) rather than a correctness requirement. Now the
       migration warns and continues; the affected commands ship as
       shell-out instead of typed.
    
    2. mcp-sync reads the cli's go.mod and overrides the generator's
       modulePath so regenerated MCP source uses the actual import paths
       the rest of the CLI was built against. The bare default
       "<api>-pp-cli" matches standalone-publish layouts but library
       checkouts declare full repo paths
       (github.com/mvanhorn/printing-press-library/library/<cat>/<api>);
       the previous default broke every library-CLI migration at compile
       time. New Generator.ModulePath field carries the override.
    
    3. Dropped cli_binary from MCPB manifest emission. Claude Desktop's
       v0.3 schema validator rejects unknown top-level keys with
       "Failed to preview extension: Invalid manifest: Unrecognized
       key(s) in object: 'cli_binary'", blocking drag-drop install of
       every .mcpb shipped to date. The field was documentation-only —
       the MCP server's siblingCLIPath() helper already finds the
       companion CLI binary by walking from os.Args[0]. Moved the
       binary-name source of truth from manifest.cli_binary to
       .printing-press.json's cli_name (read via the new
       ReadCLIBinaryName helper). BundleParams gains a CLIBinaryName
       field so the bundler still knows what to call the second binary
       inside the zip without round-tripping through the manifest.
    
    Goldens regenerated to drop cli_binary from the emitted manifest.
    2195 tests pass.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): trim framework skip set + regen manifest.json in mcp-sync
    
    ESPN's first drag-drop install in Claude Desktop surfaced a real
    contract bug: the agent had typed sql/search MCP tools but no way
    to populate the local store the agent saw an empty database and
    diagnosed it correctly: "based on the about response is probably the
    companion CLI's sync command — which I can't run from here."
    
    Root cause: the framework skip set was too aggressive. It included
    sync, stale, orphans, reconcile, load, export, import, workflow, and
    analytics as "operator commands." Excluding sync while exposing the
    typed sql tool is a broken contract — sql returns empty results until
    something populates the store, and only sync does.
    
    Trims the framework set from 22 entries to 13. Removed: sync, stale,
    orphans, reconcile, load, export, import, workflow, analytics. The
    runtime walker now exposes them as shell-out tools by default.
    Remaining 13 entries split into two clear categories per the new
    classification rule:
    
      - typed equivalent already covers the capability (sql, search,
        about/agent-context/api covered by typed tools);
      - non-functional via MCP (auth, completion, doctor, version,
        feedback, profile, which, help).
    
    Also fixes a related bug surfaced by the same install: mcp-sync did
    not regenerate manifest.json, so library CLIs whose stale manifests
    included the now-removed cli_binary key still failed Claude Desktop's
    strict schema validator. Migration now calls WriteMCPBManifest after
    the MCP source rewrite so the manifest schema is current too.
    
    AGENTS.md "Agent-Native Surface" section rewritten with the corrected
    classification rule and a worked example using sync/sql to make the
    "broken contract" failure mode concrete. Also flips the default
    framing: "no entry in the framework set means the runtime walker
    exposes it; only add to the set if you have a clear typed-equivalent
    or non-functional reason." Skipping by default was the underlying
    shape of this bug.
    
    Goldens regenerated for the trimmed set; 2195 tests pass.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): preserve existing manifest.json description through regeneration
    
    ESPN's third drag-drop test surfaced that mcp-sync overwrote the rich
    description from PR #145's codemod ("Live scores, standings, news, and
    game history across 17 sports from ESPN") with the derived default
    ("ESPN API surface as MCP tools.").
    
    Root cause: WriteMCPBManifest reads .printing-press.json, which on
    older library CLIs (printed pre-2.x) has no description field. The
    old codemod baked the description from registry.json directly into
    manifest.json. mcp-sync's regeneration loses that path.
    
    Fix: manifestDescription now consults the existing manifest.json on
    disk when CLIManifest.Description is empty. Resolution order is now:
    
      .printing-press.json description
        -> existing manifest.json description (if not the derived default)
        -> derived "<displayName> API surface as MCP tools."
    
    Detecting the derived default by string comparison prevents the
    fallback chain from latching onto a stale derived form and never
    advancing past it.
    
    This also preserves any hand-edited descriptions through future
    mcp-sync runs, which is a friendlier behavior than a strict
    template-rerun. New prints still get the canonical
    .printing-press.json source path; only library-backfill prints
    benefit from the existing-manifest fallback.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): preserve manifest display_name through regeneration
    
    ESPN's fourth retest after the description fix showed the manifest's
    display_name regressing from "ESPN" to "espn" (lowercase API slug).
    Same shape of bug as the description regression: WriteMCPBManifest
    reads .printing-press.json into CLIManifest, ESPN's old print left
    DisplayName null, so buildMCPBManifest fell through to the
    m.APIName fallback.
    
    Mirrors the description-preservation fix from the previous commit:
    when CLIManifest.DisplayName is empty, peek at the existing
    manifest.json and preserve its display_name unless it's a known
    derived form (the bare API slug or a title-cased slug from the older
    EffectiveDisplayName fallback). Real brand names — "ESPN", "Cal.com",
    "Company GOAT", "PokéAPI" — flow through; derived stand-ins don't.
    
    Also threaded display_name preservation into mcp-sync's parsed spec
    so the regenerated main_mcp.go's NewMCPServer first arg picks up the
    right brand. Both surfaces (manifest.json display_name and the MCP
    server protocol identity) now consistently report "ESPN".
    
    After this fix, ESPN's bundled .mcpb shows:
      display_name: "ESPN"
      description: "Live scores, standings, news, and game history..."
      Server name (initialize response): "ESPN"
      Tool count: 41 (with sync exposed)
    
    The library backfill PR will rely on this preservation for every CLI
    whose .printing-press.json lacks DisplayName. Task #7 tracks the
    parallel cleanup: backfilling spec.yaml display_name during the sweep
    so the canonical source-of-truth chain is repaired permanently.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): emit MCP tool safety annotations from spec method + opt-in flag
    
    Claude Desktop's permission UI buckets every tool as "Write/delete tools"
    when annotations are missing, defaulting to per-call permission prompts.
    ESPN's first install showed all 41 tools in the write/delete bucket
    even though every endpoint is GET.
    
    Two emit paths now:
    
    1. Typed endpoint mirrors (one per spec endpoint):
       - GET   -> readOnlyHint: true, destructiveHint: false, openWorldHint: true
       - DELETE -> destructiveHint: true, openWorldHint: true
       - POST  -> destructiveHint: false, openWorldHint: true (additive)
       - PUT/PATCH -> openWorldHint: true (default destructive=true conservative)
    
    2. Built-in MCP tools the template registers separately:
       - context, sql, search -> readOnlyHint: true, destructiveHint: false
       - These read local domain context, the local DB, or the local FTS
         index, none of which mutate external state.
    
    3. Runtime-walker shell-out tools default to no overrides because the
       walker can't infer semantics from a Cobra command alone. Authors
       opt their command into the read-only hint via a new annotation:
       cmd.Annotations["mcp:read-only"] = "true". The walker reads it and
       emits readOnlyHint: true + destructiveHint: false. The annotation is
       parallel to mcp:hidden — same Cobra-native shape, declared at
       command construction.
    
    mcp-go's defaults (destructiveHint: true) needed an explicit override
    on every read-only tool path. Without that, Claude Desktop saw
    readOnlyHint: true but ALSO destructiveHint: true and still demanded
    write/delete permission. Now read-only tools cleanly carry both flags.
    
    AGENTS.md gains a "Tool safety annotations" subsection under
    Agent-Native Surface explaining the four MCP hint flags, what the
    generator emits automatically, and when to use mcp:read-only.
    SKILL.md Phase 3 instructs agents authoring novel commands to add the
    annotation on read-only operations.
    
    ESPN typed endpoint mirrors and built-in tools now correctly classify
    as read-only after install. Shell-out novel commands (boxscore,
    compare, today, etc.) still ship without the annotation because ESPN's
    source predates this work — a follow-up sweep is tracked as Task #8.
    
    Goldens regenerated; 2195 tests pass.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): mcp-sync refreshes manifest even when MCP source already migrated
    
    When mcp-sync detected the MCP source was already on the runtime walker
    template, it returned "already up to date" and skipped manifest.json
    regeneration entirely. That meant edits to .printing-press.json's
    description, display_name, or other metadata silently failed to
    propagate to the bundled manifest until someone manually deleted
    manifest.json.
    
    The migration of MCP source files (tools.go, cobratree/) and the
    refresh of metadata files (manifest.json, tools-manifest.json) are two
    separate concerns. mcp-sync now skips only the source migration when
    already migrated, and always refreshes the metadata. Result message
    distinguishes the two cases.
    
    Also threads through the description preservation chain so the bundled
    manifest tracks .printing-press.json updates.
    
    Surfaced while updating company-goat's description from the SEC-Form-D-
    narrow framing to the multi-source-synthesis framing. Without this fix,
    spec.yaml updates required deleting manifest.json before re-running
    mcp-sync, which is a discipline trap.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): mcp-sync rewrites all bare &flags callsites, not just (&flags)
    
    Hit while migrating hackernews. Compile failure:
    
      cannot use &flags (value of type **rootFlags) as *rootFlags value
      in argument to autoRefreshIfStale
    
    Cause: mcp-sync's ensureRootCmdExport refactors `flags` from a struct
    value to a *rootFlags pointer parameter, then needs to rewrite bare
    `&flags` references (passing the whole struct's address) to plain
    `flags` while leaving `&flags.someField` (field-address) alone. The
    prior strings.ReplaceAll only matched literal `(&flags)` — single-arg
    or trailing-arg callsites — and missed multi-arg shapes:
    
      autoRefreshIfStale(cmd.Context(), &flags, resources)   // missed
      doSomething(&flags, "x", "y")                           // missed
      finalize("a", &flags)                                   // hit (trailing)
    
    Fix is a regex `&flags([^.\w]|$)` that matches `&flags` followed by
    any non-`.`-non-word character (or end of string). Captures and
    preserves the trailing character so commas and close-parens stay
    where they were.
    
    Verified:
      - `&flags.asJSON` stays untouched (field access)
      - `(&flags)`, `(&flags, x)`, `(x, &flags)`, `(x, &flags, y)` all
        rewrite cleanly
      - hackernews migration now builds (was the third real-world CLI
        surfacing this bug after pagliacci-pizza and dub didn't hit it)
    
    Regression test added covering all four callsite shapes plus the
    field-access negative case.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): mcp-sync removes stale handlers.go from older templates
    
    Older generator templates split MCP handlers across tools.go and
    handlers.go (handleContext, handleSQL, handleSync, makeAPIHandler,
    newMCPClient, dbPath). The current template emits everything in
    tools.go. Leaving the stale handlers.go in place during regen
    produces "handleContext redeclared in this block" build errors,
    blocking food52's library migration.
    
    Adds removeStaleMCPHandlersFile to mcp-sync's Sync flow, called
    before GenerateMCPSurfaceOnly so the regenerated tools.go doesn't
    collide with leftovers. Marker check preserves the safety net:
    
      - Generator-marked handlers.go -> auto-delete
      - No marker (potentially hand-edited) -> refuse, error message
        points at --force
      - Absent -> no-op
    
    Considered upgrading to function-name fingerprinting (auto-detect
    generator shape regardless of marker) but rejected: only one library
    CLI (food52) currently hits this, and food52's handlers.go happens
    to lack the marker because the older template version that emitted
    it didn't stamp companion files consistently. Fingerprinting is ~80
    LOC plus an ongoing maintenance burden (the known-helper-name set
    drifts silently as generator adds new helpers). For one occurrence
    the manual `rm` is cheaper than auto-detection complexity. If we
    see this recur across 5+ CLIs during the library sweep, fingerprint
    becomes worth it; not before.
    
    Four regression tests cover happy path, no-op-when-absent,
    hand-edit refusal, and --force override.
    
    2200 tests pass.
    
    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                                          |  54 ++-
 docs/PIPELINE.md                                   |   2 +-
 internal/cli/bundle.go                             |  16 +-
 internal/cli/dogfood.go                            |  14 +
 internal/cli/mcp_sync.go                           |  40 ++
 internal/cli/root.go                               |   1 +
 internal/generator/generator.go                    |  99 +++--
 internal/generator/generator_test.go               |  20 +-
 internal/generator/mcp_novel_features_test.go      |  99 ++---
 .../generator/templates/cobratree/classify.go.tmpl | 107 +++++
 .../generator/templates/cobratree/cli_path.go.tmpl |  26 ++
 .../generator/templates/cobratree/names.go.tmpl    |  25 ++
 .../generator/templates/cobratree/shellout.go.tmpl | 102 +++++
 .../generator/templates/cobratree/typemap.go.tmpl  |  76 ++++
 .../generator/templates/cobratree/walker.go.tmpl   |  66 ++++
 .../generator/templates/command_endpoint.go.tmpl   |   1 +
 .../generator/templates/command_promoted.go.tmpl   |   1 +
 internal/generator/templates/main_mcp.go.tmpl      |   2 -
 internal/generator/templates/mcp_tools.go.tmpl     | 154 +++-----
 internal/generator/templates/root.go.tmpl          |  88 +++--
 internal/generator/templates/search.go.tmpl        |   1 +
 internal/pipeline/climanifest.go                   |  17 +
 internal/pipeline/dogfood.go                       |  30 ++
 internal/pipeline/mcp_surface.go                   |  73 ++++
 internal/pipeline/mcp_surface_test.go              |  40 ++
 internal/pipeline/mcpb_bundle.go                   |  16 +-
 internal/pipeline/mcpb_manifest.go                 | 100 ++++-
 internal/pipeline/mcpsync/sync.go                  | 435 +++++++++++++++++++++
 internal/pipeline/mcpsync/sync_test.go             | 352 +++++++++++++++++
 internal/pipeline/scorecard.go                     |   4 +-
 skills/printing-press-polish/SKILL.md              |   1 +
 skills/printing-press/SKILL.md                     |  12 +-
 .../golden/cases/generate-golden-api/artifacts.txt |   6 +
 .../fail-path-auth-dead.json                       |   6 +
 .../expected/dogfood-verdict-matrix/pass.json      |   5 +
 .../dogfood-verdict-matrix/warn-priority.json      |   6 +
 .../expected/generate-golden-api/dogfood.json      |   5 +
 .../cmd/printing-press-golden-pp-mcp/main.go       |   1 -
 .../internal/cli/projects_create.go                |   1 +
 .../internal/cli/projects_list.go                  |   1 +
 .../internal/cli/projects_tasks_list-project.go    |   1 +
 .../internal/cli/projects_tasks_update-project.go  |   1 +
 .../internal/cli/promoted_public.go                |   1 +
 .../printing-press-golden/internal/cli/root.go     |  82 ++--
 .../internal/mcp/cobratree/classify.go             | 107 +++++
 .../internal/mcp/cobratree/cli_path.go             |  26 ++
 .../internal/mcp/cobratree/names.go                |  25 ++
 .../internal/mcp/cobratree/shellout.go             | 102 +++++
 .../internal/mcp/cobratree/typemap.go              |  76 ++++
 .../internal/mcp/cobratree/walker.go               |  66 ++++
 .../printing-press-golden/internal/mcp/tools.go    |  53 ++-
 .../printing-press-golden/manifest.json            |   1 -
 .../expected/generate-golden-api/scorecard.json    |   7 +-
 .../pass/internal/mcp/tools.go                     |  18 +
 54 files changed, 2360 insertions(+), 311 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index 39d227ee..2cbe3d9f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -36,6 +36,54 @@ Three carve-outs are legitimate:
 
 The rule is enforced in two places. The absorb manifest has a Kill Check (see `skills/printing-press/references/absorb-scoring.md`) that rejects reimplementation candidates before they enter the feature list. Dogfood runs `reimplementation_check` over every built novel-feature command and flags any handler file that shows neither a client call nor a store access (and lacks the static-reference opt-out).
 
+## Agent-Native Surface
+
+Every printed CLI exposes two surfaces: the **CLI surface** that humans drive with shell commands, and the **MCP surface** that agents call as tools. Per the agent-native parity principle, any action a user can take should be reachable by an agent — but the surfaces are not identical. The CLI carries operator/human ergonomics that don't belong in an agent's tool catalog.
+
+### What belongs on the MCP surface
+
+The runtime walker in `internal/mcp/cobratree/` mirrors the Cobra tree at server start. Default: every user-facing command becomes an MCP tool. The walker filters via three rules, in order:
+
+1. **Endpoint mirrors keep typed schemas.** A Cobra command annotated `cmd.Annotations["pp:endpoint"] = "<resource>.<endpoint>"` is registered as a typed MCP tool by the existing template (one per spec endpoint, schema derived from spec params). The walker skips these so they aren't shell-out duplicates.
+2. **Framework commands are excluded by name.** The `frameworkCommands` set in `cobratree/classify.go.tmpl` lists generator-emitted CLI commands the walker must skip. Two cases qualify:
+   - A typed MCP tool already covers the capability (the typed schema is strictly better than a shell-out): `sql`, `search`, `about`/`agent-context` (covered by typed `context`), `api` (covered by typed endpoint tools).
+   - The command is non-functional via MCP — interactive setup, shell ergonomics, trivial introspection, local-only state: `auth`, `completion`, `doctor`, `version`, `feedback`, `profile`, `which`, `help`.
+3. **Per-command opt-out via annotation.** Domain commands that should not be agent tools — interactive setup wizards, debug commands, anything that needs human-in-the-loop input — set `cmd.Annotations["mcp:hidden"] = "true"` at construction time.
+
+**Critical: store-population commands stay exposed.** `sync`, `stale`, `orphans`, `reconcile`, `load`, `export`, `import`, `workflow`, `analytics` are generator-emitted but they have real agent value, so they MUST be reachable as MCP tools. The walker registers them as shell-out tools by default (no entry in the framework set means "the runtime walker exposes it"). 
+
+Excluding `sync` while exposing the typed `sql` tool is a **broken contract** — `sql` returns empty results until something populates the store, and the only thing that does is `sync`. The same logic applies to `search` (FTS5 over the store): without `sync`, it's inert. Earlier framework-set drafts incorrectly excluded all of these as "operator commands"; the agent surfaced the bug by trying to query an empty database. The corrected rule, encoded above, is "framework-skip is for things with a better typed equivalent OR no agent value at all" — store-population doesn't fit either case.
+
+A novel domain command that maps cleanly to a single agent action gets exposed automatically. The author does not need to declare it anywhere.
+
+### Tool safety annotations
+
+The MCP spec includes per-tool annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) that hosts like Claude Desktop use to classify tool safety and decide when to ask for user permission. Without annotations the host defaults conservatively to "could write or delete," demanding permission per call.
+
+The generator emits annotations automatically based on what it knows:
+
+- **Spec endpoint mirrors:** annotated from the HTTP method. `GET` → `readOnlyHint: true` + `openWorldHint: true`. `DELETE` → `destructiveHint: true` + `openWorldHint: true`. `POST`/`PUT`/`PATCH` get `openWorldHint: true` (writes, not destructive). All endpoint-mirror tools also carry `openWorldHint: true` since they call external APIs.
+- **Built-in MCP tools:** `context`, `sql`, `search` all carry `readOnlyHint: true` (no `openWorldHint` — they read local domain context, the local DB, or the local FTS index, not external APIs).
+- **Runtime-walker shell-out tools:** the walker can't infer semantics from a Cobra command alone, so they ship without annotations by default. Authors can opt a command into the read-only hint via `cmd.Annotations["mcp:read-only"] = "true"` (parallel to `mcp:hidden`). Use this on novel CLI commands that don't mutate external state — read-only API queries, local cache lookups, lightweight derivations.
+
+Default openness: missing annotations don't break anything; they just produce more permission prompts. Adding the wrong annotation (e.g., claiming `readOnlyHint: true` on a tool that mutates state) is the failure mode to avoid — the host trusts the claim and stops asking.
+
+### Adding a new framework command
+
+When you add a new generator-emitted top-level command, the default is **expose it as an MCP tool** — no action needed in the walker. The runtime walker registers any user-facing Cobra command as a shell-out tool automatically.
+
+Only update `frameworkCommands` in `internal/generator/templates/cobratree/classify.go.tmpl` when the new command meets one of the two skip criteria above (typed equivalent already exists, or non-functional via MCP). Adding to `frameworkCommands` is a structural choice to *hide* a capability from agents — make it deliberately, not by reflex.
+
+Skipping a command that should be exposed silently breaks contracts (the `sync`/`sql` example above). Exposing a command that should be hidden adds a low-value tool to the catalog but doesn't break anything. **When in doubt, leave it out of the framework set.**
+
+This is the same shape as the "When adding a capability that affects scoring" rule a few sections up: a new generator capability must update the dependent verifier or surface in the same change. Forgetting either half ships a CLI whose advertised contract diverges from what's actually emitted.
+
+### Why agent-facing != user-facing
+
+Diagnostics (`doctor`, `version`), interactive setup (`auth login`), local-only operations (`profile save`, `feedback`), and shell ergonomics (`completion`, `which`) all belong on the human-facing CLI. Exposing them as MCP tools doesn't help agents — it pollutes the tool catalog with tools that either fail without TTY input, return information the agent doesn't need (e.g., the CLI's own version), or duplicate first-class MCP tools (`agent-context` would shadow the typed `context` MCP tool). Curated absence is a feature.
+
+The default flips toward exposure for one reason: agents must be able to do anything users can. So the rules are written as exceptions to a permissive default, not as an allowlist. When in doubt, leave it exposed — undeclared MCP gaps were the bug class that drove this whole architecture (see `docs/plans/2026-04-28-001-feat-mcp-cobra-tree-mirror-plan.md` for the audit that surfaced ESPN missing 18 commands and company-goat missing 7).
+
 ## Build, Test & Lint
 
 ```bash
@@ -121,11 +169,13 @@ Key terms used throughout this repo. Several have overloaded meanings — the gl
 | **retro** / **retrospective** | Post-generation analysis of *the machine itself* — not the printed CLI. Identifies systemic improvements to templates, the Go binary, skill instructions, or catalog. Output goes to `docs/retros/` and `manuscripts/<api>/<run>/proofs/`. |
 | **quality gates** | 7 mechanical static checks every printed CLI must pass: go mod tidy, go vet, go build, binary build, `--help`, version, doctor. These are build-time checks — see **verify** for runtime testing. |
 | **verify** | Runtime behavioral testing of a printed CLI — runs every command against the real API (read-only) or a mock server. Produces PASS/WARN/FAIL verdicts. Has `--fix` mode for auto-patching. Distinct from quality gates (static) and dogfood (structural). |
-| **dogfood** | Generation-time structural validation of a printed CLI against its source spec. Catches dead flags, invalid API paths, auth mismatches. Subcommand: `printing-press dogfood`. Compare with **doctor** (shipped in the CLI for end-users) and **verify** (runtime behavioral). |
+| **dogfood** | Generation-time structural validation of a printed CLI against its source spec. Catches dead flags, invalid API paths, auth mismatches, and MCP surface parity drift. Subcommand: `printing-press dogfood`. Compare with **doctor** (shipped in the CLI for end-users) and **verify** (runtime behavioral). |
 | **cliutil** | The generator-owned Go package emitted into every printed CLI at `internal/cliutil/`. Houses shared helpers meant for agent-authored novel code to import: `cliutil.FanoutRun` for aggregation commands (per-source error collection, bounded concurrency, source-order output), `cliutil.CleanText` for HTML/JSON-LD text normalization, `cliutil.IsVerifyEnv()` for the side-effect short-circuit (see **side-effect command convention**). **Generator-reserved namespace** — agents authoring novel code in Phase 3 must not put their code in `internal/cliutil/` or name their own helpers that collide with cliutil's exports. |
+| **cobratree** | The generator-owned Go package emitted into every printed CLI at `internal/mcp/cobratree/`. The MCP server uses it to walk the printed CLI's Cobra command tree at startup and register shell-out tools for user-facing commands that are not already typed endpoint tools. Classification rules and the framework skip list live in `cobratree/classify.go.tmpl`; see **Agent-Native Surface** for when to add to the framework set vs. annotate `mcp:hidden`. **Generator-reserved namespace** — do not hand-author code here. |
 | **side-effect command convention** | Two-part rule for hand-written novel commands that perform visible actions (open browser tabs, send notifications, dial out to OS handlers). (1) Print by default; require explicit opt-in (`--launch`, `--send`, `--play`) to actually act. (2) Short-circuit when `cliutil.IsVerifyEnv()` is true — the verifier sets `PRINTING_PRESS_VERIFY=1` in every mock-mode subprocess, and the env-var check is the floor that catches any command the verifier's heuristic side-effect classifier misses. Documented in `skills/printing-press/SKILL.md` Phase 3 (principle 9). |
 | **canonicalargs** | Tiny generator subpackage at `internal/canonicalargs/` exporting `Lookup(name) (string, bool)` for cross-domain positional placeholder names (`since`, `until`, `tag`, `vertical`). Both verify mock-mode dispatch and the SKILL template consult this registry as one step in the lookup chain `spec.Param.Default → canonicalargs → legacy syntheticArgValue switch → "mock-value"`. **Domain-specific names belong in the spec author's `Param.Default`, not here** — anti-pattern: "Never change the machine for one CLI's edge case." |
-| **shipcheck** | The three-part verification block that gates publishing: dogfood + verify + scorecard, run together. All three must pass before a printed CLI ships. |
+| **mcp-sync** | Subcommand on the printing-press binary (`printing-press mcp-sync <cli-dir>`) that migrates generated MCP surfaces from the old static novel-feature list to the runtime Cobra-tree mirror. It rewrites generated MCP files, adds the root command export when possible, regenerates `tools-manifest.json`, and refuses hand-edited `internal/mcp/tools.go` unless `--force` is passed. |
+| **shipcheck** | The verification block that gates publishing: dogfood + verify + workflow-verify + verify-skill + scorecard, run together. Dogfood includes `mcp_surface_parity`, so stale static MCP surfaces block shipping. All legs must pass before a printed CLI ships. |
 | **scorecard** / **scoring** | Two-tier quality assessment with a 50/50 weighted composite. Tier 1: infrastructure (16 string-matching dimensions, raw max 160, normalized to 0-50). Tier 2: domain correctness (7 semantic dimensions, raw max 60 when live verify ran, normalized to 0-50). Total /100 with letter grades. Source of truth: `internal/pipeline/scorecard.go` (tier1Max / tier2Max). Subcommand: `printing-press scorecard`. |
 | **machine-owned freshness** | Opt-in freshness contract for store-backed printed CLIs using `cache.enabled`. Covered command paths map to syncable resources; in `--data-source auto` they may run a bounded pre-read refresh before serving local data. `--data-source local` never refreshes, `--data-source live` must not mutate the local store, and env opt-out only disables the freshness hook. This is current-cache freshness, not a guarantee of full historical backfill or API-specific enrichment. |
 | **doctor** | Self-diagnostic command shipped inside every printed CLI for end-users to run. Checks environment, auth config, and connectivity at the user's runtime. Unlike dogfood (which validates at generation time), doctor runs post-install. |
diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md
index 43c965d6..35d7fbc4 100644
--- a/docs/PIPELINE.md
+++ b/docs/PIPELINE.md
@@ -167,7 +167,7 @@ Outputs:
 - `review.md` summarizing the combined shipcheck result
 
 Gates:
-- Dogfood: structural checks (path validity, auth, dead flags, wiring) pass at the configured tier
+- Dogfood: structural checks (path validity, auth, dead flags, wiring, MCP surface parity) pass at the configured tier. The `mcp_surface_parity` check requires generated MCP files to use the runtime Cobra-tree mirror; stale static novel-feature lists fail with a `printing-press mcp-sync <cli-dir>` remediation.
 - Verify: runtime behavioral checks against the real API or a mock server return PASS (or WARN after an auto-remediation pass)
 - Scorecard: overall grade clears the operator's configured threshold. Tier-1 dimensions include three MCP-shape dimensions (`mcp_remote_transport`, `mcp_tool_design`, `mcp_surface_strategy`) added in the April 2026 MCP production-readiness pass — opt-in via the `(score, bool)` pattern so CLIs without the relevant surface remain unscored.
 
diff --git a/internal/cli/bundle.go b/internal/cli/bundle.go
index 3b9ab2a9..367e93ce 100644
--- a/internal/cli/bundle.go
+++ b/internal/cli/bundle.go
@@ -82,10 +82,11 @@ from another build pipeline.`,
 			if binaryPath == "" {
 				binaryPath = pipeline.StagedMCPBinaryPath(cliDir, manifest.Name)
 			}
-			if cliBinaryPath == "" && manifest.CLIBinary != "" {
-				cliBinaryPath = pipeline.StagedMCPBinaryPath(cliDir, manifest.CLIBinary)
+			cliName := pipeline.ReadCLIBinaryName(cliDir)
+			if cliBinaryPath == "" && cliName != "" {
+				cliBinaryPath = pipeline.StagedMCPBinaryPath(cliDir, cliName)
 			}
-			if err := buildBundleBinaries(cliDir, manifest.Name, binaryPath, skipBuild, manifest.CLIBinary, cliBinaryPath, cliSkipBuild, goos, goarch); err != nil {
+			if err := buildBundleBinaries(cliDir, manifest.Name, binaryPath, skipBuild, cliName, cliBinaryPath, cliSkipBuild, goos, goarch); err != nil {
 				return err
 			}
 
@@ -96,6 +97,7 @@ from another build pipeline.`,
 			if err := pipeline.BuildMCPBBundle(pipeline.BundleParams{
 				CLIDir:        cliDir,
 				BinaryPath:    binaryPath,
+				CLIBinaryName: cliName,
 				CLIBinaryPath: cliBinaryPath,
 				OutputPath:    output,
 			}); err != nil {
@@ -148,11 +150,12 @@ func autoBundleForHost(cliDir string, w io.Writer) {
 		return
 	}
 	binaryPath := pipeline.StagedMCPBinaryPath(cliDir, manifest.Name)
+	cliName := pipeline.ReadCLIBinaryName(cliDir)
 	cliBinaryPath := ""
-	if manifest.CLIBinary != "" {
-		cliBinaryPath = pipeline.StagedMCPBinaryPath(cliDir, manifest.CLIBinary)
+	if cliName != "" {
+		cliBinaryPath = pipeline.StagedMCPBinaryPath(cliDir, cliName)
 	}
-	if err := buildBundleBinaries(cliDir, manifest.Name, binaryPath, false, manifest.CLIBinary, cliBinaryPath, false, runtime.GOOS, runtime.GOARCH); err != nil {
+	if err := buildBundleBinaries(cliDir, manifest.Name, binaryPath, false, cliName, cliBinaryPath, false, runtime.GOOS, runtime.GOARCH); err != nil {
 		fmt.Fprintf(w, "warning: %v\n", err)
 		return
 	}
@@ -160,6 +163,7 @@ func autoBundleForHost(cliDir string, w io.Writer) {
 	if err := pipeline.BuildMCPBBundle(pipeline.BundleParams{
 		CLIDir:        cliDir,
 		BinaryPath:    binaryPath,
+		CLIBinaryName: cliName,
 		CLIBinaryPath: cliBinaryPath,
 		OutputPath:    output,
 	}); err != nil {
diff --git a/internal/cli/dogfood.go b/internal/cli/dogfood.go
index c00c55bf..503fb6a3 100644
--- a/internal/cli/dogfood.go
+++ b/internal/cli/dogfood.go
@@ -176,6 +176,20 @@ func printDogfoodReport(report *pipeline.DogfoodReport) {
 	}
 	fmt.Println()
 
+	mcp := report.MCPSurfaceParityCheck
+	if mcp.Skipped {
+		fmt.Printf("MCP Surface:       SKIP (%s)\n", mcp.Detail)
+	} else {
+		mcpStatus := "PASS"
+		if mcp.HandEdited {
+			mcpStatus = "WARN"
+		} else if !mcp.Pass {
+			mcpStatus = "FAIL"
+		}
+		fmt.Printf("MCP Surface:       %s (%s)\n", mcpStatus, mcp.Detail)
+	}
+	fmt.Println()
+
 	fmt.Printf("Verdict: %s\n", report.Verdict)
 	for _, issue := range report.Issues {
 		fmt.Printf("  - %s\n", issue)
diff --git a/internal/cli/mcp_sync.go b/internal/cli/mcp_sync.go
new file mode 100644
index 00000000..f8e27868
--- /dev/null
+++ b/internal/cli/mcp_sync.go
@@ -0,0 +1,40 @@
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline/mcpsync"
+	"github.com/spf13/cobra"
+)
+
+func newMCPSyncCmd() *cobra.Command {
+	var force bool
+	cmd := &cobra.Command{
+		Use:   "mcp-sync <cli-dir>",
+		Short: "Migrate a printed CLI to the runtime Cobra-tree MCP surface",
+		Args:  cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cliDir, err := filepath.Abs(args[0])
+			if err != nil {
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("resolving cli dir: %w", err)}
+			}
+			result, err := mcpsync.Sync(cliDir, mcpsync.Options{Force: force})
+			if err != nil {
+				if errors.Is(err, mcpsync.ErrHandEdited) {
+					return &ExitError{Code: ExitUnknownError, Err: err}
+				}
+				return &ExitError{Code: ExitPublishError, Err: err}
+			}
+			if result.Changed {
+				fmt.Fprintf(cmd.OutOrStdout(), "migrated MCP surface in %s\n", cliDir)
+			} else {
+				fmt.Fprintf(cmd.OutOrStdout(), "%s\n", result.Detail)
+			}
+			return nil
+		},
+	}
+	cmd.Flags().BoolVar(&force, "force", false, "Overwrite generated MCP files even when tools.go lacks the generated marker")
+	return cmd
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index d32e65da..c7657a23 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -64,6 +64,7 @@ func Execute() error {
 	rootCmd.AddCommand(newProbeReachabilityCmd())
 	rootCmd.AddCommand(newSchemaCmd())
 	rootCmd.AddCommand(newBundleCmd())
+	rootCmd.AddCommand(newMCPSyncCmd())
 
 	return rootCmd.Execute()
 }
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index cb577194..ba31570b 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -132,6 +132,14 @@ type Generator struct {
 	Narrative       *ReadmeNarrative        // LLM-authored prose for README/SKILL; optional
 	AsyncJobs       map[string]AsyncJobInfo // Detected async-job endpoints, keyed by "<resource>/<endpoint>"
 
+	// ModulePath overrides the Go module import path emitted by templates that
+	// reference internal packages (`{{modulePath}}/internal/client`, etc.).
+	// Defaults to `<api>-pp-cli` when empty — matches the standalone-publish
+	// shape. Set explicitly when regenerating a CLI that lives under a
+	// different go.mod, e.g. library checkouts where the module path is the
+	// repo-prefixed full path. Read by mcp-sync from the existing go.mod.
+	ModulePath string
+
 	// Promoted-command plan, populated by Generate() before any rendering so
 	// SKILL/README templates can honor leaf promotion (and not emit phantom paths
 	// like `<cli> qr get-qrcode` for a resource the generator collapsed to `qr`).
@@ -219,9 +227,14 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 			}
 			return false
 		},
-		"exampleLine":       g.exampleLine,
-		"currentYear":       func() string { return strconv.Itoa(time.Now().Year()) },
-		"modulePath":        func() string { return naming.CLI(s.Name) },
+		"exampleLine": g.exampleLine,
+		"currentYear": func() string { return strconv.Itoa(time.Now().Year()) },
+		"modulePath": func() string {
+			if g.ModulePath != "" {
+				return g.ModulePath
+			}
+			return naming.CLI(s.Name)
+		},
 		"graphqlQueryField": graphqlQueryField,
 		"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
 			return graphqlFieldSelection(typeName, types)
@@ -958,6 +971,7 @@ func (g *Generator) prepareOutput() error {
 		filepath.Join("internal", "client"),
 		filepath.Join("internal", "cliutil"),
 		filepath.Join("internal", "config"),
+		filepath.Join("internal", "mcp", "cobratree"),
 		filepath.Join("internal", "types"),
 	}
 
@@ -999,30 +1013,36 @@ func (g *Generator) prepareOutput() error {
 
 func (g *Generator) renderSingleFiles() error {
 	singleFiles := map[string]string{
-		"main.go.tmpl":              filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
-		"helpers.go.tmpl":           filepath.Join("internal", "cli", "helpers.go"),
-		"doctor.go.tmpl":            filepath.Join("internal", "cli", "doctor.go"),
-		"agent_context.go.tmpl":     filepath.Join("internal", "cli", "agent_context.go"),
-		"profile.go.tmpl":           filepath.Join("internal", "cli", "profile.go"),
-		"deliver.go.tmpl":           filepath.Join("internal", "cli", "deliver.go"),
-		"feedback.go.tmpl":          filepath.Join("internal", "cli", "feedback.go"),
-		"which.go.tmpl":             filepath.Join("internal", "cli", "which.go"),
-		"which_test.go.tmpl":        filepath.Join("internal", "cli", "which_test.go"),
-		"config.go.tmpl":            filepath.Join("internal", "config", "config.go"),
-		"cache.go.tmpl":             filepath.Join("internal", "cache", "cache.go"),
-		"client.go.tmpl":            filepath.Join("internal", "client", "client.go"),
-		"cliutil_fanout.go.tmpl":    filepath.Join("internal", "cliutil", "fanout.go"),
-		"cliutil_text.go.tmpl":      filepath.Join("internal", "cliutil", "text.go"),
-		"cliutil_probe.go.tmpl":     filepath.Join("internal", "cliutil", "probe.go"),
-		"cliutil_ratelimit.go.tmpl": filepath.Join("internal", "cliutil", "ratelimit.go"),
-		"cliutil_verifyenv.go.tmpl": filepath.Join("internal", "cliutil", "verifyenv.go"),
-		"cliutil_test.go.tmpl":      filepath.Join("internal", "cliutil", "cliutil_test.go"),
-		"types.go.tmpl":             filepath.Join("internal", "types", "types.go"),
-		"golangci.yml.tmpl":         ".golangci.yml",
-		"readme.md.tmpl":            "README.md",
-		"skill.md.tmpl":             "SKILL.md",
-		"LICENSE.tmpl":              "LICENSE",
-		"NOTICE.tmpl":               "NOTICE",
+		"main.go.tmpl":               filepath.Join("cmd", naming.CLI(g.Spec.Name), "main.go"),
+		"helpers.go.tmpl":            filepath.Join("internal", "cli", "helpers.go"),
+		"doctor.go.tmpl":             filepath.Join("internal", "cli", "doctor.go"),
+		"agent_context.go.tmpl":      filepath.Join("internal", "cli", "agent_context.go"),
+		"profile.go.tmpl":            filepath.Join("internal", "cli", "profile.go"),
+		"deliver.go.tmpl":            filepath.Join("internal", "cli", "deliver.go"),
+		"feedback.go.tmpl":           filepath.Join("internal", "cli", "feedback.go"),
+		"which.go.tmpl":              filepath.Join("internal", "cli", "which.go"),
+		"which_test.go.tmpl":         filepath.Join("internal", "cli", "which_test.go"),
+		"config.go.tmpl":             filepath.Join("internal", "config", "config.go"),
+		"cache.go.tmpl":              filepath.Join("internal", "cache", "cache.go"),
+		"client.go.tmpl":             filepath.Join("internal", "client", "client.go"),
+		"cliutil_fanout.go.tmpl":     filepath.Join("internal", "cliutil", "fanout.go"),
+		"cliutil_text.go.tmpl":       filepath.Join("internal", "cliutil", "text.go"),
+		"cliutil_probe.go.tmpl":      filepath.Join("internal", "cliutil", "probe.go"),
+		"cliutil_ratelimit.go.tmpl":  filepath.Join("internal", "cliutil", "ratelimit.go"),
+		"cliutil_verifyenv.go.tmpl":  filepath.Join("internal", "cliutil", "verifyenv.go"),
+		"cliutil_test.go.tmpl":       filepath.Join("internal", "cliutil", "cliutil_test.go"),
+		"cobratree/walker.go.tmpl":   filepath.Join("internal", "mcp", "cobratree", "walker.go"),
+		"cobratree/classify.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "classify.go"),
+		"cobratree/typemap.go.tmpl":  filepath.Join("internal", "mcp", "cobratree", "typemap.go"),
+		"cobratree/shellout.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "shellout.go"),
+		"cobratree/cli_path.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "cli_path.go"),
+		"cobratree/names.go.tmpl":    filepath.Join("internal", "mcp", "cobratree", "names.go"),
+		"types.go.tmpl":              filepath.Join("internal", "types", "types.go"),
+		"golangci.yml.tmpl":          ".golangci.yml",
+		"readme.md.tmpl":             "README.md",
+		"skill.md.tmpl":              "SKILL.md",
+		"LICENSE.tmpl":               "LICENSE",
+		"NOTICE.tmpl":                "NOTICE",
 	}
 
 	for tmplName, outPath := range singleFiles {
@@ -1170,6 +1190,31 @@ func (g *Generator) Generate() error {
 	return g.renderVisionAndRootFiles(g.PromotedCommands, g.PromotedResourceNames)
 }
 
+// GenerateMCPSurfaceOnly rewrites the generated MCP entrypoint, tools package,
+// and cobratree helpers without touching the printed CLI's command files.
+func (g *Generator) GenerateMCPSurfaceOnly() error {
+	if err := g.prepareOutput(); err != nil {
+		return err
+	}
+	g.PromotedCommands, g.PromotedResourceNames, g.PromotedEndpointNames = buildPromotedCommandPlan(g.Spec)
+	for tmplName, outPath := range map[string]string{
+		"cobratree/walker.go.tmpl":   filepath.Join("internal", "mcp", "cobratree", "walker.go"),
+		"cobratree/classify.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "classify.go"),
+		"cobratree/typemap.go.tmpl":  filepath.Join("internal", "mcp", "cobratree", "typemap.go"),
+		"cobratree/shellout.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "shellout.go"),
+		"cobratree/cli_path.go.tmpl": filepath.Join("internal", "mcp", "cobratree", "cli_path.go"),
+		"cobratree/names.go.tmpl":    filepath.Join("internal", "mcp", "cobratree", "names.go"),
+	} {
+		if err := g.renderTemplate(tmplName, outPath, g.Spec); err != nil {
+			return fmt.Errorf("rendering %s: %w", tmplName, err)
+		}
+	}
+	if err := g.renderMCPEntrypoint(); err != nil {
+		return err
+	}
+	return g.renderMCPToolFiles(g.schemaWithDependentParents())
+}
+
 func buildPromotedCommandPlan(apiSpec *spec.APISpec) ([]PromotedCommand, map[string]bool, map[string]string) {
 	// Compute promoted commands early — needed to determine Hidden flag on parent commands
 	promotedCommands := buildPromotedCommands(apiSpec)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index d6e2023a..3e46dff5 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -55,6 +55,12 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		"internal/cliutil/cliutil_test.go",
 		"internal/client/client.go",
 		"internal/config/config.go",
+		"internal/mcp/cobratree/walker.go",
+		"internal/mcp/cobratree/classify.go",
+		"internal/mcp/cobratree/typemap.go",
+		"internal/mcp/cobratree/shellout.go",
+		"internal/mcp/cobratree/cli_path.go",
+		"internal/mcp/cobratree/names.go",
 	}
 
 	tests := []struct {
@@ -66,9 +72,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
 		// Bump it AND add to mustInclude above when adding always-emitted
 		// templates. Per-spec dynamic files (per-resource command files,
 		// generated tests) account for the difference between fixtures.
-		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 47},
-		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 52},
-		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 49},
+		{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 53},
+		{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 58},
+		{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 55},
 	}
 
 	for _, tt := range tests {
@@ -205,7 +211,7 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
 	// Root command must wire the hook.
 	rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
 	require.NoError(t, err)
-	assert.Contains(t, string(rootSrc), "flags.freshnessMeta = autoRefreshIfStale(cmd.Context(), &flags, resources)",
+	assert.Contains(t, string(rootSrc), "flags.freshnessMeta = autoRefreshIfStale(cmd.Context(), flags, resources)",
 		"root.go must invoke autoRefreshIfStale from PersistentPreRunE")
 
 	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
@@ -312,7 +318,7 @@ func TestGenerateShareEmittedWhenEnabled(t *testing.T) {
 	// root.go registers the share parent command.
 	rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
 	require.NoError(t, err)
-	assert.Contains(t, string(rootSrc), "rootCmd.AddCommand(newShareCmd(&flags))",
+	assert.Contains(t, string(rootSrc), "rootCmd.AddCommand(newShareCmd(flags))",
 		"root.go must register newShareCmd when share is enabled")
 
 	// The generated share package tests must compile and pass; this is
@@ -3062,8 +3068,8 @@ func TestGenerateGraphQLBFFUsesSemanticCommandSurface(t *testing.T) {
 	rootGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
 	require.NoError(t, err)
 	rootSrc := string(rootGo)
-	assert.Contains(t, rootSrc, "rootCmd.AddCommand(newProductsCmd(&flags))")
-	assert.NotContains(t, rootSrc, "rootCmd.AddCommand(newGraphqlCmd(&flags))")
+	assert.Contains(t, rootSrc, "rootCmd.AddCommand(newProductsCmd(flags))")
+	assert.NotContains(t, rootSrc, "rootCmd.AddCommand(newGraphqlCmd(flags))")
 	assert.FileExists(t, filepath.Join(outputDir, "internal", "cli", "products.go"))
 	assert.FileExists(t, filepath.Join(outputDir, "internal", "cli", "products_launches.go"))
 	assert.FileExists(t, filepath.Join(outputDir, "internal", "cli", "products_makers.go"))
diff --git a/internal/generator/mcp_novel_features_test.go b/internal/generator/mcp_novel_features_test.go
index dd011c58..756bbfa7 100644
--- a/internal/generator/mcp_novel_features_test.go
+++ b/internal/generator/mcp_novel_features_test.go
@@ -3,6 +3,7 @@ package generator
 import (
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"testing"
 
@@ -10,12 +11,11 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-// TestMCPRegisterNovelFeatureToolsEmitted verifies that the generator emits
-// MCP tool registrations for each NovelFeature, plus the shell-out helpers,
-// only when novel features exist. The empty-features case is exercised by
-// the golden suite (every standard fixture has no novel features today and
-// the goldens lock in the empty-body shape).
-func TestMCPRegisterNovelFeatureToolsEmitted(t *testing.T) {
+// TestMCPRegistersCobraTreeMirror verifies that novel features no longer
+// drive a static MCP list. RegisterTools wires the runtime Cobra-tree mirror,
+// while RegisterNovelFeatureTools remains as a compatibility no-op for old
+// generated mains.
+func TestMCPRegistersCobraTreeMirror(t *testing.T) {
 	t.Parallel()
 
 	apiSpec := minimalSpec("noveltest")
@@ -47,44 +47,32 @@ func TestMCPRegisterNovelFeatureToolsEmitted(t *testing.T) {
 	require.NoError(t, err)
 	content := string(tools)
 
-	// Function declaration with body
+	// Compatibility function remains, but the static registration body is gone.
 	assert.Contains(t, content, "func RegisterNovelFeatureTools(s *server.MCPServer) {")
+	assert.Contains(t, content, "_ = s")
+	assert.NotContains(t, content, `shellOutToCLI("snapshot")`)
+	assert.Contains(t, content, "cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)")
 
-	// One s.AddTool call per novel feature, with snake_case tool names derived
-	// from each Command. The "funding --who" command must collapse to
-	// "funding_who" (no leading dashes, single underscore between tokens).
-	assert.Contains(t, content, `mcplib.NewTool("snapshot",`)
-	assert.Contains(t, content, `mcplib.NewTool("funding_who",`)
-	assert.Contains(t, content, `mcplib.NewTool("funding_trend",`)
-
-	// Each tool gets the args-string parameter
-	assert.Contains(t, content, `mcplib.WithString("args"`)
-
-	// Handler dispatch passes the original command spec verbatim — this is
-	// what shellOutToCLI splits and prepends to user args. Preserves the
-	// "funding --who" form so the CLI sees the right subcommand+flag pair.
-	assert.Contains(t, content, `shellOutToCLI("snapshot")`)
-	assert.Contains(t, content, `shellOutToCLI("funding --who")`)
-	assert.Contains(t, content, `shellOutToCLI("funding-trend")`)
-
-	// Helpers must emit when novel features exist
-	assert.Contains(t, content, "func siblingCLIPath()")
-	assert.Contains(t, content, "func shellOutToCLI(commandSpec string)")
-	assert.Contains(t, content, "func splitShellArgs(s string)")
-
-	// Sibling CLI binary name must match the spec's CLI name
-	assert.Contains(t, content, `const cliName = "noveltest-pp-cli"`)
+	cobratreeCLIPath, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "cobratree", "cli_path.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(cobratreeCLIPath), `const cliName = "noveltest-pp-cli"`)
+	assert.Contains(t, string(cobratreeCLIPath), `os.Getenv("NOVELTEST_CLI_PATH")`)
 
-	// Env-var fallback uses the API's prefix (uppercased, hyphens to underscores)
-	assert.Contains(t, content, `os.Getenv("NOVELTEST_CLI_PATH")`)
+	cobratreeShellout, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "cobratree", "shellout.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(cobratreeShellout), "func shellOutToCLI(")
+	assert.Contains(t, string(cobratreeShellout), "func splitShellArgs(s string)")
 
-	// os/exec import must be present (only when novel features exist)
-	assert.Contains(t, content, `"os/exec"`)
+	root, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(root), "func RootCmd() *cobra.Command")
 
-	// main.go always calls RegisterNovelFeatureTools — wiring stays uniform
+	// main.go calls only RegisterTools; RegisterTools owns endpoint tools and
+	// the runtime command mirror.
 	main, err := os.ReadFile(filepath.Join(outputDir, "cmd", "noveltest-pp-mcp", "main.go"))
 	require.NoError(t, err)
-	assert.Contains(t, string(main), "mcptools.RegisterNovelFeatureTools(s)")
+	assert.Contains(t, string(main), "mcptools.RegisterTools(s)")
+	assert.NotContains(t, string(main), "mcptools.RegisterNovelFeatureTools(s)")
 }
 
 // TestMCPNovelFeatureToolNameSanitization pins the snake-case tool-name
@@ -119,16 +107,39 @@ func TestMCPNovelFeatureToolNameSanitization(t *testing.T) {
 	}
 	require.NoError(t, gen.Generate())
 
-	tools, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
+	names, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "cobratree", "names.go"))
 	require.NoError(t, err)
-	content := string(tools)
+	content := string(names)
+
+	assert.Contains(t, content, "func toolNameForPath(parts []string) string")
+
+	var testSrc strings.Builder
+	testSrc.WriteString(`package cobratree
 
+import (
+	"strings"
+	"testing"
+)
+
+func TestToolNameForPathCases(t *testing.T) {
+	cases := map[string]string{
+`)
 	for command, want := range cases {
-		if command == "" || want == "" {
-			continue
+		testSrc.WriteString("\t\t")
+		testSrc.WriteString(strconv.Quote(command))
+		testSrc.WriteString(": ")
+		testSrc.WriteString(strconv.Quote(want))
+		testSrc.WriteString(",\n")
+	}
+	testSrc.WriteString(`	}
+	for command, want := range cases {
+		if got := toolNameForPath(strings.Fields(command)); got != want {
+			t.Fatalf("toolNameForPath(%q) = %q, want %q", command, got, want)
 		}
-		assert.True(t,
-			strings.Contains(content, `mcplib.NewTool("`+want+`",`),
-			"command %q should produce tool name %q", command, want)
 	}
 }
+`)
+	require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "mcp", "cobratree", "names_extra_test.go"), []byte(testSrc.String()), 0o644))
+
+	runGoCommandRequired(t, outputDir, "test", "./internal/mcp/cobratree")
+}
diff --git a/internal/generator/templates/cobratree/classify.go.tmpl b/internal/generator/templates/cobratree/classify.go.tmpl
new file mode 100644
index 00000000..727f1376
--- /dev/null
+++ b/internal/generator/templates/cobratree/classify.go.tmpl
@@ -0,0 +1,107 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+const (
+	EndpointAnnotation = "pp:endpoint"
+	HiddenAnnotation   = "mcp:hidden"
+	// ReadOnlyAnnotation, when set on a Cobra command to "true"/"1"/"yes",
+	// causes the runtime walker to register the resulting MCP tool with
+	// readOnlyHint=true. Use for novel CLI commands that don't mutate
+	// external state — read-only API queries, local cache reads, etc.
+	// Without it, hosts like Claude Desktop default to "could write or
+	// delete" and demand permission per call.
+	ReadOnlyAnnotation = "mcp:read-only"
+)
+
+type commandKind int
+
+const (
+	commandNovel commandKind = iota
+	commandEndpoint
+	commandFramework
+	commandHidden
+)
+
+// frameworkCommands are top-level CLI commands the walker should skip when
+// mirroring the Cobra tree. Two cases qualify:
+//
+//  1. A typed MCP tool already covers the same capability (the typed tool's
+//     schema is strictly better than a shell-out). Examples: `sql`, `search`,
+//     `context`/`about`/`agent-context`, `api` (endpoint mirror tools cover it).
+//  2. The command is non-functional via MCP (interactive setup, shell-only
+//     ergonomics, trivial introspection, local-only feedback). Examples:
+//     `auth`, `completion`, `doctor`, `version`, `feedback`, `profile`,
+//     `which`, `help`.
+//
+// Commands that DO have agent value — `sync` (populates the store that `sql`
+// and `search` query), `stale`/`orphans`/`reconcile`/`load` (store
+// diagnostics), `export`/`import` (data movement), `workflow`
+// (compound operations), `analytics` (aggregations) — must NOT be in this
+// list. Excluding `sync` while exposing `sql` is a broken contract because
+// the typed `sql` tool returns empty results until something populates the
+// store. See AGENTS.md "Agent-Native Surface" for the principle.
+//
+// Adding a new generator-emitted command means deciding which of the two
+// cases above applies. When in doubt, leave it out — the walker registers
+// any user-facing command as a shell-out tool, and the cost of a slightly
+// underused tool is much smaller than the cost of a broken contract like
+// `sql` without `sync`.
+var frameworkCommands = map[string]bool{
+	"about":         true,
+	"agent-context": true,
+	"api":           true,
+	"auth":          true,
+	"completion":    true,
+	"doctor":        true,
+	"feedback":      true,
+	"help":          true,
+	"profile":       true,
+	"search":        true,
+	"sql":           true,
+	"version":       true,
+	"which":         true,
+}
+
+func classify(cmd *cobra.Command) commandKind {
+	if cmd == nil || cmd.Hidden || isMCPHidden(cmd) {
+		return commandHidden
+	}
+	if endpointID(cmd) != "" {
+		return commandEndpoint
+	}
+	if frameworkCommands[cmd.Name()] {
+		return commandFramework
+	}
+	return commandNovel
+}
+
+func endpointID(cmd *cobra.Command) string {
+	if cmd == nil || cmd.Annotations == nil {
+		return ""
+	}
+	return strings.TrimSpace(cmd.Annotations[EndpointAnnotation])
+}
+
+func isMCPHidden(cmd *cobra.Command) bool {
+	return annotationIsTrue(cmd, HiddenAnnotation)
+}
+
+func isMCPReadOnly(cmd *cobra.Command) bool {
+	return annotationIsTrue(cmd, ReadOnlyAnnotation)
+}
+
+func annotationIsTrue(cmd *cobra.Command, key string) bool {
+	if cmd == nil || cmd.Annotations == nil {
+		return false
+	}
+	v := strings.ToLower(strings.TrimSpace(cmd.Annotations[key]))
+	return v == "true" || v == "1" || v == "yes"
+}
diff --git a/internal/generator/templates/cobratree/cli_path.go.tmpl b/internal/generator/templates/cobratree/cli_path.go.tmpl
new file mode 100644
index 00000000..a241302b
--- /dev/null
+++ b/internal/generator/templates/cobratree/cli_path.go.tmpl
@@ -0,0 +1,26 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+)
+
+// SiblingCLIPath resolves the companion CLI via sibling-of-executable,
+// {{envPrefix .Name}}_CLI_PATH env var, then PATH.
+func SiblingCLIPath() (string, error) {
+	const cliName = "{{.Name}}-pp-cli"
+	if exe, err := os.Executable(); err == nil {
+		candidate := filepath.Join(filepath.Dir(exe), cliName)
+		if _, err := os.Stat(candidate); err == nil {
+			return candidate, nil
+		}
+	}
+	if v := os.Getenv("{{envPrefix .Name}}_CLI_PATH"); v != "" {
+		return v, nil
+	}
+	return exec.LookPath(cliName)
+}
diff --git a/internal/generator/templates/cobratree/names.go.tmpl b/internal/generator/templates/cobratree/names.go.tmpl
new file mode 100644
index 00000000..1c2928d6
--- /dev/null
+++ b/internal/generator/templates/cobratree/names.go.tmpl
@@ -0,0 +1,25 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+	"strings"
+	"unicode"
+)
+
+func toolNameForPath(parts []string) string {
+	var out []rune
+	for _, part := range parts {
+		for _, r := range part {
+			switch {
+			case unicode.IsLetter(r) || unicode.IsDigit(r):
+				out = append(out, unicode.ToLower(r))
+			default:
+				out = append(out, '_')
+			}
+		}
+		out = append(out, '_')
+	}
+	return strings.Trim(strings.Join(strings.FieldsFunc(string(out), func(r rune) bool { return r == '_' }), "_"), "_")
+}
diff --git a/internal/generator/templates/cobratree/shellout.go.tmpl b/internal/generator/templates/cobratree/shellout.go.tmpl
new file mode 100644
index 00000000..34cd612b
--- /dev/null
+++ b/internal/generator/templates/cobratree/shellout.go.tmpl
@@ -0,0 +1,102 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"sort"
+	"strconv"
+	"strings"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+func shellOutToCLI(cliPath func() (string, error), commandPath []string) server.ToolHandlerFunc {
+	lookupPath, lookupErr := cliPath()
+	prefixArgs := append([]string{}, commandPath...)
+	return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+		if lookupErr != nil {
+			return mcplib.NewToolResultError(fmt.Sprintf("companion CLI binary not found: %v\nTried sibling lookup, {{envPrefix .Name}}_CLI_PATH env var, and PATH.", lookupErr)), nil
+		}
+		args := req.GetArguments()
+		finalArgs := append([]string{}, prefixArgs...)
+		finalArgs = append(finalArgs, cliArgsFromMCP(args)...)
+		if raw, _ := args["args"].(string); strings.TrimSpace(raw) != "" {
+			finalArgs = append(finalArgs, splitShellArgs(raw)...)
+		}
+		cmd := exec.CommandContext(ctx, lookupPath, finalArgs...)
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			return mcplib.NewToolResultError(string(out)), nil
+		}
+		return mcplib.NewToolResultText(string(out)), nil
+	}
+}
+
+func cliArgsFromMCP(args map[string]any) []string {
+	keys := make([]string, 0, len(args))
+	for k := range args {
+		if k != "args" {
+			keys = append(keys, k)
+		}
+	}
+	sort.Strings(keys)
+
+	var out []string
+	for _, k := range keys {
+		v := args[k]
+		switch tv := v.(type) {
+		case bool:
+			if tv {
+				out = append(out, "--"+k)
+			}
+		case float64:
+			out = append(out, "--"+k, strconv.FormatFloat(tv, 'f', -1, 64))
+		case string:
+			if tv != "" {
+				out = append(out, "--"+k, tv)
+			}
+		case []any:
+			if len(tv) > 0 {
+				parts := make([]string, 0, len(tv))
+				for _, item := range tv {
+					parts = append(parts, fmt.Sprintf("%v", item))
+				}
+				out = append(out, "--"+k, strings.Join(parts, ","))
+			}
+		default:
+			if v != nil {
+				out = append(out, "--"+k, fmt.Sprintf("%v", v))
+			}
+		}
+	}
+	return out
+}
+
+// splitShellArgs whitespace-splits with double-quoted-token preservation.
+func splitShellArgs(s string) []string {
+	var tokens []string
+	var cur []rune
+	inQuote := false
+	for _, r := range s {
+		switch {
+		case r == '"':
+			inQuote = !inQuote
+		case (r == ' ' || r == '\t') && !inQuote:
+			if len(cur) > 0 {
+				tokens = append(tokens, string(cur))
+				cur = cur[:0]
+			}
+		default:
+			cur = append(cur, r)
+		}
+	}
+	if len(cur) > 0 {
+		tokens = append(tokens, string(cur))
+	}
+	return tokens
+}
diff --git a/internal/generator/templates/cobratree/typemap.go.tmpl b/internal/generator/templates/cobratree/typemap.go.tmpl
new file mode 100644
index 00000000..22e8f9d7
--- /dev/null
+++ b/internal/generator/templates/cobratree/typemap.go.tmpl
@@ -0,0 +1,76 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+	"regexp"
+	"strings"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+var positionalPattern = regexp.MustCompile(`(?:^|\s)(?:<[^>]+>|\[[^\]]+\])`)
+
+func toolOptionsForFlags(cmd *cobra.Command) []mcplib.ToolOption {
+	var opts []mcplib.ToolOption
+	seen := map[string]bool{}
+	addFlag := func(flag *pflag.Flag) {
+		if flag == nil || flag.Hidden || flag.Deprecated != "" {
+			return
+		}
+		if seen[flag.Name] {
+			return
+		}
+		seen[flag.Name] = true
+		opts = append(opts, toolOptionForFlag(flag))
+	}
+	cmd.InheritedFlags().VisitAll(addFlag)
+	cmd.NonInheritedFlags().VisitAll(addFlag)
+	return opts
+}
+
+func toolOptionForFlag(flag *pflag.Flag) mcplib.ToolOption {
+	propOpts := []mcplib.PropertyOption{mcplib.Description(flagDescription(flag))}
+	if isRequired(flag) {
+		propOpts = append(propOpts, mcplib.Required())
+	}
+	switch flag.Value.Type() {
+	case "bool":
+		return mcplib.WithBoolean(flag.Name, propOpts...)
+	case "int", "int8", "int16", "int32", "int64",
+		"uint", "uint8", "uint16", "uint32", "uint64",
+		"float32", "float64", "count":
+		return mcplib.WithNumber(flag.Name, propOpts...)
+	case "string", "stringSlice", "stringArray", "duration":
+		return mcplib.WithString(flag.Name, propOpts...)
+	default:
+		propOpts[0] = mcplib.Description(flagDescription(flag) + " (unknown Cobra flag type " + flag.Value.Type() + "; pass as a string)")
+		return mcplib.WithString(flag.Name, propOpts...)
+	}
+}
+
+func flagDescription(flag *pflag.Flag) string {
+	usage := strings.TrimSpace(flag.Usage)
+	if usage == "" {
+		usage = "Value for --" + flag.Name
+	}
+	if flag.DefValue != "" && flag.DefValue != "[]" {
+		usage += " (default: " + flag.DefValue + ")"
+	}
+	return usage
+}
+
+func isRequired(flag *pflag.Flag) bool {
+	if flag == nil || flag.Annotations == nil {
+		return false
+	}
+	_, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]
+	return ok
+}
+
+func commandTakesArgs(cmd *cobra.Command) bool {
+	return positionalPattern.MatchString(cmd.Use)
+}
diff --git a/internal/generator/templates/cobratree/walker.go.tmpl b/internal/generator/templates/cobratree/walker.go.tmpl
new file mode 100644
index 00000000..e4c41d01
--- /dev/null
+++ b/internal/generator/templates/cobratree/walker.go.tmpl
@@ -0,0 +1,66 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cobratree
+
+import (
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+	"github.com/spf13/cobra"
+)
+
+// RegisterAll walks root's user-facing Cobra commands and registers shell-out
+// MCP tools for commands that are not already covered by typed endpoint tools.
+func RegisterAll(s *server.MCPServer, root *cobra.Command, cliPath func() (string, error)) {
+	if root == nil {
+		return
+	}
+	walk(root, nil, func(cmd *cobra.Command, path []string) {
+		switch classify(cmd) {
+		case commandHidden:
+			return
+		case commandEndpoint, commandFramework:
+			return
+		}
+		if !cmd.Runnable() {
+			return
+		}
+
+		toolName := toolNameForPath(path)
+		if toolName == "" {
+			return
+		}
+		options := []mcplib.ToolOption{mcplib.WithDescription(descriptionFor(cmd))}
+		options = append(options, toolOptionsForFlags(cmd)...)
+		if commandTakesArgs(cmd) {
+			options = append(options, mcplib.WithString("args", mcplib.Description("Additional positional arguments or raw CLI flags to append to the command.")))
+		}
+		if isMCPReadOnly(cmd) {
+			options = append(options, mcplib.WithReadOnlyHintAnnotation(true), mcplib.WithDestructiveHintAnnotation(false))
+		}
+		s.AddTool(mcplib.NewTool(toolName, options...), shellOutToCLI(cliPath, path))
+	})
+}
+
+func walk(cmd *cobra.Command, path []string, visit func(*cobra.Command, []string)) {
+	for _, child := range cmd.Commands() {
+		if child.Hidden || isMCPHidden(child) {
+			continue
+		}
+		childPath := append(append([]string{}, path...), child.Name())
+		visit(child, childPath)
+		if kind := classify(child); kind != commandHidden && kind != commandFramework {
+			walk(child, childPath, visit)
+		}
+	}
+}
+
+func descriptionFor(cmd *cobra.Command) string {
+	if cmd.Long != "" {
+		return cmd.Long
+	}
+	if cmd.Short != "" {
+		return cmd.Short
+	}
+	return "Run `" + cmd.CommandPath() + "` through the companion CLI binary."
+}
diff --git a/internal/generator/templates/command_endpoint.go.tmpl b/internal/generator/templates/command_endpoint.go.tmpl
index fa8a1bc8..bb7cf40d 100644
--- a/internal/generator/templates/command_endpoint.go.tmpl
+++ b/internal/generator/templates/command_endpoint.go.tmpl
@@ -48,6 +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}}"},
 		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 6ef388cb..045a3c32 100644
--- a/internal/generator/templates/command_promoted.go.tmpl
+++ b/internal/generator/templates/command_promoted.go.tmpl
@@ -26,6 +26,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}}"},
 		RunE: func(cmd *cobra.Command, args []string) error {
 {{- range .Endpoint.Params}}
 {{- if and .Required (not .Positional) (not .Default)}}
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index 63700647..da73b5ae 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -33,7 +33,6 @@ func main() {
 	)
 
 	mcptools.RegisterTools(s)
-	mcptools.RegisterNovelFeatureTools(s)
 
 	transport := flag.String("transport", defaultTransport(), "MCP transport: stdio | http")
 	addr := flag.String("addr", defaultHTTPAddr, "bind address for http transport (host:port or :port)")
@@ -87,7 +86,6 @@ func main() {
 	)
 
 	mcptools.RegisterTools(s)
-	mcptools.RegisterNovelFeatureTools(s)
 
 	if err := server.ServeStdio(s); err != nil {
 		fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 53c75048..3176e39a 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -8,20 +8,19 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
-{{- if .NovelFeatures}}
-	"os/exec"
-{{- end}}
 	"path/filepath"
 	"strings"
 	"time"
 
 	mcplib "github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
+	"{{modulePath}}/internal/cli"
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
 	"{{modulePath}}/internal/cliutil"
 {{- end}}
 	"{{modulePath}}/internal/client"
 	"{{modulePath}}/internal/config"
+	"{{modulePath}}/internal/mcp/cobratree"
 {{- if .VisionSet.Store}}
 	"{{modulePath}}/internal/store"
 {{- end}}
@@ -48,6 +47,19 @@ func RegisterTools(s *server.MCPServer) {
 {{- else}}
 			mcplib.WithString({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (oneline .Description)}})),
 {{- end}}
+{{- end}}
+{{- if eq (upper $endpoint.Method) "GET"}}
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
+{{- else if eq (upper $endpoint.Method) "DELETE"}}
+			mcplib.WithDestructiveHintAnnotation(true),
+			mcplib.WithOpenWorldHintAnnotation(true),
+{{- else if eq (upper $endpoint.Method) "POST"}}
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
+{{- else}}
+			mcplib.WithOpenWorldHintAnnotation(true),
 {{- end}}
 		),
 		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
@@ -66,6 +78,19 @@ func RegisterTools(s *server.MCPServer) {
 {{- else}}
 			mcplib.WithString({{printf "%q" .Name}}{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description({{printf "%q" (oneline .Description)}})),
 {{- end}}
+{{- end}}
+{{- if eq (upper $endpoint.Method) "GET"}}
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
+{{- else if eq (upper $endpoint.Method) "DELETE"}}
+			mcplib.WithDestructiveHintAnnotation(true),
+			mcplib.WithOpenWorldHintAnnotation(true),
+{{- else if eq (upper $endpoint.Method) "POST"}}
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
+{{- else}}
+			mcplib.WithOpenWorldHintAnnotation(true),
 {{- end}}
 		),
 		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
@@ -80,19 +105,6 @@ func RegisterTools(s *server.MCPServer) {
 {{- end}}
 {{- end}}
 
-{{- if .VisionSet.Sync}}
-	// Sync tool — populates local database for offline search and sql queries
-	s.AddTool(
-		mcplib.NewTool("sync",
-			mcplib.WithDescription("Sync API data to local SQLite database. Run this before using search or sql tools. Supports incremental sync."),
-			mcplib.WithString("resources", mcplib.Description("Comma-separated resource types to sync (omit for all)")),
-			mcplib.WithString("since", mcplib.Description("Incremental sync since duration (e.g. 7d, 24h, 1w)")),
-			mcplib.WithBoolean("full", mcplib.Description("Full resync ignoring checkpoints")),
-		),
-		handleSync,
-	)
-{{- end}}
-
 {{- if .VisionSet.Search}}
 	// Search tool — faster than iterating list endpoints for finding specific items
 	s.AddTool(
@@ -100,6 +112,8 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithDescription("Full-text search across all synced data. Faster than paginating list endpoints. Requires sync first."),
 			mcplib.WithString("query", mcplib.Required(), mcplib.Description("Search query (supports FTS5 syntax: AND, OR, NOT, quotes for phrases)")),
 			mcplib.WithNumber("limit", mcplib.Description("Max results (default 25)")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
 		),
 		handleSearch,
 	)
@@ -111,6 +125,8 @@ func RegisterTools(s *server.MCPServer) {
 		mcplib.NewTool("sql",
 			mcplib.WithDescription("Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first."),
 			mcplib.WithString("query", mcplib.Required(), mcplib.Description("SQL query (SELECT only). Tables match resource names.")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
 		),
 		handleSQL,
 	)
@@ -121,9 +137,15 @@ func RegisterTools(s *server.MCPServer) {
 	s.AddTool(
 		mcplib.NewTool("context",
 			mcplib.WithDescription("Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first."),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
 		),
 		handleContext,
 	)
+
+	// Runtime Cobra-tree mirror — exposes every user-facing command that is
+	// not already covered by a typed endpoint or framework MCP tool.
+	cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
 }
 
 // makeAPIHandler creates a generic MCP tool handler for an API endpoint.
@@ -282,13 +304,6 @@ func dbPath() string {
 // Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).
 // The CLI's defaultDBPath() in the cli package uses the same canonical path.
 
-{{- if .VisionSet.Sync}}
-
-func handleSync(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
-	return mcplib.NewToolResultText("sync not yet implemented via MCP - use the CLI: {{.Name}}-pp-cli sync"), nil
-}
-{{- end}}
-
 {{- if .VisionSet.Search}}
 
 func handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
@@ -375,9 +390,8 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		"description": {{printf "%q" .DomainContext.Description}},
 		"archetype":   {{printf "%q" .DomainContext.Archetype}},
 		"tool_count":  {{.MCPTotalCount}},
-		// tool_surface tells agents which surface a capability lives on so
-		// they don't try to invoke cli_only_capabilities through MCP.
-		"tool_surface": "MCP exposes the endpoints listed under `resources` (plus sync/search/sql/context utilities when present). Items under `cli_only_capabilities` require running the companion {{.Name}}-pp-cli binary; the MCP cannot invoke them.",
+		// 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 {{.Name}}-pp-cli binary.",
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
 		"auth": map[string]any{
 			"type": {{printf "%q" .Auth.Type}},
@@ -414,13 +428,11 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		},
 {{- end}}
 {{- if .NovelFeatures}}
-		// CLI-only features. Each entry's `via: "cli"` is intentional and
-		// machine-readable: an agent that filters tool surfaces by transport
-		// can skip these when scoring "what can I do via MCP?" without
-		// pattern-matching prose.
-		"cli_only_capabilities": []map[string]string{
+		// Command-mirror capabilities are exposed through MCP by shelling out
+		// to the companion CLI binary.
+		"command_mirror_capabilities": []map[string]string{
 {{- range .NovelFeatures}}
-			{"name": {{printf "%q" .Name}}, "command": {{printf "%q" .Command}}, "description": {{printf "%q" (oneline .Description)}}, "rationale": {{printf "%q" (oneline .Rationale)}}, "via": "cli"},
+			{"name": {{printf "%q" .Name}}, "command": {{printf "%q" .Command}}, "description": {{printf "%q" (oneline .Description)}}, "rationale": {{printf "%q" (oneline .Rationale)}}, "via": "mcp-command-mirror"},
 {{- end}}
 		},
 {{- end}}
@@ -436,79 +448,9 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 	return mcplib.NewToolResultText(string(data)), nil
 }
 
-// RegisterNovelFeatureTools registers MCP tools that shell out to the
-// companion CLI binary. Empty body when the spec has no novel features.
+// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP
+// mains. New generated mains call RegisterTools only; RegisterTools now
+// includes the runtime Cobra-tree mirror.
 func RegisterNovelFeatureTools(s *server.MCPServer) {
-{{- range .NovelFeatures}}
-	s.AddTool(
-		mcplib.NewTool({{printf "%q" (mcpToolName .Command)}},
-			mcplib.WithDescription({{printf "%q" (oneline .Description)}}),
-			mcplib.WithString("args", mcplib.Description("Arguments to pass to the CLI command (e.g. \"--domain stripe.com --json\"). Empty string for no args.")),
-		),
-		shellOutToCLI({{printf "%q" .Command}}),
-	)
-{{- end}}
+	_ = s
 }
-{{- if .NovelFeatures}}
-
-// siblingCLIPath resolves the companion CLI via sibling-of-executable,
-// {{envPrefix .Name}}_CLI_PATH env var, then PATH.
-func siblingCLIPath() (string, error) {
-	const cliName = "{{.Name}}-pp-cli"
-	if exe, err := os.Executable(); err == nil {
-		candidate := filepath.Join(filepath.Dir(exe), cliName)
-		if _, err := os.Stat(candidate); err == nil {
-			return candidate, nil
-		}
-	}
-	if v := os.Getenv("{{envPrefix .Name}}_CLI_PATH"); v != "" {
-		return v, nil
-	}
-	return exec.LookPath(cliName)
-}
-
-// shellOutToCLI returns an MCP tool handler that runs commandSpec against
-// the companion CLI. Resolves the binary path and pre-splits commandSpec
-// at registration so the per-call work is just user-arg split + exec.
-func shellOutToCLI(commandSpec string) server.ToolHandlerFunc {
-	cliPath, lookupErr := siblingCLIPath()
-	prefixArgs := splitShellArgs(commandSpec)
-	return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
-		if lookupErr != nil {
-			return mcplib.NewToolResultError(fmt.Sprintf("companion CLI binary not found: %v\nTried sibling lookup, {{envPrefix .Name}}_CLI_PATH env var, and PATH.", lookupErr)), nil
-		}
-		userArgs, _ := req.GetArguments()["args"].(string)
-		finalArgs := append(append([]string{}, prefixArgs...), splitShellArgs(userArgs)...)
-		cmd := exec.CommandContext(ctx, cliPath, finalArgs...)
-		out, err := cmd.CombinedOutput()
-		if err != nil {
-			return mcplib.NewToolResultError(string(out)), nil
-		}
-		return mcplib.NewToolResultText(string(out)), nil
-	}
-}
-
-// splitShellArgs whitespace-splits with double-quoted-token preservation.
-func splitShellArgs(s string) []string {
-	var tokens []string
-	var cur []rune
-	inQuote := false
-	for _, r := range s {
-		switch {
-		case r == '"':
-			inQuote = !inQuote
-		case (r == ' ' || r == '\t') && !inQuote:
-			if len(cur) > 0 {
-				tokens = append(tokens, string(cur))
-				cur = cur[:0]
-			}
-		default:
-			cur = append(cur, r)
-		}
-	}
-	if len(cur) > 0 {
-		tokens = append(tokens, string(cur))
-	}
-	return tokens
-}
-{{- end}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index d0a62999..77000193 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -46,10 +46,39 @@ type rootFlags struct {
 	deliverSink DeliverSink
 }
 
+// RootCmd returns the Cobra command tree without executing it. The MCP server
+// uses this to mirror every user-facing command as an agent tool.
+func RootCmd() *cobra.Command {
+	var flags rootFlags
+	return newRootCmd(&flags)
+}
+
 // Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
 func Execute() error {
 	var flags rootFlags
+	rootCmd := newRootCmd(&flags)
 
+	err := rootCmd.Execute()
+	if err != nil && strings.Contains(err.Error(), "unknown flag") {
+		msg := err.Error()
+		// Extract the flag name from the error message (e.g., "unknown flag: --foob")
+		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
+			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
+			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+				return fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
+			}
+		}
+	}
+	if err == nil && flags.deliverBuf != nil {
+		if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
+			fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
+			return derr
+		}
+	}
+	return err
+}
+
+func newRootCmd(flags *rootFlags) *cobra.Command {
 	rootCmd := &cobra.Command{
 		Use:   "{{.Name}}-pp-cli",
 {{- /* Root --help Long surfaces all verified-built novel features, not
@@ -185,7 +214,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 		// runs a bounded API refresh. Failures become stderr warnings;
 		// the command proceeds with the stale cache either way.
 		if resources, isRead := readCommandResources[cmd.CommandPath()]; isRead {
-			flags.freshnessMeta = autoRefreshIfStale(cmd.Context(), &flags, resources)
+			flags.freshnessMeta = autoRefreshIfStale(cmd.Context(), flags, resources)
 		}
 {{- end}}
 		return nil
@@ -193,76 +222,59 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 
 {{- range $name, $resource := .Resources}}
 {{- if and (not (index $.PromotedResourceNames $name)) (ne $name "auth") (not (index $.VisionCmdNames $name))}}
-	rootCmd.AddCommand(new{{camel $name}}Cmd(&flags))
+	rootCmd.AddCommand(new{{camel $name}}Cmd(flags))
 {{- end}}
 {{- end}}
-	rootCmd.AddCommand(newDoctorCmd(&flags))
+	rootCmd.AddCommand(newDoctorCmd(flags))
 {{- if .HasAuthCommand}}
-	rootCmd.AddCommand(newAuthCmd(&flags))
+	rootCmd.AddCommand(newAuthCmd(flags))
 {{- end}}
 	rootCmd.AddCommand(newAgentContextCmd(rootCmd))
-	rootCmd.AddCommand(newProfileCmd(&flags))
-	rootCmd.AddCommand(newFeedbackCmd(&flags))
-	rootCmd.AddCommand(newWhichCmd(&flags))
+	rootCmd.AddCommand(newProfileCmd(flags))
+	rootCmd.AddCommand(newFeedbackCmd(flags))
+	rootCmd.AddCommand(newWhichCmd(flags))
 {{- if .HasAsyncJobs}}
-	rootCmd.AddCommand(newJobsCmd(&flags))
+	rootCmd.AddCommand(newJobsCmd(flags))
 {{- end}}
 {{- if .VisionSet.Export}}
-	rootCmd.AddCommand(newExportCmd(&flags))
+	rootCmd.AddCommand(newExportCmd(flags))
 {{- end}}
 {{- if .VisionSet.Import}}
-	rootCmd.AddCommand(newImportCmd(&flags))
+	rootCmd.AddCommand(newImportCmd(flags))
 {{- end}}
 {{- if .VisionSet.Search}}
-	rootCmd.AddCommand(newSearchCmd(&flags))
+	rootCmd.AddCommand(newSearchCmd(flags))
 {{- end}}
 {{- if .VisionSet.Sync}}
-	rootCmd.AddCommand(newSyncCmd(&flags))
+	rootCmd.AddCommand(newSyncCmd(flags))
 {{- end}}
 {{- if .VisionSet.Tail}}
-	rootCmd.AddCommand(newTailCmd(&flags))
+	rootCmd.AddCommand(newTailCmd(flags))
 {{- end}}
 {{- if .VisionSet.Analytics}}
-	rootCmd.AddCommand(newAnalyticsCmd(&flags))
+	rootCmd.AddCommand(newAnalyticsCmd(flags))
 {{- end}}
 {{- if .VisionSet.Store}}
-	rootCmd.AddCommand(newWorkflowCmd(&flags))
+	rootCmd.AddCommand(newWorkflowCmd(flags))
 {{- end}}
 {{- if .Share.Enabled}}
-	rootCmd.AddCommand(newShareCmd(&flags))
+	rootCmd.AddCommand(newShareCmd(flags))
 {{- end}}
 {{- range .WorkflowConstructors}}
-	rootCmd.AddCommand(new{{.}}Cmd(&flags))
+	rootCmd.AddCommand(new{{.}}Cmd(flags))
 {{- end}}
 {{- range .InsightConstructors}}
-	rootCmd.AddCommand(new{{.}}Cmd(&flags))
+	rootCmd.AddCommand(new{{.}}Cmd(flags))
 {{- end}}
 {{- if .PromotedCommands}}
-	rootCmd.AddCommand(newAPICmd(&flags))
+	rootCmd.AddCommand(newAPICmd(flags))
 {{- end}}
 {{- range .PromotedCommands}}
-	rootCmd.AddCommand(new{{camel .PromotedName}}PromotedCmd(&flags))
+	rootCmd.AddCommand(new{{camel .PromotedName}}PromotedCmd(flags))
 {{- end}}
 	rootCmd.AddCommand(newVersionCliCmd())
 
-	err := rootCmd.Execute()
-	if err != nil && strings.Contains(err.Error(), "unknown flag") {
-		msg := err.Error()
-		// Extract the flag name from the error message (e.g., "unknown flag: --foob")
-		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
-			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
-			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
-				return fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
-			}
-		}
-	}
-	if err == nil && flags.deliverBuf != nil {
-		if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
-			fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
-			return derr
-		}
-	}
-	return err
+	return rootCmd
 }
 
 func ExitCode(err error) int {
diff --git a/internal/generator/templates/search.go.tmpl b/internal/generator/templates/search.go.tmpl
index 83188e61..79d3b04d 100644
--- a/internal/generator/templates/search.go.tmpl
+++ b/internal/generator/templates/search.go.tmpl
@@ -107,6 +107,7 @@ In local mode: searches locally synced data only.`,
 
   # JSON output for piping
   {{.Name}}-pp-cli search "critical" --json --limit 20`,
+		Annotations: map[string]string{"mcp:hidden": "true"},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if len(args) == 0 {
 				return cmd.Help()
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 073ea84a..da49636d 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -73,6 +73,23 @@ type NovelFeatureManifest struct {
 	Description string `json:"description"`
 }
 
+// ReadCLIBinaryName reads .printing-press.json from dir and returns the
+// cli_name field. Returns empty string when the file is missing or
+// unparseable so callers can fall back to convention. Used by the MCPB
+// bundle builder, which can't store the CLI binary name in manifest.json
+// (Claude Desktop's MCPB v0.3 validator rejects unknown top-level keys).
+func ReadCLIBinaryName(dir string) string {
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	if err != nil {
+		return ""
+	}
+	var m CLIManifest
+	if err := json.Unmarshal(data, &m); err != nil {
+		return ""
+	}
+	return m.CLIName
+}
+
 // WriteCLIManifest marshals m as indented JSON and writes it to
 // dir/.printing-press.json.
 func WriteCLIManifest(dir string, m CLIManifest) error {
diff --git a/internal/pipeline/dogfood.go b/internal/pipeline/dogfood.go
index 8983794d..b48181c0 100644
--- a/internal/pipeline/dogfood.go
+++ b/internal/pipeline/dogfood.go
@@ -32,6 +32,7 @@ type DogfoodReport struct {
 	ExampleCheck          ExampleCheckResult          `json:"example_check"`
 	WiringCheck           WiringCheckResult           `json:"wiring_check"`
 	NovelFeaturesCheck    NovelFeaturesCheckResult    `json:"novel_features_check"`
+	MCPSurfaceParityCheck MCPSurfaceResult            `json:"mcp_surface_parity"`
 	ReimplementationCheck ReimplementationCheckResult `json:"reimplementation_check"`
 	SourceClientCheck     SourceClientCheckResult     `json:"source_client_check"`
 	TestPresence          TestPresenceResult          `json:"test_presence"`
@@ -258,6 +259,7 @@ func RunDogfood(dir, specPath string, opts ...DogfoodOption) (*DogfoodReport, er
 	report.ExampleCheck = checkExamples(dir)
 	report.WiringCheck = checkWiring(dir)
 	report.NovelFeaturesCheck = checkNovelFeatures(dir, cfg.researchDir)
+	report.MCPSurfaceParityCheck = checkMCPSurfaceParity(dir)
 	report.ReimplementationCheck = checkReimplementation(dir, cfg.researchDir)
 	report.SourceClientCheck = checkSourceClients(dir)
 	report.TestPresence = checkTestPresence(dir)
@@ -287,6 +289,18 @@ func WithResearchDir(dir string) DogfoodOption {
 	}
 }
 
+func checkMCPSurfaceParity(cliDir string) MCPSurfaceResult {
+	result, err := InspectMCPSurface(cliDir)
+	if err != nil {
+		return MCPSurfaceResult{
+			State:  MCPSurfaceUnknown,
+			Pass:   false,
+			Detail: err.Error(),
+		}
+	}
+	return result
+}
+
 // checkNovelFeatures validates that transcendence features from research.json
 // have corresponding registered commands in the generated CLI. It also writes
 // the verified list back as novel_features_built so downstream consumers
@@ -1328,8 +1342,14 @@ var dogfoodVerdictRules = []dogfoodVerdictRule{
 	// Pure-logic packages with zero tests fail shipcheck; prompts alone have not kept this invariant reliable.
 	{"FAIL", func(r *DogfoodReport, _ bool) bool { return len(r.TestPresence.MissingTests) > 0 }},
 	{"FAIL", func(r *DogfoodReport, _ bool) bool { return len(r.NamingCheck.Violations) > 0 }},
+	{"FAIL", func(r *DogfoodReport, _ bool) bool {
+		return mcpSurfaceCheckActive(r.MCPSurfaceParityCheck) && !r.MCPSurfaceParityCheck.HandEdited && !r.MCPSurfaceParityCheck.Pass
+	}},
 	{"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.WiringCheck.WorkflowComplete.UnmappedSteps) > 0 }},
 	{"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.NovelFeaturesCheck.Missing) > 0 }},
+	{"WARN", func(r *DogfoodReport, _ bool) bool {
+		return mcpSurfaceCheckActive(r.MCPSurfaceParityCheck) && r.MCPSurfaceParityCheck.HandEdited
+	}},
 	// Surface hand-rolled responses without hard-blocking early iteration.
 	{"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.ReimplementationCheck.Suspicious) > 0 }},
 	{"WARN", func(r *DogfoodReport, _ bool) bool { return len(r.SourceClientCheck.Findings) > 0 }},
@@ -1396,6 +1416,12 @@ func collectDogfoodIssues(report *DogfoodReport, hasSpec bool) []string {
 			report.NovelFeaturesCheck.Planned,
 			strings.Join(report.NovelFeaturesCheck.Missing, ", ")))
 	}
+	if mcpSurfaceCheckActive(report.MCPSurfaceParityCheck) && !report.MCPSurfaceParityCheck.Pass {
+		issues = append(issues, "MCP surface parity: "+report.MCPSurfaceParityCheck.Detail)
+	}
+	if mcpSurfaceCheckActive(report.MCPSurfaceParityCheck) && report.MCPSurfaceParityCheck.HandEdited {
+		issues = append(issues, "MCP surface parity: "+report.MCPSurfaceParityCheck.Detail)
+	}
 	if len(report.ReimplementationCheck.Suspicious) > 0 {
 		parts := make([]string, 0, len(report.ReimplementationCheck.Suspicious))
 		for _, f := range report.ReimplementationCheck.Suspicious {
@@ -1434,6 +1460,10 @@ func collectDogfoodIssues(report *DogfoodReport, hasSpec bool) []string {
 	return issues
 }
 
+func mcpSurfaceCheckActive(result MCPSurfaceResult) bool {
+	return result.State != ""
+}
+
 func checkExamples(dir string) ExampleCheckResult {
 	result := ExampleCheckResult{}
 
diff --git a/internal/pipeline/mcp_surface.go b/internal/pipeline/mcp_surface.go
new file mode 100644
index 00000000..e3dbd8e6
--- /dev/null
+++ b/internal/pipeline/mcp_surface.go
@@ -0,0 +1,73 @@
+package pipeline
+
+import (
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+)
+
+const generatedToolsMarker = "Generated by CLI Printing Press"
+
+type MCPSurfaceState string
+
+const (
+	MCPSurfaceMissing    MCPSurfaceState = "missing"
+	MCPSurfaceRuntime    MCPSurfaceState = "runtime_walking"
+	MCPSurfaceStaticList MCPSurfaceState = "static_list"
+	MCPSurfaceHandEdited MCPSurfaceState = "hand_edited"
+	MCPSurfaceUnknown    MCPSurfaceState = "unknown_generated"
+)
+
+type MCPSurfaceResult struct {
+	State      MCPSurfaceState `json:"state"`
+	Pass       bool            `json:"pass"`
+	Skipped    bool            `json:"skipped,omitempty"`
+	HandEdited bool            `json:"hand_edited,omitempty"`
+	Detail     string          `json:"detail"`
+}
+
+func InspectMCPSurface(dir string) (MCPSurfaceResult, error) {
+	path := filepath.Join(dir, "internal", "mcp", "tools.go")
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return MCPSurfaceResult{
+				State:   MCPSurfaceMissing,
+				Skipped: true,
+				Pass:    true,
+				Detail:  "no internal/mcp/tools.go; CLI does not ship an MCP surface",
+			}, nil
+		}
+		return MCPSurfaceResult{}, fmt.Errorf("reading %s: %w", path, err)
+	}
+	return ClassifyMCPSurface(string(data)), nil
+}
+
+func ClassifyMCPSurface(src string) MCPSurfaceResult {
+	hasMarker := strings.Contains(src, generatedToolsMarker)
+	hasWalker := strings.Contains(src, "cobratree.RegisterAll")
+	hasStaticList := strings.Contains(src, "RegisterNovelFeatureTools") &&
+		(strings.Contains(src, "shellOutToCLI(") || strings.Contains(src, "siblingCLIPath()"))
+
+	switch {
+	case hasMarker && hasWalker:
+		return MCPSurfaceResult{State: MCPSurfaceRuntime, Pass: true, Detail: "MCP surface mirrors the Cobra tree at runtime"}
+	case hasMarker && hasStaticList:
+		return MCPSurfaceResult{State: MCPSurfaceStaticList, Pass: false, Detail: "static novel-feature MCP list detected; run `printing-press mcp-sync <cli-dir>`"}
+	case !hasMarker:
+		return MCPSurfaceResult{State: MCPSurfaceHandEdited, Pass: true, HandEdited: true, Detail: "tools.go appears hand-edited; mcp-sync will not overwrite it without --force"}
+	default:
+		return MCPSurfaceResult{State: MCPSurfaceUnknown, Pass: false, Detail: "generated MCP tools.go does not use the runtime Cobra-tree template"}
+	}
+}
+
+func IsRuntimeWalkingTemplate(toolsGoPath string) (bool, error) {
+	data, err := os.ReadFile(toolsGoPath)
+	if err != nil {
+		return false, err
+	}
+	result := ClassifyMCPSurface(string(data))
+	return result.State == MCPSurfaceRuntime, nil
+}
diff --git a/internal/pipeline/mcp_surface_test.go b/internal/pipeline/mcp_surface_test.go
new file mode 100644
index 00000000..d19a988b
--- /dev/null
+++ b/internal/pipeline/mcp_surface_test.go
@@ -0,0 +1,40 @@
+package pipeline
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestClassifyMCPSurface(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name string
+		src  string
+		want MCPSurfaceResult
+	}{
+		{
+			name: "runtime walker",
+			src:  "// Generated by CLI Printing Press. DO NOT EDIT.\nfunc RegisterTools(){ cobratree.RegisterAll(s, root, path) }",
+			want: MCPSurfaceResult{State: MCPSurfaceRuntime, Pass: true, Detail: "MCP surface mirrors the Cobra tree at runtime"},
+		},
+		{
+			name: "static list",
+			src:  "// Generated by CLI Printing Press. DO NOT EDIT.\nfunc RegisterNovelFeatureTools(){ shellOutToCLI(\"stale\") }",
+			want: MCPSurfaceResult{State: MCPSurfaceStaticList, Pass: false, Detail: "static novel-feature MCP list detected; run `printing-press mcp-sync <cli-dir>`"},
+		},
+		{
+			name: "hand edited",
+			src:  "package mcp\nfunc RegisterTools(){}",
+			want: MCPSurfaceResult{State: MCPSurfaceHandEdited, Pass: true, HandEdited: true, Detail: "tools.go appears hand-edited; mcp-sync will not overwrite it without --force"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tt.want, ClassifyMCPSurface(tt.src))
+		})
+	}
+}
diff --git a/internal/pipeline/mcpb_bundle.go b/internal/pipeline/mcpb_bundle.go
index 975d780d..d7036852 100644
--- a/internal/pipeline/mcpb_bundle.go
+++ b/internal/pipeline/mcpb_bundle.go
@@ -19,13 +19,17 @@ import (
 // information so multi-platform builds don't overwrite each other.
 //
 // CLIBinaryPath is optional — when set, the bundle includes a second
-// binary at `bin/<manifest.cli_binary>` so the MCP server can shell out
-// to its companion CLI for novel-feature tools. Empty CLIBinaryPath
-// produces a single-binary bundle (the CLI must be on PATH for
-// novel-feature tools to work).
+// binary at `bin/<CLIBinaryName>` so the MCP server can shell out to its
+// companion CLI for novel-feature tools. Empty CLIBinaryPath produces a
+// single-binary bundle (the CLI must be on PATH for novel-feature tools
+// to work). CLIBinaryName must be set when CLIBinaryPath is — it names
+// the binary inside the zip; we deliberately do NOT serialize this name
+// into manifest.json because Claude Desktop's MCPB v0.3 schema
+// strictly rejects unknown top-level keys.
 type BundleParams struct {
 	CLIDir        string
 	BinaryPath    string
+	CLIBinaryName string
 	CLIBinaryPath string
 	OutputPath    string
 }
@@ -78,8 +82,8 @@ func BuildMCPBBundle(params BundleParams) error {
 		_ = zw.Close()
 		return fmt.Errorf("writing MCP binary into bundle: %w", err)
 	}
-	if params.CLIBinaryPath != "" && manifest.CLIBinary != "" {
-		if err := zipFile(zw, "bin/"+manifest.CLIBinary, params.CLIBinaryPath); err != nil {
+	if params.CLIBinaryPath != "" && params.CLIBinaryName != "" {
+		if err := zipFile(zw, "bin/"+params.CLIBinaryName, params.CLIBinaryPath); err != nil {
 			_ = zw.Close()
 			return fmt.Errorf("writing CLI binary into bundle: %w", err)
 		}
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index 2a913f85..be361d1c 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -65,11 +65,6 @@ type MCPBManifest struct {
 	Server          MCPBServer         `json:"server"`
 	UserConfig      map[string]MCPBVar `json:"user_config,omitempty"`
 	Compatibility   *MCPBCompat        `json:"compatibility,omitempty"`
-	// CLIBinary, when set, names the companion CLI binary shipped alongside
-	// the MCP binary in the bundle. Documentation only — the host doesn't
-	// launch it directly; the MCP binary's siblingCLIPath() helper finds it
-	// at `${__dirname}/bin/<cli_binary>` to power novel-feature tool calls.
-	CLIBinary string `json:"cli_binary,omitempty"`
 }
 
 // MCPBAuthor identifies the bundle publisher. The upstream schema accepts
@@ -159,14 +154,23 @@ func WriteMCPBManifestFromStruct(dir string, m CLIManifest) error {
 	enc := json.NewEncoder(&buf)
 	enc.SetEscapeHTML(false)
 	enc.SetIndent("", "  ")
-	if err := enc.Encode(buildMCPBManifest(m)); err != nil {
+	if err := enc.Encode(buildMCPBManifest(dir, m)); err != nil {
 		return fmt.Errorf("marshaling MCPB manifest: %w", err)
 	}
 	return os.WriteFile(filepath.Join(dir, MCPBManifestFilename), buf.Bytes(), 0o644)
 }
 
-func buildMCPBManifest(m CLIManifest) MCPBManifest {
+func buildMCPBManifest(dir string, m CLIManifest) MCPBManifest {
+	// Resolution order mirrors manifestDescription: canonical source first
+	// (.printing-press.json populated by spec.display_name on modern
+	// prints), then existing manifest.json for older library CLIs whose
+	// codemod-baked display_name carries the right brand casing, then
+	// derived from the API slug. Without the existing-manifest fallback,
+	// mcp-sync regressed library CLIs from "ESPN" → "espn".
 	displayName := m.DisplayName
+	if displayName == "" {
+		displayName = readExistingManifestDisplayName(dir, m.APIName)
+	}
 	if displayName == "" {
 		displayName = m.APIName
 	}
@@ -175,13 +179,12 @@ func buildMCPBManifest(m CLIManifest) MCPBManifest {
 		ManifestVersion: MCPBManifestVersion,
 		Name:            m.MCPBinary,
 		DisplayName:     displayName,
-		CLIBinary:       m.CLIName,
 		// Bundle version tracks the printing-press release that produced
 		// it so Claude Desktop's update detection sees a fresh value on
 		// regeneration. A hardcoded "1.0.0" would defeat the host's
 		// "newer bundle available" prompt.
 		Version:     bundleVersion(m),
-		Description: manifestDescription(m, displayName),
+		Description: manifestDescription(dir, m, displayName),
 		Author:      MCPBAuthor{Name: "CLI Printing Press"},
 		License:     "Apache-2.0",
 		Server: MCPBServer{
@@ -219,13 +222,90 @@ func bundleVersion(m CLIManifest) string {
 // only falls back to a derived sentence when nothing better is available.
 // We deliberately keep this single-line — long_description is reserved for
 // multi-paragraph context, which we don't synthesize from spec data today.
-func manifestDescription(m CLIManifest, displayName string) string {
+//
+// Resolution order: the .printing-press.json description (canonical source
+// when the printing-press version that produced the CLI populates it) →
+// the existing manifest.json's description (preserves rich descriptions
+// baked by older codemods or hand-edits when .printing-press.json lacks
+// the field — e.g., library CLIs printed under v1.x predate the field) →
+// derived from displayName.
+func manifestDescription(dir string, m CLIManifest, displayName string) string {
 	if m.Description != "" {
 		return m.Description
 	}
+	if existing := readExistingManifestDescription(dir, displayName); existing != "" {
+		return existing
+	}
 	return displayName + " API surface as MCP tools."
 }
 
+// readExistingManifestDescription returns the description from an existing
+// manifest.json on disk, but only if it's a real description rather than
+// the derived "<displayName> API surface as MCP tools." default we'd
+// otherwise re-emit. Returning the derived form would defeat the
+// fallback chain by treating an old derived-default as "preserved
+// content" and never advancing past it.
+func readExistingManifestDescription(dir, displayName string) string {
+	data, err := os.ReadFile(filepath.Join(dir, MCPBManifestFilename))
+	if err != nil {
+		return ""
+	}
+	var existing struct {
+		Description string `json:"description"`
+	}
+	if err := json.Unmarshal(data, &existing); err != nil {
+		return ""
+	}
+	derivedDefault := displayName + " API surface as MCP tools."
+	if existing.Description == "" || existing.Description == derivedDefault {
+		return ""
+	}
+	return existing.Description
+}
+
+// readExistingManifestDisplayName returns the display_name from an
+// existing manifest.json if it's a real brand name rather than a derived
+// fallback. The two derived forms we explicitly reject: the bare API slug
+// (lowercase, e.g. "espn") that buildMCPBManifest emits when nothing
+// better is known, and the title-cased slug ("Espn") that the older
+// spec.EffectiveDisplayName fallback used to produce. Anything else —
+// "ESPN", "Cal.com", "Company GOAT", "PokéAPI" — is real brand content
+// from a hand-edit or a codemod and worth preserving across regen.
+func readExistingManifestDisplayName(dir, apiSlug string) string {
+	data, err := os.ReadFile(filepath.Join(dir, MCPBManifestFilename))
+	if err != nil {
+		return ""
+	}
+	var existing struct {
+		DisplayName string `json:"display_name"`
+	}
+	if err := json.Unmarshal(data, &existing); err != nil {
+		return ""
+	}
+	if existing.DisplayName == "" || existing.DisplayName == apiSlug {
+		return ""
+	}
+	titleCased := titleCaseFirstRune(apiSlug)
+	if existing.DisplayName == titleCased {
+		return ""
+	}
+	return existing.DisplayName
+}
+
+// titleCaseFirstRune capitalizes the first ASCII letter of slug. Mirrors
+// the older spec.EffectiveDisplayName fallback so we can detect and
+// reject preserved title-cased slugs that masquerade as real brand names.
+func titleCaseFirstRune(slug string) string {
+	if slug == "" {
+		return ""
+	}
+	runes := []rune(slug)
+	if runes[0] >= 'a' && runes[0] <= 'z' {
+		runes[0] -= 'a' - 'A'
+	}
+	return string(runes)
+}
+
 // buildMCPBEnv maps each declared auth env var into the launch spec's env
 // block, pointing at the corresponding user_config slot. The host fills in
 // the value at runtime from what the user typed (or whatever the keychain
diff --git a/internal/pipeline/mcpsync/sync.go b/internal/pipeline/mcpsync/sync.go
new file mode 100644
index 00000000..98139a2d
--- /dev/null
+++ b/internal/pipeline/mcpsync/sync.go
@@ -0,0 +1,435 @@
+package mcpsync
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/v2/internal/graphql"
+	"github.com/mvanhorn/cli-printing-press/v2/internal/openapi"
+	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline"
+	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
+)
+
+var (
+	ErrHandEdited = errors.New("mcp tools.go appears hand-edited")
+	// errAnnotationSoftFail signals the caller of ensureEndpointAnnotation
+	// that the file could not be annotated for a non-fatal reason (hand-edit
+	// or pre-existing Annotations map). The migration should warn and skip
+	// rather than abort, because the runtime walker registers any
+	// unannotated user-facing command as a shell-out tool — typed endpoint
+	// annotation is an optimization, not a correctness requirement.
+	errAnnotationSoftFail = errors.New("endpoint annotation skipped")
+)
+
+var endpointAnnotationLine = regexp.MustCompile(`(?m)^\s*Annotations: map\[string\]string\{"pp:endpoint": "[^"]+"\},\s*$`)
+
+type Result struct {
+	Changed bool
+	Detail  string
+}
+
+type Options struct {
+	Force bool
+}
+
+func Sync(cliDir string, opts Options) (Result, error) {
+	state, err := pipeline.InspectMCPSurface(cliDir)
+	if err != nil {
+		return Result{}, err
+	}
+	if state.State == pipeline.MCPSurfaceHandEdited && !opts.Force {
+		return Result{}, fmt.Errorf("%w: tools.go appears hand-edited; refusing to overwrite. Use --force to override at your own risk", ErrHandEdited)
+	}
+	// MCPSurfaceRuntime means the MCP source is already on the new walker
+	// template and we don't need to migrate that. But we still refresh
+	// metadata files (manifest.json, tools-manifest.json) because their
+	// upstream sources (.printing-press.json description, spec auth, etc.)
+	// may have changed since the last sync. Skipping these would silently
+	// freeze stale descriptions/annotations through future regen.
+	alreadyMigrated := state.State == pipeline.MCPSurfaceRuntime
+
+	parsed, err := loadArchivedSpec(cliDir)
+	if err != nil {
+		return Result{}, err
+	}
+	// Preserve the existing manifest.json's display_name onto the parsed
+	// spec when the spec itself doesn't carry one. Library CLIs printed
+	// before spec.display_name existed (v1.x) lack the canonical source,
+	// but the PR #145 codemod baked the right brand casing into
+	// manifest.json from registry.json. Without this, mcp-sync's
+	// regeneration drops "ESPN" back to the title-cased slug ("Espn"),
+	// regressing both the MCP server identity (NewMCPServer first arg)
+	// and the bundled manifest's display_name field.
+	if parsed.DisplayName == "" {
+		if existing := readExistingManifestDisplayName(cliDir); existing != "" {
+			parsed.DisplayName = existing
+		}
+	}
+	modulePath, err := readModulePath(cliDir)
+	if err != nil {
+		return Result{}, err
+	}
+	features := loadNovelFeatures(cliDir)
+	if !alreadyMigrated {
+		if err := ensureRootCmdExport(cliDir); err != nil {
+			return Result{}, err
+		}
+		if err := ensureEndpointAnnotations(cliDir, parsed, features); err != nil {
+			return Result{}, err
+		}
+		// Older generator templates split MCP handlers into a separate
+		// internal/mcp/handlers.go file. The current template emits all
+		// handlers (handleContext, handleSQL, handleSync, makeAPIHandler,
+		// etc.) in tools.go. Leaving the stale handlers.go in place
+		// during regen produces a "redeclared in this block" build error
+		// because both files now define the same functions. Detect a
+		// generator-marked handlers.go and remove it before regenerating;
+		// refuse to delete a hand-edited one without --force.
+		if err := removeStaleMCPHandlersFile(cliDir, opts.Force); err != nil {
+			return Result{}, err
+		}
+		gen := generator.New(parsed, cliDir)
+		gen.NovelFeatures = features
+		gen.ModulePath = modulePath
+		if err := gen.GenerateMCPSurfaceOnly(); err != nil {
+			return Result{}, fmt.Errorf("rendering MCP surface: %w", err)
+		}
+	}
+	if err := pipeline.WriteToolsManifest(cliDir, parsed); err != nil {
+		return Result{}, fmt.Errorf("regenerating tools-manifest.json: %w", err)
+	}
+	// Regenerate the MCPB manifest too. The schema can drift between
+	// generator releases (most recently: cli_binary was removed because
+	// Claude Desktop strict-validates v0.3 keys). mcp-sync without this
+	// step left every library CLI with a manifest that fails drag-drop
+	// install in Claude Desktop.
+	if err := pipeline.WriteMCPBManifest(cliDir); err != nil {
+		return Result{}, fmt.Errorf("regenerating manifest.json: %w", err)
+	}
+	if alreadyMigrated {
+		return Result{Changed: true, Detail: "refreshed manifest.json + tools-manifest.json from current spec/.printing-press.json"}, nil
+	}
+	return Result{Changed: true, Detail: "migrated MCP surface to runtime Cobra-tree mirror"}, nil
+}
+
+func loadArchivedSpec(cliDir string) (*spec.APISpec, error) {
+	for _, name := range []string{"spec.yaml", "spec.yml", "spec.json", "schema.graphql", "schema.gql"} {
+		path := filepath.Join(cliDir, name)
+		data, err := os.ReadFile(path)
+		if err != nil {
+			if errors.Is(err, os.ErrNotExist) {
+				continue
+			}
+			return nil, fmt.Errorf("reading %s: %w", path, err)
+		}
+		if openapi.IsOpenAPI(data) {
+			return openapi.ParseLenient(data)
+		}
+		if graphql.IsGraphQLSDL(data) {
+			return graphql.ParseSDLBytes(path, data)
+		}
+		return spec.ParseBytes(data)
+	}
+	return nil, fmt.Errorf("missing archived spec (expected spec.yaml, spec.yml, spec.json, schema.graphql, or schema.gql)")
+}
+
+func loadNovelFeatures(cliDir string) []generator.NovelFeature {
+	data, err := os.ReadFile(filepath.Join(cliDir, pipeline.CLIManifestFilename))
+	if err != nil {
+		return nil
+	}
+	var manifest pipeline.CLIManifest
+	if err := json.Unmarshal(data, &manifest); err != nil {
+		return nil
+	}
+	features := make([]generator.NovelFeature, 0, len(manifest.NovelFeatures))
+	for _, nf := range manifest.NovelFeatures {
+		features = append(features, generator.NovelFeature{
+			Name:        nf.Name,
+			Command:     nf.Command,
+			Description: nf.Description,
+		})
+	}
+	return features
+}
+
+func ensureEndpointAnnotations(cliDir string, parsed *spec.APISpec, features []generator.NovelFeature) error {
+	tmp, err := os.MkdirTemp("", "printing-press-mcp-sync-*")
+	if err != nil {
+		return fmt.Errorf("creating endpoint annotation reference tree: %w", err)
+	}
+	defer func() { _ = os.RemoveAll(tmp) }()
+
+	gen := generator.New(parsed, tmp)
+	gen.NovelFeatures = features
+	if modulePath, err := readModulePath(cliDir); err == nil && modulePath != "" {
+		gen.ModulePath = modulePath
+	}
+	if err := gen.Generate(); err != nil {
+		return fmt.Errorf("rendering endpoint annotation reference tree: %w", err)
+	}
+
+	refRoot := filepath.Join(tmp, "internal", "cli")
+	return filepath.WalkDir(refRoot, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if d.IsDir() || filepath.Ext(path) != ".go" {
+			return nil
+		}
+		data, err := os.ReadFile(path)
+		if err != nil {
+			return err
+		}
+		line := endpointAnnotationLine.FindString(string(data))
+		if line == "" {
+			return nil
+		}
+		rel, err := filepath.Rel(tmp, path)
+		if err != nil {
+			return err
+		}
+		err = ensureEndpointAnnotation(filepath.Join(cliDir, rel), line)
+		if err == nil {
+			return nil
+		}
+		// Hand-edited or pre-existing-Annotations files can't have
+		// endpoint annotations added safely. That's not fatal — the runtime
+		// walker registers them as shell-out tools regardless. Warn and
+		// move on so the rest of the migration completes.
+		if errors.Is(err, errAnnotationSoftFail) {
+			fmt.Fprintf(os.Stderr, "warning: %v\n", err)
+			return nil
+		}
+		return err
+	})
+}
+
+func ensureEndpointAnnotation(path, annotationLine string) error {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+		return fmt.Errorf("reading endpoint command %s: %w", path, err)
+	}
+	src := string(data)
+	if strings.Contains(src, `"pp:endpoint"`) {
+		return nil
+	}
+	if !strings.Contains(src, "Generated by CLI Printing Press") {
+		return fmt.Errorf("%w: %s appears hand-edited; runtime walker will register it as a shell-out tool instead", errAnnotationSoftFail, path)
+	}
+	if strings.Contains(src, "\n\t\tAnnotations:") {
+		return fmt.Errorf("%w: %s already has a Cobra annotation map without pp:endpoint; runtime walker will register it as a shell-out tool", errAnnotationSoftFail, path)
+	}
+
+	insertAt := -1
+	if loc := regexp.MustCompile(`(?m)^\t\tExample: .*,\n`).FindStringIndex(src); loc != nil {
+		insertAt = loc[1]
+	} else if loc := regexp.MustCompile(`(?m)^\t\tRunE: func`).FindStringIndex(src); loc != nil {
+		insertAt = loc[0]
+	}
+	if insertAt < 0 {
+		return fmt.Errorf("%s does not match the generated endpoint command shape; cannot add endpoint MCP annotation", path)
+	}
+
+	if !strings.HasSuffix(annotationLine, "\n") {
+		annotationLine += "\n"
+	}
+	next := src[:insertAt] + annotationLine + src[insertAt:]
+	return writeFileAtomic(path, []byte(next))
+}
+
+func ensureRootCmdExport(cliDir string) error {
+	path := filepath.Join(cliDir, "internal", "cli", "root.go")
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return fmt.Errorf("reading root.go: %w", err)
+	}
+	src := string(data)
+	if strings.Contains(src, "func RootCmd() *cobra.Command") {
+		return nil
+	}
+	if !strings.Contains(src, "Generated by CLI Printing Press") {
+		return fmt.Errorf("root.go appears hand-edited; refusing to add RootCmd export")
+	}
+
+	executePrefix := "// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.\nfunc Execute() error {\n\tvar flags rootFlags\n\n\trootCmd := &cobra.Command{"
+	start := strings.Index(src, executePrefix)
+	if start < 0 {
+		executePrefix = "func Execute() error {\n\tvar flags rootFlags\n\n\trootCmd := &cobra.Command{"
+		start = strings.Index(src, executePrefix)
+	}
+	if start < 0 {
+		return fmt.Errorf("root.go does not match the generated Execute shape; cannot add RootCmd export automatically")
+	}
+	prolog := `// RootCmd returns the Cobra command tree without executing it. The MCP server
+// uses this to mirror every user-facing command as an agent tool.
+func RootCmd() *cobra.Command {
+	var flags rootFlags
+	return newRootCmd(&flags)
+}
+
+// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
+func Execute() error {
+	var flags rootFlags
+	rootCmd := newRootCmd(&flags)
+
+	err := rootCmd.Execute()
+	if err != nil && strings.Contains(err.Error(), "unknown flag") {
+		msg := err.Error()
+		// Extract the flag name from the error message (e.g., "unknown flag: --foob")
+		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
+			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
+			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+				return fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
+			}
+		}
+	}
+	if err == nil && flags.deliverBuf != nil {
+		if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
+			fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
+			return derr
+		}
+	}
+	return err
+}
+
+func newRootCmd(flags *rootFlags) *cobra.Command {
+	rootCmd := &cobra.Command{`
+
+	// After the Execute → newRootCmd refactor, `flags` is *rootFlags
+	// rather than a struct value, so any bare `&flags` reference (passing
+	// the whole struct's address) needs to drop the `&`. We must NOT
+	// touch `&flags.someField` (taking the address of an individual
+	// field) — those still compile because field access through a
+	// pointer auto-dereferences. The earlier ReplaceAll-based approach
+	// only caught `(&flags)` (single-arg or trailing-arg callsites) and
+	// missed multi-arg shapes like `f(cmd.Context(), &flags, resources)`
+	// and `f(x, &flags)`. The regex below matches `&flags` only when the
+	// next character is something other than `.` or a word character —
+	// i.e., a comma, close-paren, semicolon, brace, etc. — which
+	// distinguishes "address of the whole struct" from "address of a
+	// field" without false positives.
+	bareFlagsRef := regexp.MustCompile(`&flags([^.\w]|$)`)
+	tail := bareFlagsRef.ReplaceAllString(src[start+len(executePrefix):], "flags$1")
+	src = src[:start] + prolog + tail
+
+	exitStart := strings.LastIndex(src, "\n\terr := rootCmd.Execute()")
+	exitEnd := strings.Index(src, "\nfunc ExitCode")
+	if exitStart < 0 || exitEnd < 0 || exitStart > exitEnd {
+		return fmt.Errorf("root.go does not match the generated Execute footer; cannot add RootCmd export automatically")
+	}
+	src = src[:exitStart] + "\n\treturn rootCmd\n}\n" + src[exitEnd:]
+
+	return writeFileAtomic(path, []byte(src))
+}
+
+// readExistingManifestDisplayName returns the display_name from an existing
+// manifest.json on disk if it's a real brand name rather than the
+// title-cased slug fallback. Used by Sync to preserve PR #145 codemod
+// brand-casing for library CLIs printed before spec.display_name existed.
+func readExistingManifestDisplayName(cliDir string) string {
+	manifestData, err := os.ReadFile(filepath.Join(cliDir, "manifest.json"))
+	if err != nil {
+		return ""
+	}
+	var existing struct {
+		Name        string `json:"name"`
+		DisplayName string `json:"display_name"`
+	}
+	if err := json.Unmarshal(manifestData, &existing); err != nil {
+		return ""
+	}
+	if existing.DisplayName == "" {
+		return ""
+	}
+	// The derived form for old prints is the title-cased mcp-binary slug
+	// minus the "-pp-mcp" suffix (e.g., "espn-pp-mcp" → "Espn"). If the
+	// existing display_name matches that derived shape, treat it as no
+	// brand info and fall through.
+	derived := titleCaseFromSlug(strings.TrimSuffix(existing.Name, "-pp-mcp"))
+	if existing.DisplayName == derived {
+		return ""
+	}
+	return existing.DisplayName
+}
+
+// titleCaseFromSlug capitalizes the first rune of a slug. Approximates
+// the spec.EffectiveDisplayName fallback for slugs without an explicit
+// display_name (e.g., "espn" → "Espn"). Mirrors the case-detection logic
+// readExistingManifestDisplayName uses to decide whether the existing
+// manifest carries real brand information.
+func titleCaseFromSlug(slug string) string {
+	if slug == "" {
+		return ""
+	}
+	runes := []rune(slug)
+	if runes[0] >= 'a' && runes[0] <= 'z' {
+		runes[0] -= 'a' - 'A'
+	}
+	return string(runes)
+}
+
+// removeStaleMCPHandlersFile deletes internal/mcp/handlers.go when it
+// carries the generator's don't-edit marker. Older templates split MCP
+// handlers across tools.go and handlers.go; the current template emits
+// everything in tools.go. Leaving the stale file in place causes
+// duplicate function definitions ("handleContext redeclared in this
+// block"). When the file lacks the marker (hand-edited), refuse to
+// delete without --force so we don't blow away custom logic.
+func removeStaleMCPHandlersFile(cliDir string, force bool) error {
+	path := filepath.Join(cliDir, "internal", "mcp", "handlers.go")
+	data, err := os.ReadFile(path)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+		return fmt.Errorf("reading %s: %w", path, err)
+	}
+	if !strings.Contains(string(data), "Generated by CLI Printing Press") && !force {
+		return fmt.Errorf("%s appears hand-edited; refusing to remove. Use --force to override (this will delete your custom handlers)", path)
+	}
+	if err := os.Remove(path); err != nil {
+		return fmt.Errorf("removing stale %s: %w", path, err)
+	}
+	return nil
+}
+
+// readModulePath parses the cli's go.mod and returns the declared module
+// path. mcp-sync needs this so the regenerated MCP source uses the actual
+// import paths the rest of the CLI was built against. Library checkouts
+// declare the full repo path; standalone publishes use the bare CLI name.
+// Either way the existing go.mod is the source of truth.
+func readModulePath(cliDir string) (string, error) {
+	data, err := os.ReadFile(filepath.Join(cliDir, "go.mod"))
+	if err != nil {
+		return "", fmt.Errorf("reading go.mod: %w", err)
+	}
+	for _, line := range strings.SplitN(string(data), "\n", 50) {
+		line = strings.TrimSpace(line)
+		if strings.HasPrefix(line, "module ") {
+			return strings.TrimSpace(strings.TrimPrefix(line, "module")), nil
+		}
+	}
+	return "", fmt.Errorf("go.mod missing module declaration")
+}
+
+func writeFileAtomic(path string, data []byte) error {
+	tmp := path + ".tmp"
+	if err := os.WriteFile(tmp, data, 0o644); err != nil {
+		return fmt.Errorf("writing temporary %s: %w", path, err)
+	}
+	if err := os.Rename(tmp, path); err != nil {
+		return fmt.Errorf("replacing %s: %w", path, err)
+	}
+	return nil
+}
diff --git a/internal/pipeline/mcpsync/sync_test.go b/internal/pipeline/mcpsync/sync_test.go
new file mode 100644
index 00000000..bcd1f2c3
--- /dev/null
+++ b/internal/pipeline/mcpsync/sync_test.go
@@ -0,0 +1,352 @@
+package mcpsync
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
+)
+
+func TestSyncBackfillsEndpointAnnotations(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "synctest",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"SYNCTEST_TOKEN"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/synctest-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"projects": {
+				Description: "Manage projects",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {Method: "GET", Path: "/projects", Description: "List projects"},
+					"get":  {Method: "GET", Path: "/projects/{id}", Description: "Get a project"},
+				},
+			},
+		},
+	}
+
+	cliDir := filepath.Join(t.TempDir(), "synctest")
+	gen := generator.New(apiSpec, cliDir)
+	require.NoError(t, gen.Generate())
+
+	specData, err := yaml.Marshal(apiSpec)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "spec.yaml"), specData, 0o644))
+
+	endpointPath := filepath.Join(cliDir, "internal", "cli", "projects_list.go")
+	endpointData, err := os.ReadFile(endpointPath)
+	require.NoError(t, err)
+	withoutAnnotation := endpointAnnotationLine.ReplaceAllString(string(endpointData), "")
+	require.NotContains(t, withoutAnnotation, `"pp:endpoint"`)
+	require.NoError(t, os.WriteFile(endpointPath, []byte(withoutAnnotation), 0o644))
+
+	oldTools := `// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package mcp
+
+func RegisterNovelFeatureTools() { shellOutToCLI("projects list") }
+`
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "internal", "mcp", "tools.go"), []byte(oldTools), 0o644))
+
+	result, err := Sync(cliDir, Options{})
+	require.NoError(t, err)
+	assert.True(t, result.Changed)
+
+	updatedEndpoint, err := os.ReadFile(endpointPath)
+	require.NoError(t, err)
+	assert.Contains(t, string(updatedEndpoint), `Annotations: map[string]string{"pp:endpoint": "projects.list"}`)
+
+	updatedTools, err := os.ReadFile(filepath.Join(cliDir, "internal", "mcp", "tools.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(updatedTools), "cobratree.RegisterAll")
+}
+
+func TestEnsureRootCmdExportPreservesRootFlagPointer(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "cli"), 0o755))
+	rootPath := filepath.Join(cliDir, "internal", "cli", "root.go")
+	require.NoError(t, os.WriteFile(rootPath, []byte(`// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+import "github.com/spf13/cobra"
+
+type rootFlags struct{}
+
+// Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
+func Execute() error {
+	var flags rootFlags
+
+	rootCmd := &cobra.Command{
+		Use: "synctest-pp-cli",
+	}
+	rootCmd.AddCommand(newDoctorCmd(&flags))
+
+	err := rootCmd.Execute()
+	return err
+}
+
+func newDoctorCmd(flags *rootFlags) *cobra.Command {
+	return &cobra.Command{Use: "doctor"}
+}
+
+func ExitCode(err error) int { return 0 }
+`), 0o644))
+
+	require.NoError(t, ensureRootCmdExport(cliDir))
+
+	updated, err := os.ReadFile(rootPath)
+	require.NoError(t, err)
+	src := string(updated)
+	assert.Contains(t, src, "return newRootCmd(&flags)")
+	assert.Contains(t, src, "rootCmd := newRootCmd(&flags)")
+	assert.Contains(t, src, "rootCmd.AddCommand(newDoctorCmd(flags))")
+	assert.NotContains(t, src, "newRootCmd(flags)")
+}
+
+// TestRemoveStaleMCPHandlersFile locks behavior for the food52 case:
+// older generator templates emitted MCP handlers in a separate
+// internal/mcp/handlers.go; the current template emits everything in
+// tools.go. mcp-sync needs to delete the stale handlers.go before
+// regenerating to avoid "handleContext redeclared in this block"
+// build errors.
+func TestRemoveStaleMCPHandlersFile(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	mcpDir := filepath.Join(cliDir, "internal", "mcp")
+	require.NoError(t, os.MkdirAll(mcpDir, 0o755))
+	handlersPath := filepath.Join(mcpDir, "handlers.go")
+	require.NoError(t, os.WriteFile(handlersPath, []byte(`// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package mcp
+func handleContext() {}
+`), 0o644))
+
+	require.NoError(t, removeStaleMCPHandlersFile(cliDir, false))
+
+	if _, err := os.Stat(handlersPath); !os.IsNotExist(err) {
+		t.Errorf("expected handlers.go to be removed; stat err: %v", err)
+	}
+}
+
+func TestRemoveStaleMCPHandlersFileNoOpWhenAbsent(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "mcp"), 0o755))
+
+	if err := removeStaleMCPHandlersFile(cliDir, false); err != nil {
+		t.Errorf("expected no error when handlers.go is absent, got: %v", err)
+	}
+}
+
+func TestRemoveStaleMCPHandlersFileRefusesHandEdits(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	mcpDir := filepath.Join(cliDir, "internal", "mcp")
+	require.NoError(t, os.MkdirAll(mcpDir, 0o755))
+	handlersPath := filepath.Join(mcpDir, "handlers.go")
+	// Hand-edited: marker absent. Removing this would destroy custom logic.
+	require.NoError(t, os.WriteFile(handlersPath, []byte(`package mcp
+// Hand-written by maintainer. Marker stripped on purpose.
+func handleContext() {}
+`), 0o644))
+
+	err := removeStaleMCPHandlersFile(cliDir, false)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "appears hand-edited")
+	if _, err := os.Stat(handlersPath); err != nil {
+		t.Errorf("handlers.go should still exist after refusal; stat err: %v", err)
+	}
+}
+
+func TestRemoveStaleMCPHandlersFileForceOverride(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	mcpDir := filepath.Join(cliDir, "internal", "mcp")
+	require.NoError(t, os.MkdirAll(mcpDir, 0o755))
+	handlersPath := filepath.Join(mcpDir, "handlers.go")
+	require.NoError(t, os.WriteFile(handlersPath, []byte(`package mcp
+// Hand-written. --force will delete this anyway.
+func handleContext() {}
+`), 0o644))
+
+	require.NoError(t, removeStaleMCPHandlersFile(cliDir, true))
+
+	if _, err := os.Stat(handlersPath); !os.IsNotExist(err) {
+		t.Errorf("expected --force to remove hand-edited handlers.go; stat err: %v", err)
+	}
+}
+
+// TestEnsureRootCmdExportRewritesMultiArgFlagsCallsites locks the
+// regex behavior that distinguishes "&flags" (whole struct address —
+// must become "flags" after refactor) from "&flags.someField" (field
+// address — must stay). The earlier (&flags)→(flags) ReplaceAll
+// missed callsites where &flags appeared in the middle of an arg
+// list, breaking compilation on real library CLIs (hackernews has
+// `autoRefreshIfStale(cmd.Context(), &flags, resources)`).
+func TestEnsureRootCmdExportRewritesMultiArgFlagsCallsites(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "cli"), 0o755))
+	rootPath := filepath.Join(cliDir, "internal", "cli", "root.go")
+	require.NoError(t, os.WriteFile(rootPath, []byte(`// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+import "github.com/spf13/cobra"
+
+type rootFlags struct{ asJSON bool }
+
+func Execute() error {
+	var flags rootFlags
+
+	rootCmd := &cobra.Command{
+		Use: "synctest-pp-cli",
+	}
+	rootCmd.PersistentFlags().BoolVar(&flags.asJSON, "json", false, "")
+
+	// Multi-arg call passing &flags — must become flags.
+	autoRefreshIfStale(rootCmd.Context(), &flags, []string{"x"})
+	// First-of-many call — must also become flags.
+	doSomething(&flags, "x", "y")
+	// Last-of-many call — must become flags.
+	finalize("a", &flags)
+	// Field-access — must NOT become flags.asJSON (would be a different bug).
+	useFlag(&flags.asJSON)
+
+	err := rootCmd.Execute()
+	return err
+}
+
+func ExitCode(err error) int { return 0 }
+`), 0o644))
+
+	require.NoError(t, ensureRootCmdExport(cliDir))
+
+	updated, err := os.ReadFile(rootPath)
+	require.NoError(t, err)
+	src := string(updated)
+
+	// All three bare-&flags callsite shapes should rewrite cleanly.
+	assert.Contains(t, src, "autoRefreshIfStale(rootCmd.Context(), flags, []string{")
+	assert.Contains(t, src, "doSomething(flags, \"x\", \"y\")")
+	assert.Contains(t, src, "finalize(\"a\", flags)")
+
+	// Field-access must remain untouched — &flags.asJSON is still valid
+	// when flags is *rootFlags (Go auto-dereferences for field access).
+	assert.Contains(t, src, "BoolVar(&flags.asJSON,")
+	assert.Contains(t, src, "useFlag(&flags.asJSON)")
+
+	// And the prologue should still produce the canonical RootCmd export.
+	assert.Contains(t, src, "func RootCmd() *cobra.Command")
+	assert.Contains(t, src, "return newRootCmd(&flags)")
+}
+
+// TestEnsureRootCmdExportIsIdempotent locks the no-op behavior on a root.go
+// that already has RootCmd. Future template changes that add or remove
+// surrounding code must not reintroduce the export and double-define it.
+func TestEnsureRootCmdExportIsIdempotent(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "cli"), 0o755))
+	rootPath := filepath.Join(cliDir, "internal", "cli", "root.go")
+	original := []byte(`// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+import "github.com/spf13/cobra"
+
+type rootFlags struct{}
+
+func RootCmd() *cobra.Command {
+	var flags rootFlags
+	return newRootCmd(&flags)
+}
+
+func Execute() error { return nil }
+
+func newRootCmd(flags *rootFlags) *cobra.Command { return &cobra.Command{} }
+`)
+	require.NoError(t, os.WriteFile(rootPath, original, 0o644))
+
+	require.NoError(t, ensureRootCmdExport(cliDir))
+
+	updated, err := os.ReadFile(rootPath)
+	require.NoError(t, err)
+	assert.Equal(t, string(original), string(updated), "ensureRootCmdExport should not modify root.go that already exports RootCmd")
+}
+
+// TestEnsureRootCmdExportRefusesHandEdits guards the hand-edit safety net.
+// If the generator's don't-edit marker is missing the migration must refuse
+// rather than silently overwriting whatever shape the file has now.
+func TestEnsureRootCmdExportRefusesHandEdits(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "cli"), 0o755))
+	rootPath := filepath.Join(cliDir, "internal", "cli", "root.go")
+	require.NoError(t, os.WriteFile(rootPath, []byte(`// Hand-edited by maintainer; marker stripped on purpose.
+package cli
+
+import "github.com/spf13/cobra"
+
+type rootFlags struct{}
+
+func Execute() error {
+	var flags rootFlags
+	rootCmd := &cobra.Command{Use: "synctest"}
+	_ = flags
+	return rootCmd.Execute()
+}
+`), 0o644))
+
+	err := ensureRootCmdExport(cliDir)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "appears hand-edited")
+}
+
+// TestEnsureRootCmdExportFailsClosedOnUnknownShape locks the failure path
+// when ensureRootCmdExport encounters a generator-marked file whose Execute
+// shape no longer matches the regex. The right behavior is "error out so the
+// human investigates," not "patch what you find anyway." This test pins that
+// contract and will fail if anyone weakens it.
+func TestEnsureRootCmdExportFailsClosedOnUnknownShape(t *testing.T) {
+	t.Parallel()
+
+	cliDir := t.TempDir()
+	require.NoError(t, os.MkdirAll(filepath.Join(cliDir, "internal", "cli"), 0o755))
+	rootPath := filepath.Join(cliDir, "internal", "cli", "root.go")
+	require.NoError(t, os.WriteFile(rootPath, []byte(`// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+package cli
+
+// This shape doesn't match the prologue ensureRootCmdExport expects.
+func Execute() error {
+	cfg := loadConfig()
+	root := buildRoot(cfg)
+	return root.Execute()
+}
+`), 0o644))
+
+	err := ensureRootCmdExport(cliDir)
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "does not match the generated Execute shape")
+}
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index a2d0c453..10b86f3a 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -631,13 +631,15 @@ func scoreMCPQuality(dir string) int {
 
 	// High-level tools: sql, search, sync exposed to MCP (not just CLI)
 	highlevelCount := 0
+	hasRuntimeMirror := strings.Contains(mcpContent, "cobratree.RegisterAll")
 	if strings.Contains(mcpContent, `"sql"`) && strings.Contains(mcpContent, "handleSQL") {
 		highlevelCount++
 	}
 	if strings.Contains(mcpContent, `"search"`) && strings.Contains(mcpContent, "handleSearch") {
 		highlevelCount++
 	}
-	if strings.Contains(mcpContent, `"sync"`) && strings.Contains(mcpContent, "handleSync") {
+	if (strings.Contains(mcpContent, `"sync"`) && strings.Contains(mcpContent, "handleSync")) ||
+		(hasRuntimeMirror && fileExists(filepath.Join(dir, "internal", "cli", "sync.go"))) {
 		highlevelCount++
 	}
 	if highlevelCount >= 2 {
diff --git a/skills/printing-press-polish/SKILL.md b/skills/printing-press-polish/SKILL.md
index 8eced115..c0d35545 100644
--- a/skills/printing-press-polish/SKILL.md
+++ b/skills/printing-press-polish/SKILL.md
@@ -177,4 +177,5 @@ End normally.
 - Do not add new features. Polish fixes quality issues, not feature gaps.
 - Do not re-run research or generation. Polish works with the CLI as-is.
 - Do not modify the printing-press generator. That's `/printing-press-retro`.
+- If polish adds or renames a Cobra command, the MCP surface updates automatically through the generated `internal/mcp/cobratree` runtime mirror. Update `novel_features` only when README/SKILL highlights or registry display should change; use `cmd.Annotations["mcp:hidden"] = "true"` for debug-only commands.
 - Maximum 3 total polish passes (initial + 2 retries).
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index a94648f0..72d2e379 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1192,7 +1192,7 @@ REOF
 
 For each tool, fill in what you know from the research. Stars and command_count are optional (use 0 if unknown). The `language` field should match the primary implementation language. Skip tools that were found during search but contributed zero features to the manifest.
 
-**Novel features rules** (the `novel_features` array populates the README's "Unique Features" section and SKILL.md's "Unique Capabilities" block):
+**Novel features rules** (the `novel_features` array populates the README's "Unique Features" section and SKILL.md's "Unique Capabilities" block; MCP exposure comes from the runtime Cobra-tree mirror, not this list):
 1. Include all transcendence features from the manifest that scored >= 5/10. Order by score descending.
 2. `description` should be user-benefit language, not implementation detail. Good: "See which team members are overloaded before sprint planning." Bad: "Requires local join across issues + assignees + cycle data."
 3. `rationale` should explain why this is only possible with our approach. Good: "Requires correlating bookings, schedules, and staff data that only exists together in the local store." Bad: "Cal.com Insights is paid-tier only."
@@ -1201,6 +1201,7 @@ For each tool, fill in what you know from the research. Stars and command_count
 6. `why_it_matters` is a single agent-facing sentence answering "when should I pick this over a generic API call?"
 7. `group` clusters related features under a theme name. Pick 2–5 themes total (e.g. "Local state that compounds", "Agent-native plumbing", "Reachability mitigation"). Use the same `group` string verbatim across features that belong together — exact matches drive README grouping. Leave `group` empty if the CLI has too few novel features to warrant clustering.
 8. If no transcendence features scored >= 5/10, omit the `novel_features` field entirely.
+9. Do not add a feature to `novel_features` merely to expose it through MCP. Any user-facing Cobra command becomes an MCP tool automatically unless it sets `cmd.Annotations["mcp:hidden"] = "true"`.
 
 **Narrative rules** (the `narrative` object drives README headline, Quick Start, Auth, Troubleshooting, and the entire SKILL.md):
 1. `display_name` is the canonical prose name, discovered during research, with exact brand casing and spacing. This is agentic/research-owned, not slug-inferred by Go code. Good: "Product Hunt", "GitHub", "YouTube", "Cal.com". Bad: "Producthunt", "Github", "Youtube", "Cal Com". Use the slug only for binary names, directories, module paths, config paths, and env-var prefixes.
@@ -1781,6 +1782,13 @@ The generator handles Priority 0 (data layer) and most of Priority 1 (absorbed A
 
 **Shared helpers available to novel code:** The generator emits `internal/cliutil/` in every CLI. When authoring novel commands, prefer `cliutil.FanoutRun` for any aggregation command (any `--site`/`--source`/`--region` CSV fan-out) and `cliutil.CleanText` for any text extracted from HTML or schema.org JSON-LD. Re-implementing these inline is how recipe-goat's trending silent-drop and `&#39;` entity bugs shipped.
 
+**MCP exposure:** The generator emits `internal/mcp/cobratree/`, and the MCP binary mirrors the Cobra tree at startup. When you add, rename, or remove a user-facing Cobra command, the MCP surface follows automatically. Two annotations control how each command appears as an MCP tool:
+
+- `cmd.Annotations["mcp:hidden"] = "true"` — exclude the command from the MCP surface entirely. Use only for debug/internal commands that should not become agent tools.
+- `cmd.Annotations["mcp:read-only"] = "true"` — declare that this command does not modify external state. The MCP server attaches `readOnlyHint: true` to the resulting tool, so hosts like Claude Desktop don't bucket it under "write/delete tools" and demand permission per call. Apply this to every novel command whose only effect is reading from the API or the local store: lookups, comparisons, aggregations, render-only views, status checks. Skip it for commands that mutate external state (orders, posts, deletes) or write to user-visible files outside the local cache.
+
+Endpoint-mirror tools the generator emits from the spec already get the right annotations automatically (`GET` → read-only, `DELETE` → destructive, etc.) — `mcp:read-only` is only needed on hand-authored Cobra commands the spec doesn't cover.
+
 Do not rationalize skipping transcendence features because "the CLI already works for live API interaction." The absorb manifest was approved by the user. Build what was approved.
 
 ## Phase 4: Shipcheck
@@ -1804,7 +1812,7 @@ The umbrella defaults to `verify --fix` (auto-repair common failures) and `score
 If a leg fails, re-run that one leg standalone (e.g., `printing-press verify-skill --dir <CLI_WORK_DIR>`) for focused iteration; once it passes, re-run the full `shipcheck` umbrella to confirm no regression in the others.
 
 Interpretation:
-- `dogfood` catches dead flags, dead helpers, invalid paths, example drift, broken data wiring, command tree/config field wiring bugs, and novel features that were planned but not built
+- `dogfood` catches dead flags, dead helpers, invalid paths, example drift, broken data wiring, command tree/config field wiring bugs, stale static MCP surfaces, and novel features that were planned but not built
 - `verify` catches runtime breakage and runs the auto-fix loop for common failures
 - `workflow-verify` tests the primary workflow end-to-end using the verification manifest (workflow_verify.yaml). Three verdicts: workflow-pass, workflow-fail, unverified-needs-auth
 - `verify-skill` checks that every `--flag` and command path in SKILL.md actually exists in the shipped CLI source. Catches bogus examples invented by the absorb LLM (e.g., `search --max-time` when `--max-time` is a `tonight` flag). Exit 1 = findings to fix; exit 0 = SKILL is honest.
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index cde14981..7a965949 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -15,6 +15,12 @@ printing-press-golden/internal/cliutil/text.go
 printing-press-golden/internal/cliutil/ratelimit.go
 printing-press-golden/internal/config/config.go
 printing-press-golden/internal/mcp/tools.go
+printing-press-golden/internal/mcp/cobratree/walker.go
+printing-press-golden/internal/mcp/cobratree/classify.go
+printing-press-golden/internal/mcp/cobratree/typemap.go
+printing-press-golden/internal/mcp/cobratree/shellout.go
+printing-press-golden/internal/mcp/cobratree/cli_path.go
+printing-press-golden/internal/mcp/cobratree/names.go
 printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
 printing-press-golden/internal/types/types.go
 dogfood.json
diff --git a/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json b/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json
index 34fa2406..ccb90a03 100644
--- a/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json
+++ b/testdata/golden/expected/dogfood-verdict-matrix/fail-path-auth-dead.json
@@ -35,6 +35,12 @@
     "auth protocol mismatch",
     "3 dead flags found"
   ],
+  "mcp_surface_parity": {
+    "detail": "no internal/mcp/tools.go; CLI does not ship an MCP surface",
+    "pass": true,
+    "skipped": true,
+    "state": "missing"
+  },
   "naming_check": {
     "checked": 3
   },
diff --git a/testdata/golden/expected/dogfood-verdict-matrix/pass.json b/testdata/golden/expected/dogfood-verdict-matrix/pass.json
index 29599792..a8c94e1a 100644
--- a/testdata/golden/expected/dogfood-verdict-matrix/pass.json
+++ b/testdata/golden/expected/dogfood-verdict-matrix/pass.json
@@ -26,6 +26,11 @@
     "with_examples": 1
   },
   "issues": null,
+  "mcp_surface_parity": {
+    "detail": "MCP surface mirrors the Cobra tree at runtime",
+    "pass": true,
+    "state": "runtime_walking"
+  },
   "naming_check": {
     "checked": 2
   },
diff --git a/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json b/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json
index e616e283..a36733a7 100644
--- a/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json
+++ b/testdata/golden/expected/dogfood-verdict-matrix/warn-priority.json
@@ -32,6 +32,12 @@
     "1 dead helper functions found",
     "1 unregistered commands: orphan"
   ],
+  "mcp_surface_parity": {
+    "detail": "no internal/mcp/tools.go; CLI does not ship an MCP surface",
+    "pass": true,
+    "skipped": true,
+    "state": "missing"
+  },
   "naming_check": {
     "checked": 3
   },
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index 6b243dc6..58d50943 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -29,6 +29,11 @@
   "issues": [
     "example check skipped: could not build CLI binary: exit status 1"
   ],
+  "mcp_surface_parity": {
+    "detail": "MCP surface mirrors the Cobra tree at runtime",
+    "pass": true,
+    "state": "runtime_walking"
+  },
   "naming_check": {
     "checked": 30
   },
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
index a051ea60..d54751b5 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
@@ -19,7 +19,6 @@ func main() {
 	)
 
 	mcptools.RegisterTools(s)
-	mcptools.RegisterNovelFeatureTools(s)
 
 	if err := server.ServeStdio(s); err != nil {
 		fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
index d78d192c..da95c738 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_create.go
@@ -22,6 +22,7 @@ func newProjectsCreateCmd(flags *rootFlags) *cobra.Command {
 		Use:   "create",
 		Short: "Create project",
 		Example: "  printing-press-golden-pp-cli projects create --name example-resource",
+		Annotations: map[string]string{"pp:endpoint": "projects.create"},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			if !stdinBody {
 				if !cmd.Flags().Changed("name") && !flags.dryRun {
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 dd5e1fcf..864de0f3 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,6 +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"},
 		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 6834cbc2..c6c33488 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,6 +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"},
 		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/projects_tasks_update-project.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/projects_tasks_update-project.go
index 492bb4c1..142dcc2f 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
@@ -23,6 +23,7 @@ func newProjectsTasksUpdateProjectCmd(flags *rootFlags) *cobra.Command {
 		Aliases: []string{"update"},
 		Short: "Update project task",
 		Example: "  printing-press-golden-pp-cli projects tasks update-project example-value example-value",
+		Annotations: map[string]string{"pp:endpoint": "tasks.update-project"},
 		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 349c30b4..bc0382a1 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,6 +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"},
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
 			if err != nil {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
index ba23a6ae..44f8977d 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.go
@@ -46,10 +46,39 @@ type rootFlags struct {
 	deliverSink DeliverSink
 }
 
+// RootCmd returns the Cobra command tree without executing it. The MCP server
+// uses this to mirror every user-facing command as an agent tool.
+func RootCmd() *cobra.Command {
+	var flags rootFlags
+	return newRootCmd(&flags)
+}
+
 // Execute runs the CLI in non-interactive mode: never prompts, all values via flags or stdin.
 func Execute() error {
 	var flags rootFlags
+	rootCmd := newRootCmd(&flags)
 
+	err := rootCmd.Execute()
+	if err != nil && strings.Contains(err.Error(), "unknown flag") {
+		msg := err.Error()
+		// Extract the flag name from the error message (e.g., "unknown flag: --foob")
+		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
+			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
+			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
+				return fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
+			}
+		}
+	}
+	if err == nil && flags.deliverBuf != nil {
+		if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
+			fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
+			return derr
+		}
+	}
+	return err
+}
+
+func newRootCmd(flags *rootFlags) *cobra.Command {
 	rootCmd := &cobra.Command{
 		Use:   "printing-press-golden-pp-cli",
 		Short: "Manage printing-press-golden resources via the printing-press-golden API",
@@ -135,44 +164,27 @@ Run 'printing-press-golden-pp-cli doctor' to verify auth and connectivity.`,
 		}
 		return nil
 	}
-	rootCmd.AddCommand(newProjectsCmd(&flags))
-	rootCmd.AddCommand(newReportsCmd(&flags))
-	rootCmd.AddCommand(newDoctorCmd(&flags))
-	rootCmd.AddCommand(newAuthCmd(&flags))
+	rootCmd.AddCommand(newProjectsCmd(flags))
+	rootCmd.AddCommand(newReportsCmd(flags))
+	rootCmd.AddCommand(newDoctorCmd(flags))
+	rootCmd.AddCommand(newAuthCmd(flags))
 	rootCmd.AddCommand(newAgentContextCmd(rootCmd))
-	rootCmd.AddCommand(newProfileCmd(&flags))
-	rootCmd.AddCommand(newFeedbackCmd(&flags))
-	rootCmd.AddCommand(newWhichCmd(&flags))
-	rootCmd.AddCommand(newExportCmd(&flags))
-	rootCmd.AddCommand(newImportCmd(&flags))
-	rootCmd.AddCommand(newSyncCmd(&flags))
-	rootCmd.AddCommand(newAnalyticsCmd(&flags))
-	rootCmd.AddCommand(newWorkflowCmd(&flags))
-	rootCmd.AddCommand(newStaleCmd(&flags))
-	rootCmd.AddCommand(newOrphansCmd(&flags))
-	rootCmd.AddCommand(newLoadCmd(&flags))
-	rootCmd.AddCommand(newAPICmd(&flags))
-	rootCmd.AddCommand(newPublicPromotedCmd(&flags))
+	rootCmd.AddCommand(newProfileCmd(flags))
+	rootCmd.AddCommand(newFeedbackCmd(flags))
+	rootCmd.AddCommand(newWhichCmd(flags))
+	rootCmd.AddCommand(newExportCmd(flags))
+	rootCmd.AddCommand(newImportCmd(flags))
+	rootCmd.AddCommand(newSyncCmd(flags))
+	rootCmd.AddCommand(newAnalyticsCmd(flags))
+	rootCmd.AddCommand(newWorkflowCmd(flags))
+	rootCmd.AddCommand(newStaleCmd(flags))
+	rootCmd.AddCommand(newOrphansCmd(flags))
+	rootCmd.AddCommand(newLoadCmd(flags))
+	rootCmd.AddCommand(newAPICmd(flags))
+	rootCmd.AddCommand(newPublicPromotedCmd(flags))
 	rootCmd.AddCommand(newVersionCliCmd())
 
-	err := rootCmd.Execute()
-	if err != nil && strings.Contains(err.Error(), "unknown flag") {
-		msg := err.Error()
-		// Extract the flag name from the error message (e.g., "unknown flag: --foob")
-		if idx := strings.Index(msg, "unknown flag: "); idx >= 0 {
-			flagStr := strings.TrimSpace(msg[idx+len("unknown flag: "):])
-			if suggestion := suggestFlag(flagStr, rootCmd); suggestion != "" {
-				return fmt.Errorf("%w\nhint: did you mean --%s?", err, suggestion)
-			}
-		}
-	}
-	if err == nil && flags.deliverBuf != nil {
-		if derr := Deliver(flags.deliverSink, flags.deliverBuf.Bytes(), flags.compact); derr != nil {
-			fmt.Fprintf(os.Stderr, "warning: deliver to %s:%s failed: %v\n", flags.deliverSink.Scheme, flags.deliverSink.Target, derr)
-			return derr
-		}
-	}
-	return err
+	return rootCmd
 }
 
 func ExitCode(err error) int {
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/classify.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/classify.go
new file mode 100644
index 00000000..54f468f2
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/classify.go
@@ -0,0 +1,107 @@
+// 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 cobratree
+
+import (
+	"strings"
+
+	"github.com/spf13/cobra"
+)
+
+const (
+	EndpointAnnotation = "pp:endpoint"
+	HiddenAnnotation   = "mcp:hidden"
+	// ReadOnlyAnnotation, when set on a Cobra command to "true"/"1"/"yes",
+	// causes the runtime walker to register the resulting MCP tool with
+	// readOnlyHint=true. Use for novel CLI commands that don't mutate
+	// external state — read-only API queries, local cache reads, etc.
+	// Without it, hosts like Claude Desktop default to "could write or
+	// delete" and demand permission per call.
+	ReadOnlyAnnotation = "mcp:read-only"
+)
+
+type commandKind int
+
+const (
+	commandNovel commandKind = iota
+	commandEndpoint
+	commandFramework
+	commandHidden
+)
+
+// frameworkCommands are top-level CLI commands the walker should skip when
+// mirroring the Cobra tree. Two cases qualify:
+//
+//  1. A typed MCP tool already covers the same capability (the typed tool's
+//     schema is strictly better than a shell-out). Examples: `sql`, `search`,
+//     `context`/`about`/`agent-context`, `api` (endpoint mirror tools cover it).
+//  2. The command is non-functional via MCP (interactive setup, shell-only
+//     ergonomics, trivial introspection, local-only feedback). Examples:
+//     `auth`, `completion`, `doctor`, `version`, `feedback`, `profile`,
+//     `which`, `help`.
+//
+// Commands that DO have agent value — `sync` (populates the store that `sql`
+// and `search` query), `stale`/`orphans`/`reconcile`/`load` (store
+// diagnostics), `export`/`import` (data movement), `workflow`
+// (compound operations), `analytics` (aggregations) — must NOT be in this
+// list. Excluding `sync` while exposing `sql` is a broken contract because
+// the typed `sql` tool returns empty results until something populates the
+// store. See AGENTS.md "Agent-Native Surface" for the principle.
+//
+// Adding a new generator-emitted command means deciding which of the two
+// cases above applies. When in doubt, leave it out — the walker registers
+// any user-facing command as a shell-out tool, and the cost of a slightly
+// underused tool is much smaller than the cost of a broken contract like
+// `sql` without `sync`.
+var frameworkCommands = map[string]bool{
+	"about":         true,
+	"agent-context": true,
+	"api":           true,
+	"auth":          true,
+	"completion":    true,
+	"doctor":        true,
+	"feedback":      true,
+	"help":          true,
+	"profile":       true,
+	"search":        true,
+	"sql":           true,
+	"version":       true,
+	"which":         true,
+}
+
+func classify(cmd *cobra.Command) commandKind {
+	if cmd == nil || cmd.Hidden || isMCPHidden(cmd) {
+		return commandHidden
+	}
+	if endpointID(cmd) != "" {
+		return commandEndpoint
+	}
+	if frameworkCommands[cmd.Name()] {
+		return commandFramework
+	}
+	return commandNovel
+}
+
+func endpointID(cmd *cobra.Command) string {
+	if cmd == nil || cmd.Annotations == nil {
+		return ""
+	}
+	return strings.TrimSpace(cmd.Annotations[EndpointAnnotation])
+}
+
+func isMCPHidden(cmd *cobra.Command) bool {
+	return annotationIsTrue(cmd, HiddenAnnotation)
+}
+
+func isMCPReadOnly(cmd *cobra.Command) bool {
+	return annotationIsTrue(cmd, ReadOnlyAnnotation)
+}
+
+func annotationIsTrue(cmd *cobra.Command, key string) bool {
+	if cmd == nil || cmd.Annotations == nil {
+		return false
+	}
+	v := strings.ToLower(strings.TrimSpace(cmd.Annotations[key]))
+	return v == "true" || v == "1" || v == "yes"
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/cli_path.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/cli_path.go
new file mode 100644
index 00000000..1f1a7b70
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/cli_path.go
@@ -0,0 +1,26 @@
+// 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 cobratree
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+)
+
+// SiblingCLIPath resolves the companion CLI via sibling-of-executable,
+// PRINTING_PRESS_GOLDEN_CLI_PATH env var, then PATH.
+func SiblingCLIPath() (string, error) {
+	const cliName = "printing-press-golden-pp-cli"
+	if exe, err := os.Executable(); err == nil {
+		candidate := filepath.Join(filepath.Dir(exe), cliName)
+		if _, err := os.Stat(candidate); err == nil {
+			return candidate, nil
+		}
+	}
+	if v := os.Getenv("PRINTING_PRESS_GOLDEN_CLI_PATH"); v != "" {
+		return v, nil
+	}
+	return exec.LookPath(cliName)
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/names.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/names.go
new file mode 100644
index 00000000..e838d264
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/names.go
@@ -0,0 +1,25 @@
+// 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 cobratree
+
+import (
+	"strings"
+	"unicode"
+)
+
+func toolNameForPath(parts []string) string {
+	var out []rune
+	for _, part := range parts {
+		for _, r := range part {
+			switch {
+			case unicode.IsLetter(r) || unicode.IsDigit(r):
+				out = append(out, unicode.ToLower(r))
+			default:
+				out = append(out, '_')
+			}
+		}
+		out = append(out, '_')
+	}
+	return strings.Trim(strings.Join(strings.FieldsFunc(string(out), func(r rune) bool { return r == '_' }), "_"), "_")
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/shellout.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/shellout.go
new file mode 100644
index 00000000..a87475fa
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/shellout.go
@@ -0,0 +1,102 @@
+// 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 cobratree
+
+import (
+	"context"
+	"fmt"
+	"os/exec"
+	"sort"
+	"strconv"
+	"strings"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+)
+
+func shellOutToCLI(cliPath func() (string, error), commandPath []string) server.ToolHandlerFunc {
+	lookupPath, lookupErr := cliPath()
+	prefixArgs := append([]string{}, commandPath...)
+	return func(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+		if lookupErr != nil {
+			return mcplib.NewToolResultError(fmt.Sprintf("companion CLI binary not found: %v\nTried sibling lookup, PRINTING_PRESS_GOLDEN_CLI_PATH env var, and PATH.", lookupErr)), nil
+		}
+		args := req.GetArguments()
+		finalArgs := append([]string{}, prefixArgs...)
+		finalArgs = append(finalArgs, cliArgsFromMCP(args)...)
+		if raw, _ := args["args"].(string); strings.TrimSpace(raw) != "" {
+			finalArgs = append(finalArgs, splitShellArgs(raw)...)
+		}
+		cmd := exec.CommandContext(ctx, lookupPath, finalArgs...)
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			return mcplib.NewToolResultError(string(out)), nil
+		}
+		return mcplib.NewToolResultText(string(out)), nil
+	}
+}
+
+func cliArgsFromMCP(args map[string]any) []string {
+	keys := make([]string, 0, len(args))
+	for k := range args {
+		if k != "args" {
+			keys = append(keys, k)
+		}
+	}
+	sort.Strings(keys)
+
+	var out []string
+	for _, k := range keys {
+		v := args[k]
+		switch tv := v.(type) {
+		case bool:
+			if tv {
+				out = append(out, "--"+k)
+			}
+		case float64:
+			out = append(out, "--"+k, strconv.FormatFloat(tv, 'f', -1, 64))
+		case string:
+			if tv != "" {
+				out = append(out, "--"+k, tv)
+			}
+		case []any:
+			if len(tv) > 0 {
+				parts := make([]string, 0, len(tv))
+				for _, item := range tv {
+					parts = append(parts, fmt.Sprintf("%v", item))
+				}
+				out = append(out, "--"+k, strings.Join(parts, ","))
+			}
+		default:
+			if v != nil {
+				out = append(out, "--"+k, fmt.Sprintf("%v", v))
+			}
+		}
+	}
+	return out
+}
+
+// splitShellArgs whitespace-splits with double-quoted-token preservation.
+func splitShellArgs(s string) []string {
+	var tokens []string
+	var cur []rune
+	inQuote := false
+	for _, r := range s {
+		switch {
+		case r == '"':
+			inQuote = !inQuote
+		case (r == ' ' || r == '\t') && !inQuote:
+			if len(cur) > 0 {
+				tokens = append(tokens, string(cur))
+				cur = cur[:0]
+			}
+		default:
+			cur = append(cur, r)
+		}
+	}
+	if len(cur) > 0 {
+		tokens = append(tokens, string(cur))
+	}
+	return tokens
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/typemap.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/typemap.go
new file mode 100644
index 00000000..3ea918cc
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/typemap.go
@@ -0,0 +1,76 @@
+// 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 cobratree
+
+import (
+	"regexp"
+	"strings"
+
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/spf13/cobra"
+	"github.com/spf13/pflag"
+)
+
+var positionalPattern = regexp.MustCompile(`(?:^|\s)(?:<[^>]+>|\[[^\]]+\])`)
+
+func toolOptionsForFlags(cmd *cobra.Command) []mcplib.ToolOption {
+	var opts []mcplib.ToolOption
+	seen := map[string]bool{}
+	addFlag := func(flag *pflag.Flag) {
+		if flag == nil || flag.Hidden || flag.Deprecated != "" {
+			return
+		}
+		if seen[flag.Name] {
+			return
+		}
+		seen[flag.Name] = true
+		opts = append(opts, toolOptionForFlag(flag))
+	}
+	cmd.InheritedFlags().VisitAll(addFlag)
+	cmd.NonInheritedFlags().VisitAll(addFlag)
+	return opts
+}
+
+func toolOptionForFlag(flag *pflag.Flag) mcplib.ToolOption {
+	propOpts := []mcplib.PropertyOption{mcplib.Description(flagDescription(flag))}
+	if isRequired(flag) {
+		propOpts = append(propOpts, mcplib.Required())
+	}
+	switch flag.Value.Type() {
+	case "bool":
+		return mcplib.WithBoolean(flag.Name, propOpts...)
+	case "int", "int8", "int16", "int32", "int64",
+		"uint", "uint8", "uint16", "uint32", "uint64",
+		"float32", "float64", "count":
+		return mcplib.WithNumber(flag.Name, propOpts...)
+	case "string", "stringSlice", "stringArray", "duration":
+		return mcplib.WithString(flag.Name, propOpts...)
+	default:
+		propOpts[0] = mcplib.Description(flagDescription(flag) + " (unknown Cobra flag type " + flag.Value.Type() + "; pass as a string)")
+		return mcplib.WithString(flag.Name, propOpts...)
+	}
+}
+
+func flagDescription(flag *pflag.Flag) string {
+	usage := strings.TrimSpace(flag.Usage)
+	if usage == "" {
+		usage = "Value for --" + flag.Name
+	}
+	if flag.DefValue != "" && flag.DefValue != "[]" {
+		usage += " (default: " + flag.DefValue + ")"
+	}
+	return usage
+}
+
+func isRequired(flag *pflag.Flag) bool {
+	if flag == nil || flag.Annotations == nil {
+		return false
+	}
+	_, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]
+	return ok
+}
+
+func commandTakesArgs(cmd *cobra.Command) bool {
+	return positionalPattern.MatchString(cmd.Use)
+}
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/walker.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/walker.go
new file mode 100644
index 00000000..d7109bcf
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/cobratree/walker.go
@@ -0,0 +1,66 @@
+// 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 cobratree
+
+import (
+	mcplib "github.com/mark3labs/mcp-go/mcp"
+	"github.com/mark3labs/mcp-go/server"
+	"github.com/spf13/cobra"
+)
+
+// RegisterAll walks root's user-facing Cobra commands and registers shell-out
+// MCP tools for commands that are not already covered by typed endpoint tools.
+func RegisterAll(s *server.MCPServer, root *cobra.Command, cliPath func() (string, error)) {
+	if root == nil {
+		return
+	}
+	walk(root, nil, func(cmd *cobra.Command, path []string) {
+		switch classify(cmd) {
+		case commandHidden:
+			return
+		case commandEndpoint, commandFramework:
+			return
+		}
+		if !cmd.Runnable() {
+			return
+		}
+
+		toolName := toolNameForPath(path)
+		if toolName == "" {
+			return
+		}
+		options := []mcplib.ToolOption{mcplib.WithDescription(descriptionFor(cmd))}
+		options = append(options, toolOptionsForFlags(cmd)...)
+		if commandTakesArgs(cmd) {
+			options = append(options, mcplib.WithString("args", mcplib.Description("Additional positional arguments or raw CLI flags to append to the command.")))
+		}
+		if isMCPReadOnly(cmd) {
+			options = append(options, mcplib.WithReadOnlyHintAnnotation(true), mcplib.WithDestructiveHintAnnotation(false))
+		}
+		s.AddTool(mcplib.NewTool(toolName, options...), shellOutToCLI(cliPath, path))
+	})
+}
+
+func walk(cmd *cobra.Command, path []string, visit func(*cobra.Command, []string)) {
+	for _, child := range cmd.Commands() {
+		if child.Hidden || isMCPHidden(child) {
+			continue
+		}
+		childPath := append(append([]string{}, path...), child.Name())
+		visit(child, childPath)
+		if kind := classify(child); kind != commandHidden && kind != commandFramework {
+			walk(child, childPath, visit)
+		}
+	}
+}
+
+func descriptionFor(cmd *cobra.Command) string {
+	if cmd.Long != "" {
+		return cmd.Long
+	}
+	if cmd.Short != "" {
+		return cmd.Short
+	}
+	return "Run `" + cmd.CommandPath() + "` through the companion CLI binary."
+}
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 94f3f615..e3c079f0 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
@@ -14,9 +14,11 @@ import (
 
 	mcplib "github.com/mark3labs/mcp-go/mcp"
 	"github.com/mark3labs/mcp-go/server"
+	"printing-press-golden-pp-cli/internal/cli"
 	"printing-press-golden-pp-cli/internal/cliutil"
 	"printing-press-golden-pp-cli/internal/client"
 	"printing-press-golden-pp-cli/internal/config"
+	"printing-press-golden-pp-cli/internal/mcp/cobratree"
 	"printing-press-golden-pp-cli/internal/store"
 )
 
@@ -25,6 +27,8 @@ func RegisterTools(s *server.MCPServer) {
 	s.AddTool(
 		mcplib.NewTool("projects_create",
 			mcplib.WithDescription("Create project Returns Project."),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("POST", "/projects", []string{ }),
 	)
@@ -32,6 +36,9 @@ func RegisterTools(s *server.MCPServer) {
 		mcplib.NewTool("projects_get",
 			mcplib.WithDescription("Get project"),
 			mcplib.WithString("projectId", mcplib.Required(), mcplib.Description("Project id")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("GET", "/projects/{projectId}", []string{"projectId", }),
 	)
@@ -41,6 +48,9 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithString("status", mcplib.Description("Status")),
 			mcplib.WithString("limit", mcplib.Description("Limit")),
 			mcplib.WithString("cursor", mcplib.Description("Cursor")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("GET", "/projects", []string{ }),
 	)
@@ -51,6 +61,9 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithString("priority", mcplib.Description("Priority")),
 			mcplib.WithString("limit", mcplib.Description("Limit")),
 			mcplib.WithString("cursor", mcplib.Description("Cursor")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("GET", "/projects/{projectId}/tasks", []string{"projectId", }),
 	)
@@ -59,12 +72,16 @@ func RegisterTools(s *server.MCPServer) {
 			mcplib.WithDescription("Update project task Partial update."),
 			mcplib.WithString("projectId", mcplib.Required(), mcplib.Description("Project id")),
 			mcplib.WithString("taskId", mcplib.Required(), mcplib.Description("Task id")),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("PATCH", "/projects/{projectId}/tasks/{taskId}", []string{"projectId","taskId", }),
 	)
 	s.AddTool(
 		mcplib.NewTool("public_get-status",
 			mcplib.WithDescription("Get public service status (public)"),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("GET", "/public/status", []string{ }),
 	)
@@ -72,24 +89,19 @@ func RegisterTools(s *server.MCPServer) {
 		mcplib.NewTool("reports_summary_get-report-year",
 			mcplib.WithDescription("Get a report summary for a year"),
 			mcplib.WithString("year", mcplib.Required(), mcplib.Description("Year")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
+			mcplib.WithOpenWorldHintAnnotation(true),
 		),
 		makeAPIHandler("GET", "/reports/{year}/summary", []string{"year", }),
 	)
-	// Sync tool — populates local database for offline search and sql queries
-	s.AddTool(
-		mcplib.NewTool("sync",
-			mcplib.WithDescription("Sync API data to local SQLite database. Run this before using search or sql tools. Supports incremental sync."),
-			mcplib.WithString("resources", mcplib.Description("Comma-separated resource types to sync (omit for all)")),
-			mcplib.WithString("since", mcplib.Description("Incremental sync since duration (e.g. 7d, 24h, 1w)")),
-			mcplib.WithBoolean("full", mcplib.Description("Full resync ignoring checkpoints")),
-		),
-		handleSync,
-	)
 	// SQL tool — ad-hoc analysis on synced data without API calls
 	s.AddTool(
 		mcplib.NewTool("sql",
 			mcplib.WithDescription("Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first."),
 			mcplib.WithString("query", mcplib.Required(), mcplib.Description("SQL query (SELECT only). Tables match resource names.")),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
 		),
 		handleSQL,
 	)
@@ -99,9 +111,15 @@ func RegisterTools(s *server.MCPServer) {
 	s.AddTool(
 		mcplib.NewTool("context",
 			mcplib.WithDescription("Get API domain context: resource taxonomy, auth requirements, query tips, and unique capabilities. Call this first."),
+			mcplib.WithReadOnlyHintAnnotation(true),
+			mcplib.WithDestructiveHintAnnotation(false),
 		),
 		handleContext,
 	)
+
+	// Runtime Cobra-tree mirror — exposes every user-facing command that is
+	// not already covered by a typed endpoint or framework MCP tool.
+	cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
 }
 
 // makeAPIHandler creates a generic MCP tool handler for an API endpoint.
@@ -227,10 +245,6 @@ func dbPath() string {
 // Note: MCP tools use their own dbPath() because they are in a separate package (main, not cli).
 // The CLI's defaultDBPath() in the cli package uses the same canonical path.
 
-func handleSync(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
-	return mcplib.NewToolResultText("sync not yet implemented via MCP - use the CLI: printing-press-golden-pp-cli sync"), nil
-}
-
 func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
 	args := req.GetArguments()
 	query, ok := args["query"].(string)
@@ -284,9 +298,8 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		"description": "Purpose-built fixture for golden generation coverage.",
 		"archetype":   "project-management",
 		"tool_count":  7,
-		// tool_surface tells agents which surface a capability lives on so
-		// they don't try to invoke cli_only_capabilities through MCP.
-		"tool_surface": "MCP exposes the endpoints listed under `resources` (plus sync/search/sql/context utilities when present). Items under `cli_only_capabilities` require running the companion printing-press-golden-pp-cli binary; the MCP cannot invoke them.",
+		// 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{
 			"type": "api_key",
 			"env_vars": []string{"PRINTING_PRESS_GOLDEN_API_KEY_AUTH",  },
@@ -327,7 +340,9 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 	return mcplib.NewToolResultText(string(data)), nil
 }
 
-// RegisterNovelFeatureTools registers MCP tools that shell out to the
-// companion CLI binary. Empty body when the spec has no novel features.
+// RegisterNovelFeatureTools is kept as a compatibility no-op for older MCP
+// mains. New generated mains call RegisterTools only; RegisterTools now
+// includes the runtime Cobra-tree mirror.
 func RegisterNovelFeatureTools(s *server.MCPServer) {
+	_ = s
 }
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/manifest.json b/testdata/golden/expected/generate-golden-api/printing-press-golden/manifest.json
index c66579c4..962b0f42 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/manifest.json
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/manifest.json
@@ -2,7 +2,6 @@
   "author": {
     "name": "CLI Printing Press"
   },
-  "cli_binary": "printing-press-golden-pp-cli",
   "compatibility": {
     "claude_desktop": ">=1.0.0",
     "platforms": [
diff --git a/testdata/golden/expected/generate-golden-api/scorecard.json b/testdata/golden/expected/generate-golden-api/scorecard.json
index 9fd0dddd..5ef058d9 100644
--- a/testdata/golden/expected/generate-golden-api/scorecard.json
+++ b/testdata/golden/expected/generate-golden-api/scorecard.json
@@ -24,19 +24,20 @@
       "mcp_remote_transport": 5,
       "mcp_surface_strategy": 0,
       "mcp_token_efficiency": 7,
-      "mcp_tool_design": 5,
+      "mcp_tool_design": 0,
       "output_modes": 10,
       "path_validity": 0,
-      "percentage": 81,
+      "percentage": 82,
       "readme": 8,
       "sync_correctness": 10,
       "terminal_ux": 8,
-      "total": 81,
+      "total": 82,
       "type_fidelity": 3,
       "vision": 7,
       "workflows": 10
     },
     "unscored_dimensions": [
+      "mcp_tool_design",
       "mcp_surface_strategy",
       "path_validity",
       "auth_protocol",
diff --git a/testdata/golden/fixtures/dogfood-verdict-matrix/pass/internal/mcp/tools.go b/testdata/golden/fixtures/dogfood-verdict-matrix/pass/internal/mcp/tools.go
new file mode 100644
index 00000000..686b7117
--- /dev/null
+++ b/testdata/golden/fixtures/dogfood-verdict-matrix/pass/internal/mcp/tools.go
@@ -0,0 +1,18 @@
+// Copyright 2026 trevin-chow. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+// This fixture file exists so the dogfood verdict matrix locks the
+// runtime-walking PASS state of the MCP surface parity check. It is not
+// compiled — dogfood reads it via InspectMCPSurface as a string scan.
+package mcp
+
+import (
+	"github.com/mark3labs/mcp-go/server"
+
+	"matrix-pass-pp-cli/internal/cli"
+	"matrix-pass-pp-cli/internal/mcp/cobratree"
+)
+
+func RegisterTools(s *server.MCPServer) {
+	cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath)
+}

← 1b664d0f chore(cli): Update Go dependencies and generated CLI templat  ·  back to Cli Printing Press  ·  fix(cli): mcp-sync handles older library CLI drift (cliutil 1a4bcae8 →