[object Object]

← back to Cli Printing Press

feat(cli): emit MCP tools for novel CLI features via shell-out (#358)

7f248d5995da3c5687b47f84d5e5087c4e413e3b · 2026-04-27 22:21:17 -0700 · Trevin Chow

* feat(cli): emit MCP tools for novel CLI features via shell-out

Generated MCPs now expose every novel hand-written CLI feature as an MCP
tool, not just spec-driven endpoints. The MCPB bundle ships both binaries
(CLI + MCP); the MCP's RegisterNovelFeatureTools handler shells out to
the companion CLI for tools like company-goat's `snapshot`, `funding`,
`signal`, `search`, `compare`, `funding-trend`, and `funding --who`.
Bundled Claude Desktop users get feature parity with the CLI without a
separate go install.

Closes the surface-narrowness gap surfaced during the company-goat MCPB
spike: previously a Claude Desktop user dragging in `<api>-pp-mcp.mcpb`
saw only spec-driven tools; the actual product capability lived in
internal/cli/ and was unreachable.

Pieces:

- internal/generator/templates/mcp_tools.go.tmpl: emits
  RegisterNovelFeatureTools(s) unconditionally (empty body when no
  novel features so main_mcp's wiring stays uniform). When novel
  features exist, also emits siblingCLIPath/shellOutToCLI/splitShellArgs
  helpers + os/exec import.
- internal/generator/templates/main_mcp.go.tmpl: always calls
  RegisterNovelFeatureTools(s) after RegisterTools(s). Keeps main_mcp
  data binding to *spec.APISpec — no need to thread NovelFeatures into
  the main template.
- internal/generator/generator.go: new template helpers `envPrefix`
  (delegates to naming.EnvPrefix) and `mcpToolName` (sanitizes a
  NovelFeature.Command to a snake_case identifier — "funding --who"
  becomes "funding_who", "FUNDING-TREND" becomes "funding_trend").
- internal/pipeline/mcpb_manifest.go: MCPBManifest gains optional
  `cli_binary` field naming the companion CLI. Documentation only —
  the host doesn't launch it directly; the MCP's siblingCLIPath()
  finds it at `${__dirname}/bin/<cli_binary>`.
- internal/pipeline/mcpb_bundle.go: BundleParams gains optional
  CLIBinaryPath; when set together with manifest.cli_binary the
  bundle includes the CLI binary at `bin/<cli_binary>`. New zipFile
  helper streams the binary instead of buffering, matching the
  existing MCP-binary streaming pattern.
- internal/cli/bundle.go: `bundle` subcommand gains --cli-skip-build
  and --cli-binary flags mirroring --skip-build/--binary. Both
  `bundle` and `autoBundleForHost` build/locate both binaries.

Tests:

- internal/generator/mcp_novel_features_test.go (new): pins the
  template emission shape — function declaration, per-feature tool
  registrations with snake_case names, helpers + os/exec import,
  sibling CLI binary name, env-var fallback prefix, main.go always
  calls the function.
- TestMCPNovelFeatureToolNameSanitization: pins the snake_case
  derivation across "funding-trend", "funding --who", uppercase
  folding, leading/trailing underscores, multiple-space collapse.

Goldens regenerated to capture:
- Empty-body RegisterNovelFeatureTools function in tools.go (the
  golden fixture has no novel features today).
- main.go's RegisterNovelFeatureTools(s) call.
- manifest.json's `cli_binary` field.

Out of scope (Phase 2): codemod across the public library applying the
generator's new emission to the existing 25 MCP-shipping CLIs. Plan at
docs/plans/2026-04-28-feat-mcpb-novel-feature-tools-plan.md.

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

* refactor(cli): polish novel-features MCP emission per /simplify pass

Acts on the post-implementation /simplify review of #358's MCP novel-
features generator change. Net −31 LOC; tests, lint, goldens all clean.

Efficiency P1 — siblingCLIPath cached at registration

shellOutToCLI now resolves the companion CLI path once when the tool
gets registered, instead of on every invocation. Each invocation
previously ran os.Executable() + os.Stat() + os.Getenv() + (sometimes)
exec.LookPath() — the resolved path is invariant for the process
lifetime. Lookup error captured in the closure so first failure surfaces
on the first tool call, not silently masked.

Same pass also pre-splits commandSpec at registration. The prefix args
are invariant; splitting per-call burned an allocation + O(n) scan
every invocation.

Efficiency P1 — parallel dual `go build`

bundle.go's MCP-build-then-CLI-build was serial; doubled bundle latency
on every generate (cold-cache 10-30s instead of 5-15s). New
buildBundleBinaries helper runs both via errgroup. Go's build cache is
concurrency-safe; the two packages are disjoint. errgroup is already in
go.mod (golang.org/x/sync).

Reuse — mcpToolName moved to naming package

Hand-rolled snake_case sanitizer in generator.go's FuncMap was a 22-line
inline closure. Lifted to naming.SnakeIdentifier alongside HumanName /
EnvPrefix where identifier transforms belong. The generator's mcpToolName
template func is now a one-liner delegate.

Reuse — zipFile helper covers the MCP-binary path too

mcpb_bundle.go had an inline 10-line "open / stat / streamZip" block for
the MCP binary, then a zipFile helper that did the same thing for the
CLI binary. Refactored the MCP path to call zipFile. Eliminates the
duplication; both binaries take the same code path.

Quality — drop dead conditional in mcpb_manifest

`cliBinary := ""; if m.CLIName != "" { cliBinary = m.CLIName }` is just
`m.CLIName` — the JSON tag already has omitempty, the empty-input/empty-
output behavior is identical, and the local variable hid the assignment.

Quality — comment trim across template helpers

siblingCLIPath / shellOutToCLI / splitShellArgs / RegisterNovelFeatureTools
had multi-line preamble comments narrating the function body or
referencing the spike that motivated the work. Trimmed to one-line
descriptions of WHAT each does — the WHY lives in the plan doc.

Skipped findings (real but out of scope):
- Move helpers into emitted cliutil package (structural; would require
  per-CLI templated cliutil emission). Worth doing later when more
  novel-feature use cases surface; not a clear bug today.
- Cross-package splitShellArgs / pipeline.shellSplit deduplication.
  Generator/runtime boundary makes the share painful for one helper.
- Test reorganization: the existing per-assert spread is deliberate per
  the Quality reviewer ("would obscure intent without saving lines").

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 7f248d5995da3c5687b47f84d5e5087c4e413e3b
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Apr 27 22:21:17 2026 -0700

    feat(cli): emit MCP tools for novel CLI features via shell-out (#358)
    
    * feat(cli): emit MCP tools for novel CLI features via shell-out
    
    Generated MCPs now expose every novel hand-written CLI feature as an MCP
    tool, not just spec-driven endpoints. The MCPB bundle ships both binaries
    (CLI + MCP); the MCP's RegisterNovelFeatureTools handler shells out to
    the companion CLI for tools like company-goat's `snapshot`, `funding`,
    `signal`, `search`, `compare`, `funding-trend`, and `funding --who`.
    Bundled Claude Desktop users get feature parity with the CLI without a
    separate go install.
    
    Closes the surface-narrowness gap surfaced during the company-goat MCPB
    spike: previously a Claude Desktop user dragging in `<api>-pp-mcp.mcpb`
    saw only spec-driven tools; the actual product capability lived in
    internal/cli/ and was unreachable.
    
    Pieces:
    
    - internal/generator/templates/mcp_tools.go.tmpl: emits
      RegisterNovelFeatureTools(s) unconditionally (empty body when no
      novel features so main_mcp's wiring stays uniform). When novel
      features exist, also emits siblingCLIPath/shellOutToCLI/splitShellArgs
      helpers + os/exec import.
    - internal/generator/templates/main_mcp.go.tmpl: always calls
      RegisterNovelFeatureTools(s) after RegisterTools(s). Keeps main_mcp
      data binding to *spec.APISpec — no need to thread NovelFeatures into
      the main template.
    - internal/generator/generator.go: new template helpers `envPrefix`
      (delegates to naming.EnvPrefix) and `mcpToolName` (sanitizes a
      NovelFeature.Command to a snake_case identifier — "funding --who"
      becomes "funding_who", "FUNDING-TREND" becomes "funding_trend").
    - internal/pipeline/mcpb_manifest.go: MCPBManifest gains optional
      `cli_binary` field naming the companion CLI. Documentation only —
      the host doesn't launch it directly; the MCP's siblingCLIPath()
      finds it at `${__dirname}/bin/<cli_binary>`.
    - internal/pipeline/mcpb_bundle.go: BundleParams gains optional
      CLIBinaryPath; when set together with manifest.cli_binary the
      bundle includes the CLI binary at `bin/<cli_binary>`. New zipFile
      helper streams the binary instead of buffering, matching the
      existing MCP-binary streaming pattern.
    - internal/cli/bundle.go: `bundle` subcommand gains --cli-skip-build
      and --cli-binary flags mirroring --skip-build/--binary. Both
      `bundle` and `autoBundleForHost` build/locate both binaries.
    
    Tests:
    
    - internal/generator/mcp_novel_features_test.go (new): pins the
      template emission shape — function declaration, per-feature tool
      registrations with snake_case names, helpers + os/exec import,
      sibling CLI binary name, env-var fallback prefix, main.go always
      calls the function.
    - TestMCPNovelFeatureToolNameSanitization: pins the snake_case
      derivation across "funding-trend", "funding --who", uppercase
      folding, leading/trailing underscores, multiple-space collapse.
    
    Goldens regenerated to capture:
    - Empty-body RegisterNovelFeatureTools function in tools.go (the
      golden fixture has no novel features today).
    - main.go's RegisterNovelFeatureTools(s) call.
    - manifest.json's `cli_binary` field.
    
    Out of scope (Phase 2): codemod across the public library applying the
    generator's new emission to the existing 25 MCP-shipping CLIs. Plan at
    docs/plans/2026-04-28-feat-mcpb-novel-feature-tools-plan.md.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): polish novel-features MCP emission per /simplify pass
    
    Acts on the post-implementation /simplify review of #358's MCP novel-
    features generator change. Net −31 LOC; tests, lint, goldens all clean.
    
    Efficiency P1 — siblingCLIPath cached at registration
    
    shellOutToCLI now resolves the companion CLI path once when the tool
    gets registered, instead of on every invocation. Each invocation
    previously ran os.Executable() + os.Stat() + os.Getenv() + (sometimes)
    exec.LookPath() — the resolved path is invariant for the process
    lifetime. Lookup error captured in the closure so first failure surfaces
    on the first tool call, not silently masked.
    
    Same pass also pre-splits commandSpec at registration. The prefix args
    are invariant; splitting per-call burned an allocation + O(n) scan
    every invocation.
    
    Efficiency P1 — parallel dual `go build`
    
    bundle.go's MCP-build-then-CLI-build was serial; doubled bundle latency
    on every generate (cold-cache 10-30s instead of 5-15s). New
    buildBundleBinaries helper runs both via errgroup. Go's build cache is
    concurrency-safe; the two packages are disjoint. errgroup is already in
    go.mod (golang.org/x/sync).
    
    Reuse — mcpToolName moved to naming package
    
    Hand-rolled snake_case sanitizer in generator.go's FuncMap was a 22-line
    inline closure. Lifted to naming.SnakeIdentifier alongside HumanName /
    EnvPrefix where identifier transforms belong. The generator's mcpToolName
    template func is now a one-liner delegate.
    
    Reuse — zipFile helper covers the MCP-binary path too
    
    mcpb_bundle.go had an inline 10-line "open / stat / streamZip" block for
    the MCP binary, then a zipFile helper that did the same thing for the
    CLI binary. Refactored the MCP path to call zipFile. Eliminates the
    duplication; both binaries take the same code path.
    
    Quality — drop dead conditional in mcpb_manifest
    
    `cliBinary := ""; if m.CLIName != "" { cliBinary = m.CLIName }` is just
    `m.CLIName` — the JSON tag already has omitempty, the empty-input/empty-
    output behavior is identical, and the local variable hid the assignment.
    
    Quality — comment trim across template helpers
    
    siblingCLIPath / shellOutToCLI / splitShellArgs / RegisterNovelFeatureTools
    had multi-line preamble comments narrating the function body or
    referencing the spike that motivated the work. Trimmed to one-line
    descriptions of WHAT each does — the WHY lives in the plan doc.
    
    Skipped findings (real but out of scope):
    - Move helpers into emitted cliutil package (structural; would require
      per-CLI templated cliutil emission). Worth doing later when more
      novel-feature use cases surface; not a clear bug today.
    - Cross-package splitShellArgs / pipeline.shellSplit deduplication.
      Generator/runtime boundary makes the share painful for one helper.
    - Test reorganization: the existing per-assert spread is deliberate per
      the Quality reviewer ("would obscure intent without saving lines").
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 ...026-04-28-feat-mcpb-novel-feature-tools-plan.md | 197 +++++++++++++++++++++
 internal/cli/bundle.go                             |  64 +++++--
 internal/generator/generator.go                    |  10 +-
 internal/generator/mcp_novel_features_test.go      | 134 ++++++++++++++
 internal/generator/templates/main_mcp.go.tmpl      |   2 +
 internal/generator/templates/mcp_tools.go.tmpl     |  80 +++++++++
 internal/naming/naming.go                          |  25 +++
 internal/pipeline/mcpb_bundle.go                   |  54 ++++--
 internal/pipeline/mcpb_manifest.go                 |   6 +
 .../cmd/printing-press-golden-pp-mcp/main.go       |   1 +
 .../printing-press-golden/internal/mcp/tools.go    |   5 +
 .../printing-press-golden/manifest.json            |   1 +
 12 files changed, 542 insertions(+), 37 deletions(-)

diff --git a/docs/plans/2026-04-28-feat-mcpb-novel-feature-tools-plan.md b/docs/plans/2026-04-28-feat-mcpb-novel-feature-tools-plan.md
new file mode 100644
index 00000000..f8a39379
--- /dev/null
+++ b/docs/plans/2026-04-28-feat-mcpb-novel-feature-tools-plan.md
@@ -0,0 +1,197 @@
+---
+title: "MCPB feature parity: expose novel CLI features as MCP tools"
+type: feat
+status: locked
+date: 2026-04-28
+related:
+  - cli-printing-press#355 (machine: MCPB manifest emission)
+  - public-library spike/mcpb-company-goat (validation)
+---
+
+# MCPB feature parity: expose novel CLI features as MCP tools
+
+## Goal
+
+A user who installs an `<api>-pp-mcp.mcpb` bundle in Claude Desktop can call every novel hand-written CLI feature through the MCP, not just the spec-driven endpoints. The MCPB bundle delivers feature parity with the companion CLI; "MCPB install" stops being a hollow shell and becomes the actual product.
+
+Concrete acceptance test (using company-goat as the canonical case): a Claude Desktop user installs `company-goat-pp-mcp.mcpb`, optionally fills in `GITHUB_TOKEN` / `COMPANIES_HOUSE_API_KEY`, and can call `snapshot`, `funding`, `signal`, `search`, `compare`, `funding-trend`, and `funding --who` as MCP tools — not just `filings_list`. No separate `go install` needed.
+
+## Background
+
+PR #355 (machine) shipped MCPB manifest emission, friendly server identity, the substitution-bug fix, the `bundle` subcommand, and the context-tool capability boundary. Validated end-to-end via the spike branch on the public library where `company-goat-pp-cli` was hand-patched and tested in Claude Desktop.
+
+The spike surfaced three problems with company-goat's MCP surface:
+
+1. **Substitution bug** — `filings_list` sent literal `{cik}` to SEC EDGAR. Fixed at machine-level in #355.
+2. **Tool description framing** — the `filings_list` description ("seed call when resolving a company's…") implied it was a stepping stone to other commands, biasing Claude toward "use the CLI for the real work."
+3. **Tool surface narrowness** — only 1 API endpoint (`filings_list`) exposed; the 7 novel features (`snapshot`, `funding`, `signal`, `search`, `compare`, `funding-trend`, `funding --who`) live in `internal/cli/` but were never wrapped as MCP tools.
+
+Issue #2 is partially addressed by the `cli_only_capabilities` rename + `tool_surface` field in `handleContext`, but that only signals the boundary to agents — it doesn't widen the surface. Issue #3 is the iceberg: every printed CLI with novel features ships an MCP that's missing the actual product capability. Across the public library, ~20 of the 25 MCP-shipping CLIs have novel features; none expose them.
+
+This plan addresses #3 via a shell-out approach: each novel feature becomes an MCP tool whose handler invokes the companion CLI binary. The MCPB bundle ships both binaries (CLI + MCP). Per-CLI hand-coding is zero — the generator emits everything from `.printing-press.json`'s `novel_features[]` array.
+
+## Why shell-out (Path 2) over refactor (Path 1)
+
+A "refactor" approach would split each novel command's body into "pure work" and "CLI presentation" so the MCP can call the pure work directly and format as JSON. That's the architecturally cleaner answer but requires per-CLI hand-coding for ~20 CLIs across hundreds of novel commands. Months of work.
+
+Shell-out instead: the MCP tool handler runs `<bundle>/bin/<api>-pp-cli <command> <args>` and returns the CLI's stdout. The CLI's existing `--json` flag (already universal in printed CLIs) gives structured output the agent can parse. Per-CLI cost is zero. Generator-emittable from existing metadata.
+
+The trade-off: the MCP tool surface is "args: string" rather than typed parameters per feature. Agents handle this fine — constructing CLI args is a core agent skill. Claude Desktop's "view tool" UI shows a single textarea. We can migrate individual high-traffic features to typed schemas later if demand surfaces; the shell-out scaffolding doesn't preclude that.
+
+## Phases
+
+### Phase 1 — Machine (cli-printing-press PR)
+
+Ships the generator-side capability. After this lands, every fresh `printing-press generate` produces a CLI whose MCP tool surface includes all novel features. Existing CLIs in the public library are unaffected until Phase 2.
+
+**Files to change:**
+
+- `internal/spec/spec.go`
+  - No struct changes; existing `MCPConfig` and the manifest's `novel_features[]` already carry name/command/description.
+- `internal/generator/templates/mcp_tools.go.tmpl`
+  - New section: `RegisterNovelFeatureTools(s)` function emitted whenever `.NovelFeatures` is non-empty. One `s.AddTool(...)` per entry, schema is `args: string` (required). Handler is `shellOutToCLI(<command>, <argsTransform>)`.
+  - Each feature's `Description` becomes the tool description. `Name` becomes the tool name (snake-cased). `Command` (e.g. `funding --who`) becomes the constant string the handler prepends to user-supplied args.
+- `internal/generator/templates/mcp_tools.go.tmpl` (continued)
+  - New helper at file bottom: `siblingCLIPath()` resolves the companion CLI binary by inspecting `os.Args[0]` and walking to the conventional sibling path (`bin/<api>-pp-cli`). Falls back to `<API>_CLI_PATH` env var, then to PATH lookup of `<api>-pp-cli`.
+  - New helper: `shellOutToCLI(command string, baseArgs []string) server.ToolHandlerFunc` — captures the command + base args in a closure, the returned handler runs `exec.CommandContext(ctx, cliPath, baseArgs..., userArgs...)` and returns CombinedOutput.
+- `internal/generator/templates/main_mcp.go.tmpl`
+  - Conditionally call `mcptools.RegisterNovelFeatureTools(s)` after the existing `mcptools.RegisterTools(s)` when the spec has novel features.
+- `internal/pipeline/mcpb_bundle.go`
+  - `BuildMCPBBundle` extended to ZIP both binaries (CLI + MCP) when both exist on disk. New optional `BundleParams.CLIBinaryPath` field; if set, ZIPs at `bin/<api>-pp-cli`.
+- `internal/cli/bundle.go`
+  - `bundle` subcommand and `autoBundleForHost` extended to build the CLI binary alongside the MCP binary, then pass both paths to `BuildMCPBBundle`.
+  - `--cli-skip-build` and `--cli-binary` flags mirror the existing `--skip-build` and `--binary` shape.
+- `internal/pipeline/mcpb_manifest.go`
+  - `MCPBManifest` and `buildMCPBManifest` unchanged. The manifest's `entry_point` still points at the MCP binary; the CLI binary is invisible to the host.
+- Tests
+  - `internal/spec/spec_test.go` — verify the template renders novel-feature tools when `novel_features[]` is populated.
+  - `internal/pipeline/mcpb_bundle_test.go` — bundle includes both binaries when both paths supplied.
+  - `internal/cli/bundle_test.go` — `--cli-skip-build` flag handling.
+- Goldens
+  - Extend `testdata/golden/fixtures/golden-api.yaml` with one or two synthetic novel features so the generated `tools.go` snapshot exercises the new emission.
+  - Update `testdata/golden/expected/generate-golden-api/...` to reflect the new tools and the bundle's two-binary contents.
+
+**Decisions (locked 2026-04-28):**
+
+| Decision | Locked answer |
+|---|---|
+| Args schema | Single `args: string` parameter; agent constructs CLI args. Per-feature typed schemas deferred to Phase 3. |
+| CLI binary discovery | 3-tier fallback: sibling (`os.Args[0]` dirname → `bin/<api>-pp-cli`) → `<API>_CLI_PATH` env var → PATH lookup. |
+| Args parsing | `kballard/go-shellquote` split (or equivalent) on user-supplied string. |
+| Stdout capture | `CombinedOutput()` (stderr merged into result). |
+| Failure mode | Non-zero CLI exit → `mcplib.NewToolResultError(combinedOutput)`. |
+| Default to enabled | Yes — auto-emit `RegisterNovelFeatureTools` whenever `novel_features[]` is non-empty. No opt-in flag. |
+| `cli_binary` field in manifest | Include (`"cli_binary": "<api>-pp-cli"`) for documentation. |
+
+**Out of scope for Phase 1:**
+- Per-feature typed argument schemas (Phase 3)
+- MCP `progress` notifications for long-running novel features
+- Streaming subprocess output (returns full output at end)
+- Interactive auth flows that need user input mid-command (composed-auth CLIs)
+
+**Definition of done:**
+- `go test ./...` passing (was 2342, expect ~2350+)
+- `golangci-lint run ./...` clean
+- `scripts/golden.sh verify` passing with new fixture coverage
+- A fresh `printing-press generate --spec testdata/golden/fixtures/golden-api.yaml --output /tmp/golden` produces a CLI whose MCP exposes both spec endpoints AND the synthetic novel features as separate tools
+- Bundle produced via `printing-press bundle /tmp/golden` contains `bin/printing-press-golden-pp-cli` and `bin/printing-press-golden-pp-mcp`
+- Manual test: install the resulting `.mcpb` in Claude Desktop, observe the novel-feature tools appear in the connector list
+
+### Phase 2 — Library codemod (printing-press-library)
+
+Applies the Phase 1 generator's new emission to the existing 25 MCP-shipping CLIs in the public library. No full regenerate — additive edits only, customizations preserved.
+
+**Per-CLI changes (executed by parallel subagents, one CLI per agent):**
+
+For each CLI under `library/<category>/<slug>/`:
+
+1. **Read** `.printing-press.json` to determine whether the CLI has `novel_features[]`. If empty/missing, skip novel-feature steps but still apply manifest + name + context updates.
+2. **NEW `manifest.json`** at the CLI root. Use the new `printing-press` binary's manifest emitter (or a one-shot `printing-press generate-manifest --dir <cli-dir>` subcommand if we add one).
+3. **EDIT `cmd/<api>-pp-mcp/main.go`**:
+   - Update `server.NewMCPServer(...)` first arg to the friendly display name.
+   - Add `mcptools.RegisterNovelFeatureTools(s)` call after the existing `RegisterTools(s)` call when novel features exist.
+4. **EDIT `internal/mcp/tools.go`**:
+   - **Substitution-fix sweep** (only the 5 affected CLIs: pagliacci-pizza 29 sites, cal-com 13, movie-goat 4, flightgoat 2, company-goat 1): rewrite each `makeAPIHandler(...path-with-{name}..., []string{})` to include the path placeholders.
+   - **handleContext refactor** (all 25): replace the function body with the new shape (`tool_surface`, `cli_only_capabilities` rename, `via: "cli"` markers).
+   - **Append `RegisterNovelFeatureTools` + `shellOutToCLI` + `siblingCLIPath`** at the end of the file when novel features exist. These are mechanically generated from `.printing-press.json`'s `novel_features[]`.
+5. **Verify locally** per CLI: `go build ./...`, `go vet ./...` in the CLI's directory.
+6. **Open PR** in the public library: branch `mcpb-feature-parity/<api>`, title `feat(<api>): MCPB feature-parity codemod`.
+
+**Codemod orchestration:**
+
+- One controller agent reads the CLI inventory, decides which subagents handle which CLIs, dispatches one subagent per CLI (or per CLI cluster if grouping by similar shape).
+- Each subagent gets:
+  - The CLI's path
+  - The exact recipe (this section)
+  - The Phase 1 helper code (cliutil functions if any)
+- Subagents work in parallel, each producing one PR.
+- Controller monitors PR creation, surfaces failures.
+
+**No-touch rule:** the codemod does NOT modify:
+- `internal/cli/` (hand-written CLI commands stay as-is)
+- `internal/client/`, `internal/store/`, `internal/source/`
+- `README.md`, `SKILL.md`, `tools-manifest.json` (inputs, not outputs)
+- `.manuscripts/`, `dogfood-results.json`, `workflow-verify-report.json`
+- `cmd/<api>-pp-cli/main.go` (CLI binary entry point)
+
+**Definition of done per CLI:**
+- New `manifest.json` parses as valid JSON and has the expected `user_config` fields.
+- `cmd/<api>-pp-mcp/main.go` `server.NewMCPServer(<friendly>, ...)` matches the catalog's display_name.
+- `internal/mcp/tools.go` builds clean (`go build ./...` from CLI dir).
+- Substitution bug fixed (only 5 affected CLIs).
+- Novel-feature tools registered (≈20 of 25 CLIs).
+- PR opened against `printing-press-library:main`.
+
+**Definition of done across the codemod:**
+- 25 PRs open in the public library, each green CI.
+- Each PR title follows `type(scope): description` convention.
+- A spot-check of 3 CLIs (one with substitution fix, one with novel features, one without) installs cleanly in Claude Desktop and exposes the expected tools.
+
+### Phase 3 — Optional follow-ups
+
+After Phases 1 and 2 ship, these become tractable:
+
+- **Per-feature typed argument schemas.** For high-traffic features (e.g., company-goat's `funding`, dub's `links lint`), migrate from single-string args to structured fields. Requires per-feature flag-parsing intelligence in the generator.
+- **MCP `progress` notifications** for long-running novel features (search across many sources, sync, etc.). Requires the shell-out handler to stream output.
+- **Tool description audit** (workstream E from the original codemod scope). With novel features now exposed, the framing-drift problem changes shape — descriptions need to reflect the expanded surface, not the narrow one. Doing E before this phase would mean rewriting twice.
+- **Goreleaser MCPB cross-platform packaging.** Generate `.mcpb` artifacts on every release for darwin-arm64/amd64, linux-amd64/arm64, windows-amd64. Requires per-CLI `.goreleaser.yaml` updates across the public library.
+- **Path 1 refactor** of selected CLIs (extract pure work from CLI presentation, MCP calls work directly). Reserve for the CLIs where shell-out latency or output size becomes painful.
+
+## Risks & mitigations
+
+| Risk | Mitigation |
+|---|---|
+| Shell-out latency makes simple novel features feel slow | The CLI binary already starts in <100ms; `exec.Command` overhead is negligible. Profile if it becomes a complaint. |
+| Bundle size doubles (CLI + MCP both included) | Both are Go binaries (~10-15 MB each → ~25 MB compressed). Acceptable for an installer. |
+| Args-as-string is too loose; agents pass malformed flags | Document the expected shape per tool in the description. Agents already construct shell args reliably. Add an example in each description (auto-generated from the spec). |
+| `siblingCLIPath` lookup fails on unconventional bundle layouts | Three-tier fallback: sibling → env var → PATH. Documented in the doctor command. |
+| Per-CLI codemod produces 25 PRs reviewers can't keep up with | Subagents auto-merge low-risk PRs after CI green if the user opts in; otherwise queue them with shared review ownership. |
+| Customizations in `internal/mcp/tools.go` get overwritten | Codemod only edits well-known generator-emitted regions (handleContext body, append at file end). It greps for the `RegisterTools` boundary and edits within those landmarks. |
+| Subagent variance produces inconsistent PRs | Controller agent provides the exact recipe and a verification checklist. Each subagent must run `go build` + `go vet` before opening its PR. |
+
+## Effort estimate
+
+| Phase | Estimated effort | Complexity |
+|---|---|---|
+| Phase 1 (machine) | 1-2 days focused work | Medium — new template, new pipeline plumbing, goldens |
+| Phase 2 (library codemod) | 1 day to scaffold + 1 day for 25 parallel subagents to apply + 1-2 days for review/merge | Low per-PR, high coordination |
+| Phase 3 (follow-ups) | Multi-week, demand-driven | Variable per item |
+
+Total Phase 1 + 2: roughly 4-5 days of focused work. Phase 3 follow-ups are not on the critical path.
+
+## Resolved decisions (was: open questions)
+
+All decisions locked 2026-04-28:
+
+1. **PR batching: hybrid 2-PR** — PR 1 ships substitution-bug fix across 5 affected CLIs (urgent track). PR 2 ships MCPB-enable + novel-feature tools across the remaining ~20 MCP-shipping CLIs.
+2. **`cli_binary` field in manifest:** include it.
+3. **CLIs without MCP** (`instacart`, `agent-capture`, `slack`, `linear`, `archive-is`, `steam-web`, `hubspot`, `trigger-dev`, `postman-explore`): skip entirely. They're CLI-only by design — adding an empty MCP creates dead surface.
+4. **`tools-manifest.json`:** input-only. `.printing-press.json`'s `novel_features[]` is the source of truth; `tools-manifest.json` keeps documenting only spec-driven tools.
+
+## Hand-off after Phase 2
+
+When 25 PRs are open and CI green:
+
+> MCPB feature parity codemod complete. 25 PRs open in `printing-press-library`. Each adds manifest.json, friendly server name, capability-boundary context tool, and (where applicable) novel-feature MCP tools. Bug-fix subset (5 CLIs) also corrects substitution-bug tool registrations. Spot-check on company-goat in Claude Desktop confirms snapshot/funding/etc. now callable as MCP tools. Ready for review / merge.
+
+Reviewers can land PRs in any order; each is independent. After all 25 land, the public library is MCPB-feature-complete. Phase 3 follow-ups (typed args, goreleaser, description audit) become discretionary work driven by user feedback.
diff --git a/internal/cli/bundle.go b/internal/cli/bundle.go
index ef128541..d347737b 100644
--- a/internal/cli/bundle.go
+++ b/internal/cli/bundle.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline"
 	"github.com/spf13/cobra"
+	"golang.org/x/sync/errgroup"
 )
 
 func newBundleCmd() *cobra.Command {
@@ -19,6 +20,8 @@ func newBundleCmd() *cobra.Command {
 	var platform string
 	var skipBuild bool
 	var binaryPath string
+	var cliSkipBuild bool
+	var cliBinaryPath string
 
 	cmd := &cobra.Command{
 		Use:   "bundle <cli-dir>",
@@ -79,11 +82,11 @@ from another build pipeline.`,
 			if binaryPath == "" {
 				binaryPath = pipeline.StagedMCPBinaryPath(cliDir, manifest.Name)
 			}
-
-			if !skipBuild {
-				if err := buildMCPBBinary(cliDir, manifest.Name, binaryPath, goos, goarch); err != nil {
-					return fmt.Errorf("building MCP binary: %w", err)
-				}
+			if cliBinaryPath == "" && manifest.CLIBinary != "" {
+				cliBinaryPath = pipeline.StagedMCPBinaryPath(cliDir, manifest.CLIBinary)
+			}
+			if err := buildBundleBinaries(cliDir, manifest.Name, binaryPath, skipBuild, manifest.CLIBinary, cliBinaryPath, cliSkipBuild, goos, goarch); err != nil {
+				return err
 			}
 
 			if output == "" {
@@ -91,9 +94,10 @@ from another build pipeline.`,
 			}
 
 			if err := pipeline.BuildMCPBBundle(pipeline.BundleParams{
-				CLIDir:     cliDir,
-				BinaryPath: binaryPath,
-				OutputPath: output,
+				CLIDir:        cliDir,
+				BinaryPath:    binaryPath,
+				CLIBinaryPath: cliBinaryPath,
+				OutputPath:    output,
 			}); err != nil {
 				return fmt.Errorf("packaging bundle: %w", err)
 			}
@@ -105,8 +109,10 @@ from another build pipeline.`,
 
 	cmd.Flags().StringVar(&output, "output", "", "Output .mcpb path (default: <dir>/build/<name>-<os>-<arch>.mcpb)")
 	cmd.Flags().StringVar(&platform, "platform", "", "Target platform as <os>/<arch> (default: host)")
-	cmd.Flags().BoolVar(&skipBuild, "skip-build", false, "Skip go build; use the binary at --binary")
+	cmd.Flags().BoolVar(&skipBuild, "skip-build", false, "Skip go build for the MCP binary; use the binary at --binary")
 	cmd.Flags().StringVar(&binaryPath, "binary", "", "Pre-built MCP binary path (only meaningful with --skip-build)")
+	cmd.Flags().BoolVar(&cliSkipBuild, "cli-skip-build", false, "Skip go build for the companion CLI binary; use the binary at --cli-binary")
+	cmd.Flags().StringVar(&cliBinaryPath, "cli-binary", "", "Pre-built CLI binary path (only meaningful with --cli-skip-build)")
 	return cmd
 }
 
@@ -142,15 +148,20 @@ func autoBundleForHost(cliDir string, w io.Writer) {
 		return
 	}
 	binaryPath := pipeline.StagedMCPBinaryPath(cliDir, manifest.Name)
-	if err := buildMCPBBinary(cliDir, manifest.Name, binaryPath, runtime.GOOS, runtime.GOARCH); err != nil {
-		fmt.Fprintf(w, "warning: could not build MCP binary for bundle: %v\n", err)
+	cliBinaryPath := ""
+	if manifest.CLIBinary != "" {
+		cliBinaryPath = pipeline.StagedMCPBinaryPath(cliDir, manifest.CLIBinary)
+	}
+	if err := buildBundleBinaries(cliDir, manifest.Name, binaryPath, false, manifest.CLIBinary, cliBinaryPath, false, runtime.GOOS, runtime.GOARCH); err != nil {
+		fmt.Fprintf(w, "warning: %v\n", err)
 		return
 	}
 	output := pipeline.DefaultBundleOutputPath(cliDir, manifest.Name, runtime.GOOS, runtime.GOARCH)
 	if err := pipeline.BuildMCPBBundle(pipeline.BundleParams{
-		CLIDir:     cliDir,
-		BinaryPath: binaryPath,
-		OutputPath: output,
+		CLIDir:        cliDir,
+		BinaryPath:    binaryPath,
+		CLIBinaryPath: cliBinaryPath,
+		OutputPath:    output,
 	}); err != nil {
 		fmt.Fprintf(w, "warning: could not package MCPB bundle: %v\n", err)
 		return
@@ -158,6 +169,31 @@ func autoBundleForHost(cliDir string, w io.Writer) {
 	fmt.Fprintf(w, "Bundled %s\n", output)
 }
 
+// buildBundleBinaries compiles the MCP and (optionally) CLI binaries
+// concurrently. Go's build cache is concurrency-safe and the two packages
+// are disjoint, so running serial doubles bundle latency for no reason.
+// Returns once both finish; the first error wins.
+func buildBundleBinaries(cliDir, mcpName, mcpPath string, skipMCP bool, cliName, cliPath string, skipCLI bool, goos, goarch string) error {
+	var g errgroup.Group
+	if !skipMCP {
+		g.Go(func() error {
+			if err := buildMCPBBinary(cliDir, mcpName, mcpPath, goos, goarch); err != nil {
+				return fmt.Errorf("building MCP binary: %w", err)
+			}
+			return nil
+		})
+	}
+	if !skipCLI && cliName != "" {
+		g.Go(func() error {
+			if err := buildMCPBBinary(cliDir, cliName, cliPath, goos, goarch); err != nil {
+				return fmt.Errorf("building CLI binary: %w", err)
+			}
+			return nil
+		})
+	}
+	return g.Wait()
+}
+
 // resolvePlatform parses an optional "<os>/<arch>" string and falls back
 // to the host's GOOS/GOARCH. Returns a useful error message when the
 // caller passes a malformed value rather than silently defaulting.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 24638ba4..f0405299 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -226,10 +226,12 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"graphqlFieldSelection": func(typeName string, types map[string]spec.TypeDef) []string {
 			return graphqlFieldSelection(typeName, types)
 		},
-		"isGraphQL": isGraphQLSpec,
-		"backtick":  func() string { return "`" },
-		"kebab":     toKebab,
-		"humanName": naming.HumanName,
+		"isGraphQL":   isGraphQLSpec,
+		"backtick":    func() string { return "`" },
+		"kebab":       toKebab,
+		"humanName":   naming.HumanName,
+		"envPrefix":   naming.EnvPrefix,
+		"mcpToolName": naming.SnakeIdentifier,
 		"lookupEndpoint": func(resources map[string]spec.Resource, ref string) spec.Endpoint {
 			e, _ := lookupEndpointForTemplate(resources, ref)
 			return e
diff --git a/internal/generator/mcp_novel_features_test.go b/internal/generator/mcp_novel_features_test.go
new file mode 100644
index 00000000..dd011c58
--- /dev/null
+++ b/internal/generator/mcp_novel_features_test.go
@@ -0,0 +1,134 @@
+package generator
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"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) {
+	t.Parallel()
+
+	apiSpec := minimalSpec("noveltest")
+	outputDir := filepath.Join(t.TempDir(), "noveltest-pp-cli")
+	gen := New(apiSpec, outputDir)
+	gen.NovelFeatures = []NovelFeature{
+		{
+			Name:        "Snapshot fanout",
+			Command:     "snapshot",
+			Description: "Look up a company across multiple sources in one call.",
+			Rationale:   "Saves agent round-trips.",
+		},
+		{
+			Name:        "Form D related-person graph",
+			Command:     "funding --who",
+			Description: "Show every Form D filing that names a given person.",
+			Rationale:   "Reveals serial founders.",
+		},
+		{
+			Name:        "Funding cadence",
+			Command:     "funding-trend",
+			Description: "Time series of Form D filings for a company.",
+			Rationale:   "Spots silent-quarter signals.",
+		},
+	}
+	require.NoError(t, gen.Generate())
+
+	tools, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
+	require.NoError(t, err)
+	content := string(tools)
+
+	// Function declaration with body
+	assert.Contains(t, content, "func RegisterNovelFeatureTools(s *server.MCPServer) {")
+
+	// 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"`)
+
+	// Env-var fallback uses the API's prefix (uppercased, hyphens to underscores)
+	assert.Contains(t, content, `os.Getenv("NOVELTEST_CLI_PATH")`)
+
+	// os/exec import must be present (only when novel features exist)
+	assert.Contains(t, content, `"os/exec"`)
+
+	// main.go always calls RegisterNovelFeatureTools — wiring stays uniform
+	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)")
+}
+
+// TestMCPNovelFeatureToolNameSanitization pins the snake-case tool-name
+// derivation across the corner cases the catalog actually uses.
+func TestMCPNovelFeatureToolNameSanitization(t *testing.T) {
+	t.Parallel()
+
+	cases := map[string]string{
+		"snapshot":         "snapshot",
+		"funding-trend":    "funding_trend",
+		"funding --who":    "funding_who",
+		"compare":          "compare",
+		"signal":           "signal",
+		"FUNDING --WHO":    "funding_who", // uppercase folds
+		"  weird   spaces": "weird_spaces",
+		"trailing-":        "trailing", // trailing underscore stripped
+		"":                 "",         // empty stays empty
+	}
+
+	apiSpec := minimalSpec("sanitize")
+	outputDir := filepath.Join(t.TempDir(), "sanitize-pp-cli")
+	gen := New(apiSpec, outputDir)
+	for command := range cases {
+		if command == "" {
+			continue
+		}
+		gen.NovelFeatures = append(gen.NovelFeatures, NovelFeature{
+			Name:        "Test " + command,
+			Command:     command,
+			Description: "test feature",
+		})
+	}
+	require.NoError(t, gen.Generate())
+
+	tools, err := os.ReadFile(filepath.Join(outputDir, "internal", "mcp", "tools.go"))
+	require.NoError(t, err)
+	content := string(tools)
+
+	for command, want := range cases {
+		if command == "" || want == "" {
+			continue
+		}
+		assert.True(t,
+			strings.Contains(content, `mcplib.NewTool("`+want+`",`),
+			"command %q should produce tool name %q", command, want)
+	}
+}
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index da73b5ae..63700647 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -33,6 +33,7 @@ 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)")
@@ -86,6 +87,7 @@ 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 4e673760..53c75048 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -8,6 +8,9 @@ import (
 	"encoding/json"
 	"fmt"
 	"os"
+{{- if .NovelFeatures}}
+	"os/exec"
+{{- end}}
 	"path/filepath"
 	"strings"
 	"time"
@@ -432,3 +435,80 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 	data, _ := json.MarshalIndent(ctx, "", "  ")
 	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.
+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}}
+}
+{{- 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/naming/naming.go b/internal/naming/naming.go
index 6bb5da6a..85935bee 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -43,6 +43,31 @@ func HumanName(slug string) string {
 	return cases.Title(language.English).String(strings.ReplaceAll(slug, "-", " "))
 }
 
+// SnakeIdentifier collapses a free-form command spec into a snake_case Go
+// identifier safe to use as an MCP tool name. "funding --who" → "funding_who",
+// "FUNDING-TREND" → "funding_trend". Used by the generator's mcpToolName
+// template helper.
+func SnakeIdentifier(s string) string {
+	var b strings.Builder
+	lastUnder := false
+	for _, r := range s {
+		switch {
+		case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
+			b.WriteRune(r)
+			lastUnder = false
+		case r >= 'A' && r <= 'Z':
+			b.WriteRune(r + ('a' - 'A'))
+			lastUnder = false
+		default:
+			if !lastUnder && b.Len() > 0 {
+				b.WriteByte('_')
+				lastUnder = true
+			}
+		}
+	}
+	return strings.TrimRight(b.String(), "_")
+}
+
 // EnvPrefix returns an ASCII-only shell-safe environment variable prefix.
 // API display names and OpenAPI titles can contain accents or punctuation
 // ("PokéAPI", "Cal.com", "1Password"); generated env vars must not.
diff --git a/internal/pipeline/mcpb_bundle.go b/internal/pipeline/mcpb_bundle.go
index d7d8dbe7..089b1d2c 100644
--- a/internal/pipeline/mcpb_bundle.go
+++ b/internal/pipeline/mcpb_bundle.go
@@ -17,10 +17,17 @@ import (
 // BinaryPath. OutputPath is where the .mcpb file will be written; the
 // caller is responsible for choosing a path that includes platform
 // 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).
 type BundleParams struct {
-	CLIDir     string
-	BinaryPath string
-	OutputPath string
+	CLIDir        string
+	BinaryPath    string
+	CLIBinaryPath string
+	OutputPath    string
 }
 
 // BuildMCPBBundle assembles an MCPB ZIP at OutputPath. The bundle layout is:
@@ -49,16 +56,6 @@ func BuildMCPBBundle(params BundleParams) error {
 		return errors.New("manifest server.entry_point is empty")
 	}
 
-	binFile, err := os.Open(params.BinaryPath)
-	if err != nil {
-		return fmt.Errorf("locating MCP binary at %s: %w", params.BinaryPath, err)
-	}
-	defer func() { _ = binFile.Close() }()
-	binStat, err := binFile.Stat()
-	if err != nil {
-		return fmt.Errorf("stat MCP binary: %w", err)
-	}
-
 	if err := os.MkdirAll(filepath.Dir(params.OutputPath), 0o755); err != nil {
 		return fmt.Errorf("creating bundle output dir: %w", err)
 	}
@@ -74,13 +71,18 @@ func BuildMCPBBundle(params BundleParams) error {
 		_ = zw.Close()
 		return fmt.Errorf("writing manifest into bundle: %w", err)
 	}
-	// Preserve executable bit so hosts that respect zip POSIX mode bits
-	// (Claude Desktop on macOS, MCP for Windows) can launch the binary
-	// directly without an extra chmod step. Stream the binary rather than
-	// loading the whole thing into RAM — bundles can be 15+ MB.
-	if err := writeZipReader(zw, manifest.Server.EntryPoint, binFile, binStat.Mode()&0o777); err != nil {
+	// Stream binaries (10-30 MB combined) instead of buffering. Preserve exec
+	// bits so hosts that honor POSIX zip mode (Claude Desktop on macOS, MCP
+	// for Windows) launch directly without chmod.
+	if err := zipFile(zw, manifest.Server.EntryPoint, params.BinaryPath); err != nil {
 		_ = zw.Close()
-		return fmt.Errorf("writing binary into bundle: %w", err)
+		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 {
+			_ = zw.Close()
+			return fmt.Errorf("writing CLI binary into bundle: %w", err)
+		}
 	}
 	if err := zw.Close(); err != nil {
 		return fmt.Errorf("finalizing bundle archive: %w", err)
@@ -88,6 +90,20 @@ func BuildMCPBBundle(params BundleParams) error {
 	return nil
 }
 
+// zipFile streams srcPath into the zip at name, preserving exec bits.
+func zipFile(zw *zip.Writer, name, srcPath string) error {
+	src, err := os.Open(srcPath)
+	if err != nil {
+		return fmt.Errorf("opening %s: %w", srcPath, err)
+	}
+	defer func() { _ = src.Close() }()
+	stat, err := src.Stat()
+	if err != nil {
+		return fmt.Errorf("stat %s: %w", srcPath, err)
+	}
+	return writeZipReader(zw, name, src, stat.Mode()&0o777)
+}
+
 func writeZipBytes(zw *zip.Writer, name string, data []byte, mode os.FileMode) error {
 	return writeZipReader(zw, name, bytes.NewReader(data), mode)
 }
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index 9cc0ce1b..acbebc58 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -65,6 +65,11 @@ 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
@@ -166,6 +171,7 @@ 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
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 d54751b5..a051ea60 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,6 +19,7 @@ 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/mcp/tools.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.go
index a6c910fb..94f3f615 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
@@ -326,3 +326,8 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 	data, _ := json.MarshalIndent(ctx, "", "  ")
 	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.
+func RegisterNovelFeatureTools(s *server.MCPServer) {
+}
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 962b0f42..c66579c4 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,6 +2,7 @@
   "author": {
     "name": "CLI Printing Press"
   },
+  "cli_binary": "printing-press-golden-pp-cli",
   "compatibility": {
     "claude_desktop": ">=1.0.0",
     "platforms": [

← af214509 feat(cli): MCPB manifest and bundle support for generated CL  ·  back to Cli Printing Press  ·  ci(ci): split fast and full test lanes (#357) 9e260025 →