[object Object]

← back to Cli Printing Press

feat(cli): MCPB manifest and bundle support for generated CLIs (#355)

af21450994270738936728f5cfefabb75dadba9b · 2026-04-27 21:06:28 -0700 · Trevin Chow

* fix(cli): substitute path placeholders in MCP tool URLs regardless of declared param location

Generated MCP tools were sending literal "{name}" placeholders in URLs
when the spec author declared a path-template parameter as location:query
(or with no location), or when the param was reclassified to a flag for
pagination/date reasons. Affects 49 tool registrations across 5 published
CLIs (pagliacci-pizza 29, cal-com 13, movie-goat 4, flightgoat 2,
company-goat 1).

Two-layer fix:

1. internal/spec/spec.go: enrichEndpointPathParams now promotes existing
   params whose name matches a {placeholder} in the URL path by setting
   PathParam=true and Required=true, instead of skipping them. The path
   template is authoritative — even if the author wrote location:query
   the URL still has {cik} as a substitution slot. PathParam=true
   preserves CLI-rendering intent (flag, not positional arg) while
   making the param visible to URL substitution.

2. internal/generator/templates/mcp_tools.go.tmpl: emit positionalParams
   from any param where Positional || PathParam is true, not just
   Positional. This catches both the parser-promotion case above and the
   OpenAPI parser's existing reclassification path
   (parser.go:1620 sets PathParam=true on date/year/month/pagination
   params already in path), which the template silently ignored.

Tests:
- internal/spec/spec_test.go: TestEnrichEndpointPathParams covers the
  three meaningful cases: undeclared placeholder (existing positional
  injection), declared-as-flag promoted to PathParam, repeated
  placeholder enriched once. Pins body-declared placeholders as
  intentionally not enriched.
- testdata/golden/fixtures/golden-api.yaml: new /reports/{year}/summary
  endpoint exercises the OpenAPI reclassification path (year is in:path
  but the parser promotes to PathParam=true). Golden output's tools.go
  now correctly emits []string{"year"} as positionalParams; without
  the template fix it would be []string{} and the URL would send
  literal {year} to the API.

Goldens updated to reflect the new fixture endpoint. Diffs verified:
- tools.go gains one MCP tool with []string{"year"} substitution list
- root.go gains one cobra command registration
- counts/checksums in .printing-press.json, dogfood.json, scorecard.json
  shift to match the expanded spec (6→7 tools, 35→38 endpoint metrics)
- README/SKILL gain a "reports" section

Codemod for the 5 affected published CLIs follows in a separate change.

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

* feat(cli): emit MCPB manifest.json and friendly-name MCP server identity

Generated CLIs now ship a manifest.json (MCPB v0.3 format) alongside
.printing-press.json. The manifest is what Claude Desktop, Claude Code,
and other MCPB-aware hosts read when installing a .mcpb bundle — it
declares the MCP server identity, the launch command, and any user_config
fields the host should prompt the user for at install time.

Manifest contents are derived entirely from existing per-CLI metadata
(.printing-press.json + spec auth fields + catalog display_name) so the
agent can audit it during Phase 4 shipcheck just like it audits SKILL.md
and README.md. The MCPB bundle ZIP itself is a release-time goreleaser
artifact (attached to GitHub releases); manifest.json is the source of
truth committed to the published CLI directory.

Pieces:

- internal/spec/spec.go: APISpec gains DisplayName field plus
  EffectiveDisplayName() helper that returns the explicit value or
  title-cases Name as a fallback. Used wherever a friendly brand
  identity beats a kebab-case slug — Claude Desktop's connector list,
  manifest display_name, the MCP server's protocol-level name.
- internal/generator/templates/main_mcp.go.tmpl: passes
  EffectiveDisplayName into server.NewMCPServer instead of the
  kebab-case binary name. Connector menus now show "Stripe" or
  "Cal.com" or "PokéAPI" instead of "stripe-pp-mcp".
- internal/pipeline/climanifest.go: CLIManifest gains DisplayName
  (with catalog/spec fallback chain) and AuthKeyURL fields.
  populateMCPMetadata sets both. WriteManifestForGenerate now also
  writes manifest.json.
- internal/pipeline/mcpb_manifest.go (new): MCPBManifest types
  matching the upstream v0.3 schema, plus WriteMCPBManifest that
  reads .printing-press.json and emits manifest.json. Skips when
  MCPReady is "cli-only" (composed-auth flows where MCP can't stand
  alone) or when no MCP binary exists. user_config fields are sensitive
  by default and required only when auth_type gates every API call
  (api_key, bearer_token, oauth2); cookie/composed/none keep them
  optional so agents installing a Claude-Desktop-only bundle aren't
  forced to enter credentials they may not have yet.
- internal/pipeline/publish.go, lock.go: replace writeSmitheryYAML
  calls with WriteMCPBManifest. The smithery.yaml emission was never
  wired up to Smithery's actual registry (which requires per-CLI
  registration on smithery.ai with no Go install path), so the file
  was inert documentation. The MCPB manifest is the path the upstream
  MCP project standardized on (Nov 2025) with growing host adoption.
- internal/pipeline/publish.go: smitheryConfig types and writeSmitheryYAML
  removed. yaml import dropped (no longer used in this file).

Tests:
- TestWriteSmitheryYAML replaced with TestWriteMCPBManifest covering
  the same gates (no-manifest, cli-only skip, missing binary skip)
  plus MCPB-specific cases: api_key required, composed optional,
  multi-env-var (company-goat shape with both GITHUB_TOKEN and
  COMPANIES_HOUSE_API_KEY optional), no-auth-no-user-config.
- testdata/golden/cases/generate-golden-api/artifacts.txt now snapshots
  manifest.json and cmd/<api>-pp-mcp/main.go so the friendly-name flow
  and full manifest shape are locked in as a golden contract.

Out of scope for this commit (will follow):
- Refactoring the context MCP tool template to emit capability-derived
  bullets instead of marketing prose.
- Codemod across the public library (manifest backfill, friendly-name
  update for existing CLIs, goreleaser config for .mcpb bundles).

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

* fix(cli): MCP context tool delineates MCP-callable vs CLI-only capabilities

The context tool's prior output named hand-written features
"unique_capabilities" without distinguishing whether each item was
actually MCP-callable. Claude Desktop users reading this output have
inferred the MCP could invoke commands like snapshot or funding
directly, when those are CLI-only — leading to attempts that surface
404s or "tool not found" instead of clear "use the CLI binary"
guidance.

Two surface changes in the generated handleContext output:

- Renames "unique_capabilities" → "cli_only_capabilities" so the key
  itself signals the scope. Each entry gets a "via": "cli" field, which
  is machine-readable for agents that filter capabilities by transport.
- Adds a new "tool_surface" field with one sentence explaining that
  resources/sync/search/sql are MCP-callable while
  cli_only_capabilities require running the companion -pp-cli binary.
  Sits next to tool_count so an agent reading top-down sees the
  boundary before parsing capability lists.

Codified in the golden fixture so future template changes can't
silently re-conflate the two surfaces.

Out of scope (deliberate): wholesale separation of research-derived
prose into "positioning" vs "capability" channels. The renaming +
tool_surface note solves the misinterpretation Claude Desktop made in
practice; the broader prose-channel split is a larger refactor that
should follow once we've seen what other prose drift surfaces in real
agent sessions.

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

* feat(cli): add bundle subcommand and auto-bundle on generate

A printed CLI's manifest.json alone isn't installable — Claude Desktop
takes a .mcpb ZIP, not a JSON file. Personal-use printers (anyone who
generates a CLI for themselves without publishing to the public library)
need a way to package locally without GitHub Actions or hand-rolled bash.

Two surfaces:

1. `printing-press bundle <cli-dir>` — packages an already-generated CLI
   into a .mcpb. Compiles the MCP binary via `go build`, ZIPs with
   manifest.json, writes <dir>/build/<name>-<os>-<arch>.mcpb. Supports
   --platform for cross-compile, --skip-build with --binary for pre-
   built artifacts (so release pipelines that build via goreleaser can
   reuse the packaging step).

2. `printing-press generate` now auto-bundles for the host platform
   after writing manifest.json. Skips silently when go.sum isn't
   populated (e.g. --validate=false leaves the module untidied), so
   personal-use printers get an immediately-installable .mcpb without
   needing to know about the bundle subcommand, while machine flows
   that intentionally skip module wiring don't see noisy warnings.

The `bundle` subcommand exists separately from auto-run so users can:
- Cross-compile to a non-host platform (--platform linux/amd64)
- Rebuild after manually editing manifest.json or the MCP binary
- Wrap a pre-built binary from another build pipeline (--skip-build)
- Recover from an interrupted generate that wrote the manifest but
  failed mid-build

Pieces:

- internal/pipeline/mcpb_bundle.go: BuildMCPBBundle assembles the
  ZIP from a manifest.json and binary path. Preserves POSIX execute
  bits so macOS/Linux hosts can launch directly. DefaultBundleOutputPath
  conventionally writes to <dir>/build/<name>-<os>-<arch>.mcpb.
- internal/pipeline/mcpb_bundle_test.go: covers happy path, missing
  manifest skip, empty entry_point rejection, executable bit preservation.
- internal/cli/bundle.go: cobra command, plus autoBundleForHost helper
  used by generate. resolvePlatform parses <os>/<arch> with a useful
  error rather than silently defaulting on bad input.
- internal/cli/root.go: registers `bundle`, calls autoBundleForHost
  after both generate code paths (docs-driven and spec-driven).

Bundles are gitignored release outputs (~10MB compressed); only
manifest.json is the source of truth committed to the repo. CI/release
pipelines build the cross-platform matrix via goreleaser; the host-only
auto-build in generate covers personal-use install without requiring CI.

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

* refactor(cli): simplify MCPB manifest emission per /simplify pass

Acts on findings from the post-implementation simplify review:

P1 — correctness/UX

- mcpb_manifest.go: Version was hardcoded "1.0.0", which would defeat
  Claude Desktop's "newer bundle available" detection. Now stamps from
  CLIManifest.PrintingPressVersion (falls back to the linker version
  when the manifest field is empty). golden.sh normalizes the stamped
  version to <PRINTING_PRESS_VERSION> in the manifest.json fixture so
  release bumps don't churn the golden.
- spec.go: EffectiveDisplayName's hand-rolled `s[:1]` title-case loop
  sliced mid-codepoint on multi-byte runes (would mangle "PokéAPI",
  "Café"). Now calls a new `naming.HumanName` helper that uses
  `cases.Title(language.English)` — same path generator.go's humanName
  template func and proseName() were already using. Both call sites
  collapsed to the new helper, eliminating two duplicate implementations.
- root.go: autoBundleForHost ran before the "Generated <api> at <path>"
  print, blocking that line behind 5–15s of go build. Moved it after
  the print so users see completion immediately and the bundle output
  follows as a separate "Bundled <path>" line. Same effect on both
  generate code paths (docs-driven and spec-driven).

P2 — cleanup

- mcpb_manifest.go: hoisted "binary", "string", platform list, and the
  authTypeAPIKey/BearerToken/OAuth2 strings to package consts so a
  typo can't silently flip authRequiresCredential's branch.
- climanifest.go: WriteManifestForGenerate now calls
  WriteMCPBManifestFromStruct (new in-memory variant) instead of
  WriteMCPBManifest, which was reading and re-parsing the JSON we'd
  just written. Saves a syscall and prevents drift if the writer/reader
  paths ever diverge. Old WriteMCPBManifest kept for the standalone
  `bundle` subcommand which has nothing in memory.
- mcpb_bundle.go: added pipeline.StagedMCPBinaryPath helper so
  internal/cli/bundle.go doesn't reach into pipeline's directory
  layout. Sentinel error in BuildMCPBBundle uses errors.New instead
  of fmt.Errorf with no formatting.
- bundle.go: trimmed long doc comments on autoBundleForHost. Replaced
  "manifest.json missing — has this CLI been generated yet? %w" (which
  rendered as "...generated yet? open <path>: no such file or directory")
  with "reading manifest.json (run `printing-press generate` first): %w".
- mcpb_manifest.go: dropped envVarTitle (a pure identity function with
  more comment than code) — the title is just the env var verbatim and
  inlines cleanly. Compressed the SetEscapeHTML and skip-cases comments;
  retained the WHY each gate exists.
- mcp_tools.go.tmpl: trimmed the 9-line tool_surface preamble comment
  to 2 lines; the emitted tool_surface field text already explains the
  same thing to the agent reading runtime output.
- spec_test.go: renamed misleading "still triggers promotion path" case
  to "is not promoted" so the name matches what the assertion checks.

Test results unchanged (2330 passing, golden verify clean). Goldens
regenerated for the manifest comment trim and the version stamp
normalization.

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

* fix(cli): bundle observability, streaming binary, claude-desktop version, tests

Acts on PR review P2 findings (deferred #3 go-build consolidation
— that's an /simplify-and-refactor sweep across 11 call sites and
out of scope here).

#1 — autoBundleForHost error reporting

Distinguish expected silent skips (no manifest, no go.sum — both mean
"this CLI doesn't ship a bundle today, that's fine") from real
failures (malformed manifest, empty manifest.name — both mean a
partial-write/corruption bug the user wants to hear about).
Previously every error path was silent, hiding broken state.

#2 — stream binary instead of buffering in RAM

BuildMCPBBundle now opens the MCP binary and io.Copy()s it into the
zip stream. Old path read the entire binary (~10-15 MB for our
Go binaries) into a []byte before zipping, doubling peak resident
memory for no benefit. Added writeZipReader (io.Reader-shaped)
alongside writeZipBytes so manifestData (small, in memory) can keep
its existing call site without a wasteful bytes.Buffer round-trip.

#5 — minClaudeDesktopVersion constant

Hoisted ">=1.0.0" out of buildMCPBManifest into a named const so
the version sits next to the comment that explains why it's that
value (the MCPB-introducing release of Claude Desktop). Bumping later
becomes a one-line change instead of a hunt across templates.

#4 — tests for bundle CLI surface

New internal/cli/bundle_test.go covers what the pipeline-level tests
don't:
- TestResolvePlatform: empty/host fallback, "<os>/<arch>" parsing,
  malformed-input errors. Pure function, exhaustive.
- TestAutoBundleForHost: no manifest (silent), malformed manifest
  (loud), empty name (loud), missing go.sum (silent). Pins the
  observability boundary #1 establishes.
- TestNewBundleCmdMissingManifest: cobra-level error wrapping —
  asserts the user-facing message hints at running `generate` first.
- TestNewBundleCmdNotADirectory: cobra-level path validation.

12 new test cases total; suite is now 2342 passing (was 2330).
buildMCPBBinary itself isn't unit-tested — it shells out to `go build`
and the existing TestBuildMCPBBundle already exercises the
"binary-on-disk → bundle" half. Integration coverage of the full
generate→bundle path lives in the golden suite.

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

    feat(cli): MCPB manifest and bundle support for generated CLIs (#355)
    
    * fix(cli): substitute path placeholders in MCP tool URLs regardless of declared param location
    
    Generated MCP tools were sending literal "{name}" placeholders in URLs
    when the spec author declared a path-template parameter as location:query
    (or with no location), or when the param was reclassified to a flag for
    pagination/date reasons. Affects 49 tool registrations across 5 published
    CLIs (pagliacci-pizza 29, cal-com 13, movie-goat 4, flightgoat 2,
    company-goat 1).
    
    Two-layer fix:
    
    1. internal/spec/spec.go: enrichEndpointPathParams now promotes existing
       params whose name matches a {placeholder} in the URL path by setting
       PathParam=true and Required=true, instead of skipping them. The path
       template is authoritative — even if the author wrote location:query
       the URL still has {cik} as a substitution slot. PathParam=true
       preserves CLI-rendering intent (flag, not positional arg) while
       making the param visible to URL substitution.
    
    2. internal/generator/templates/mcp_tools.go.tmpl: emit positionalParams
       from any param where Positional || PathParam is true, not just
       Positional. This catches both the parser-promotion case above and the
       OpenAPI parser's existing reclassification path
       (parser.go:1620 sets PathParam=true on date/year/month/pagination
       params already in path), which the template silently ignored.
    
    Tests:
    - internal/spec/spec_test.go: TestEnrichEndpointPathParams covers the
      three meaningful cases: undeclared placeholder (existing positional
      injection), declared-as-flag promoted to PathParam, repeated
      placeholder enriched once. Pins body-declared placeholders as
      intentionally not enriched.
    - testdata/golden/fixtures/golden-api.yaml: new /reports/{year}/summary
      endpoint exercises the OpenAPI reclassification path (year is in:path
      but the parser promotes to PathParam=true). Golden output's tools.go
      now correctly emits []string{"year"} as positionalParams; without
      the template fix it would be []string{} and the URL would send
      literal {year} to the API.
    
    Goldens updated to reflect the new fixture endpoint. Diffs verified:
    - tools.go gains one MCP tool with []string{"year"} substitution list
    - root.go gains one cobra command registration
    - counts/checksums in .printing-press.json, dogfood.json, scorecard.json
      shift to match the expanded spec (6→7 tools, 35→38 endpoint metrics)
    - README/SKILL gain a "reports" section
    
    Codemod for the 5 affected published CLIs follows in a separate change.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): emit MCPB manifest.json and friendly-name MCP server identity
    
    Generated CLIs now ship a manifest.json (MCPB v0.3 format) alongside
    .printing-press.json. The manifest is what Claude Desktop, Claude Code,
    and other MCPB-aware hosts read when installing a .mcpb bundle — it
    declares the MCP server identity, the launch command, and any user_config
    fields the host should prompt the user for at install time.
    
    Manifest contents are derived entirely from existing per-CLI metadata
    (.printing-press.json + spec auth fields + catalog display_name) so the
    agent can audit it during Phase 4 shipcheck just like it audits SKILL.md
    and README.md. The MCPB bundle ZIP itself is a release-time goreleaser
    artifact (attached to GitHub releases); manifest.json is the source of
    truth committed to the published CLI directory.
    
    Pieces:
    
    - internal/spec/spec.go: APISpec gains DisplayName field plus
      EffectiveDisplayName() helper that returns the explicit value or
      title-cases Name as a fallback. Used wherever a friendly brand
      identity beats a kebab-case slug — Claude Desktop's connector list,
      manifest display_name, the MCP server's protocol-level name.
    - internal/generator/templates/main_mcp.go.tmpl: passes
      EffectiveDisplayName into server.NewMCPServer instead of the
      kebab-case binary name. Connector menus now show "Stripe" or
      "Cal.com" or "PokéAPI" instead of "stripe-pp-mcp".
    - internal/pipeline/climanifest.go: CLIManifest gains DisplayName
      (with catalog/spec fallback chain) and AuthKeyURL fields.
      populateMCPMetadata sets both. WriteManifestForGenerate now also
      writes manifest.json.
    - internal/pipeline/mcpb_manifest.go (new): MCPBManifest types
      matching the upstream v0.3 schema, plus WriteMCPBManifest that
      reads .printing-press.json and emits manifest.json. Skips when
      MCPReady is "cli-only" (composed-auth flows where MCP can't stand
      alone) or when no MCP binary exists. user_config fields are sensitive
      by default and required only when auth_type gates every API call
      (api_key, bearer_token, oauth2); cookie/composed/none keep them
      optional so agents installing a Claude-Desktop-only bundle aren't
      forced to enter credentials they may not have yet.
    - internal/pipeline/publish.go, lock.go: replace writeSmitheryYAML
      calls with WriteMCPBManifest. The smithery.yaml emission was never
      wired up to Smithery's actual registry (which requires per-CLI
      registration on smithery.ai with no Go install path), so the file
      was inert documentation. The MCPB manifest is the path the upstream
      MCP project standardized on (Nov 2025) with growing host adoption.
    - internal/pipeline/publish.go: smitheryConfig types and writeSmitheryYAML
      removed. yaml import dropped (no longer used in this file).
    
    Tests:
    - TestWriteSmitheryYAML replaced with TestWriteMCPBManifest covering
      the same gates (no-manifest, cli-only skip, missing binary skip)
      plus MCPB-specific cases: api_key required, composed optional,
      multi-env-var (company-goat shape with both GITHUB_TOKEN and
      COMPANIES_HOUSE_API_KEY optional), no-auth-no-user-config.
    - testdata/golden/cases/generate-golden-api/artifacts.txt now snapshots
      manifest.json and cmd/<api>-pp-mcp/main.go so the friendly-name flow
      and full manifest shape are locked in as a golden contract.
    
    Out of scope for this commit (will follow):
    - Refactoring the context MCP tool template to emit capability-derived
      bullets instead of marketing prose.
    - Codemod across the public library (manifest backfill, friendly-name
      update for existing CLIs, goreleaser config for .mcpb bundles).
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): MCP context tool delineates MCP-callable vs CLI-only capabilities
    
    The context tool's prior output named hand-written features
    "unique_capabilities" without distinguishing whether each item was
    actually MCP-callable. Claude Desktop users reading this output have
    inferred the MCP could invoke commands like snapshot or funding
    directly, when those are CLI-only — leading to attempts that surface
    404s or "tool not found" instead of clear "use the CLI binary"
    guidance.
    
    Two surface changes in the generated handleContext output:
    
    - Renames "unique_capabilities" → "cli_only_capabilities" so the key
      itself signals the scope. Each entry gets a "via": "cli" field, which
      is machine-readable for agents that filter capabilities by transport.
    - Adds a new "tool_surface" field with one sentence explaining that
      resources/sync/search/sql are MCP-callable while
      cli_only_capabilities require running the companion -pp-cli binary.
      Sits next to tool_count so an agent reading top-down sees the
      boundary before parsing capability lists.
    
    Codified in the golden fixture so future template changes can't
    silently re-conflate the two surfaces.
    
    Out of scope (deliberate): wholesale separation of research-derived
    prose into "positioning" vs "capability" channels. The renaming +
    tool_surface note solves the misinterpretation Claude Desktop made in
    practice; the broader prose-channel split is a larger refactor that
    should follow once we've seen what other prose drift surfaces in real
    agent sessions.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add bundle subcommand and auto-bundle on generate
    
    A printed CLI's manifest.json alone isn't installable — Claude Desktop
    takes a .mcpb ZIP, not a JSON file. Personal-use printers (anyone who
    generates a CLI for themselves without publishing to the public library)
    need a way to package locally without GitHub Actions or hand-rolled bash.
    
    Two surfaces:
    
    1. `printing-press bundle <cli-dir>` — packages an already-generated CLI
       into a .mcpb. Compiles the MCP binary via `go build`, ZIPs with
       manifest.json, writes <dir>/build/<name>-<os>-<arch>.mcpb. Supports
       --platform for cross-compile, --skip-build with --binary for pre-
       built artifacts (so release pipelines that build via goreleaser can
       reuse the packaging step).
    
    2. `printing-press generate` now auto-bundles for the host platform
       after writing manifest.json. Skips silently when go.sum isn't
       populated (e.g. --validate=false leaves the module untidied), so
       personal-use printers get an immediately-installable .mcpb without
       needing to know about the bundle subcommand, while machine flows
       that intentionally skip module wiring don't see noisy warnings.
    
    The `bundle` subcommand exists separately from auto-run so users can:
    - Cross-compile to a non-host platform (--platform linux/amd64)
    - Rebuild after manually editing manifest.json or the MCP binary
    - Wrap a pre-built binary from another build pipeline (--skip-build)
    - Recover from an interrupted generate that wrote the manifest but
      failed mid-build
    
    Pieces:
    
    - internal/pipeline/mcpb_bundle.go: BuildMCPBBundle assembles the
      ZIP from a manifest.json and binary path. Preserves POSIX execute
      bits so macOS/Linux hosts can launch directly. DefaultBundleOutputPath
      conventionally writes to <dir>/build/<name>-<os>-<arch>.mcpb.
    - internal/pipeline/mcpb_bundle_test.go: covers happy path, missing
      manifest skip, empty entry_point rejection, executable bit preservation.
    - internal/cli/bundle.go: cobra command, plus autoBundleForHost helper
      used by generate. resolvePlatform parses <os>/<arch> with a useful
      error rather than silently defaulting on bad input.
    - internal/cli/root.go: registers `bundle`, calls autoBundleForHost
      after both generate code paths (docs-driven and spec-driven).
    
    Bundles are gitignored release outputs (~10MB compressed); only
    manifest.json is the source of truth committed to the repo. CI/release
    pipelines build the cross-platform matrix via goreleaser; the host-only
    auto-build in generate covers personal-use install without requiring CI.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): simplify MCPB manifest emission per /simplify pass
    
    Acts on findings from the post-implementation simplify review:
    
    P1 — correctness/UX
    
    - mcpb_manifest.go: Version was hardcoded "1.0.0", which would defeat
      Claude Desktop's "newer bundle available" detection. Now stamps from
      CLIManifest.PrintingPressVersion (falls back to the linker version
      when the manifest field is empty). golden.sh normalizes the stamped
      version to <PRINTING_PRESS_VERSION> in the manifest.json fixture so
      release bumps don't churn the golden.
    - spec.go: EffectiveDisplayName's hand-rolled `s[:1]` title-case loop
      sliced mid-codepoint on multi-byte runes (would mangle "PokéAPI",
      "Café"). Now calls a new `naming.HumanName` helper that uses
      `cases.Title(language.English)` — same path generator.go's humanName
      template func and proseName() were already using. Both call sites
      collapsed to the new helper, eliminating two duplicate implementations.
    - root.go: autoBundleForHost ran before the "Generated <api> at <path>"
      print, blocking that line behind 5–15s of go build. Moved it after
      the print so users see completion immediately and the bundle output
      follows as a separate "Bundled <path>" line. Same effect on both
      generate code paths (docs-driven and spec-driven).
    
    P2 — cleanup
    
    - mcpb_manifest.go: hoisted "binary", "string", platform list, and the
      authTypeAPIKey/BearerToken/OAuth2 strings to package consts so a
      typo can't silently flip authRequiresCredential's branch.
    - climanifest.go: WriteManifestForGenerate now calls
      WriteMCPBManifestFromStruct (new in-memory variant) instead of
      WriteMCPBManifest, which was reading and re-parsing the JSON we'd
      just written. Saves a syscall and prevents drift if the writer/reader
      paths ever diverge. Old WriteMCPBManifest kept for the standalone
      `bundle` subcommand which has nothing in memory.
    - mcpb_bundle.go: added pipeline.StagedMCPBinaryPath helper so
      internal/cli/bundle.go doesn't reach into pipeline's directory
      layout. Sentinel error in BuildMCPBBundle uses errors.New instead
      of fmt.Errorf with no formatting.
    - bundle.go: trimmed long doc comments on autoBundleForHost. Replaced
      "manifest.json missing — has this CLI been generated yet? %w" (which
      rendered as "...generated yet? open <path>: no such file or directory")
      with "reading manifest.json (run `printing-press generate` first): %w".
    - mcpb_manifest.go: dropped envVarTitle (a pure identity function with
      more comment than code) — the title is just the env var verbatim and
      inlines cleanly. Compressed the SetEscapeHTML and skip-cases comments;
      retained the WHY each gate exists.
    - mcp_tools.go.tmpl: trimmed the 9-line tool_surface preamble comment
      to 2 lines; the emitted tool_surface field text already explains the
      same thing to the agent reading runtime output.
    - spec_test.go: renamed misleading "still triggers promotion path" case
      to "is not promoted" so the name matches what the assertion checks.
    
    Test results unchanged (2330 passing, golden verify clean). Goldens
    regenerated for the manifest comment trim and the version stamp
    normalization.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * fix(cli): bundle observability, streaming binary, claude-desktop version, tests
    
    Acts on PR review P2 findings (deferred #3 go-build consolidation
    — that's an /simplify-and-refactor sweep across 11 call sites and
    out of scope here).
    
    #1 — autoBundleForHost error reporting
    
    Distinguish expected silent skips (no manifest, no go.sum — both mean
    "this CLI doesn't ship a bundle today, that's fine") from real
    failures (malformed manifest, empty manifest.name — both mean a
    partial-write/corruption bug the user wants to hear about).
    Previously every error path was silent, hiding broken state.
    
    #2 — stream binary instead of buffering in RAM
    
    BuildMCPBBundle now opens the MCP binary and io.Copy()s it into the
    zip stream. Old path read the entire binary (~10-15 MB for our
    Go binaries) into a []byte before zipping, doubling peak resident
    memory for no benefit. Added writeZipReader (io.Reader-shaped)
    alongside writeZipBytes so manifestData (small, in memory) can keep
    its existing call site without a wasteful bytes.Buffer round-trip.
    
    #5 — minClaudeDesktopVersion constant
    
    Hoisted ">=1.0.0" out of buildMCPBManifest into a named const so
    the version sits next to the comment that explains why it's that
    value (the MCPB-introducing release of Claude Desktop). Bumping later
    becomes a one-line change instead of a hunt across templates.
    
    #4 — tests for bundle CLI surface
    
    New internal/cli/bundle_test.go covers what the pipeline-level tests
    don't:
    - TestResolvePlatform: empty/host fallback, "<os>/<arch>" parsing,
      malformed-input errors. Pure function, exhaustive.
    - TestAutoBundleForHost: no manifest (silent), malformed manifest
      (loud), empty name (loud), missing go.sum (silent). Pins the
      observability boundary #1 establishes.
    - TestNewBundleCmdMissingManifest: cobra-level error wrapping —
      asserts the user-facing message hints at running `generate` first.
    - TestNewBundleCmdNotADirectory: cobra-level path validation.
    
    12 new test cases total; suite is now 2342 passing (was 2330).
    buildMCPBBinary itself isn't unit-tested — it shells out to `go build`
    and the existing TestBuildMCPBBundle already exercises the
    "binary-on-disk → bundle" half. Integration coverage of the full
    generate→bundle path lives in the golden suite.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/cli/bundle.go                             | 192 +++++++++++++
 internal/cli/bundle_test.go                        | 131 +++++++++
 internal/cli/root.go                               |   3 +
 internal/generator/generator.go                    |   7 +-
 internal/generator/templates/main_mcp.go.tmpl      |   4 +-
 internal/generator/templates/mcp_tools.go.tmpl     |  15 +-
 internal/naming/naming.go                          |  13 +
 internal/pipeline/climanifest.go                   |  61 +++--
 internal/pipeline/climanifest_test.go              | 156 +++++++----
 internal/pipeline/lock.go                          |   7 +-
 internal/pipeline/mcpb_bundle.go                   | 126 +++++++++
 internal/pipeline/mcpb_bundle_test.go              | 148 ++++++++++
 internal/pipeline/mcpb_manifest.go                 | 299 +++++++++++++++++++++
 internal/pipeline/publish.go                       |  83 +-----
 internal/spec/spec.go                              |  40 +++
 internal/spec/spec_test.go                         | 104 +++++++
 scripts/golden.sh                                  |   5 +
 .../golden/cases/generate-golden-api/artifacts.txt |   2 +
 .../expected/generate-golden-api/dogfood.json      |   6 +-
 .../printing-press-golden/.printing-press.json     |   5 +-
 .../printing-press-golden/README.md                |   5 +
 .../printing-press-golden/SKILL.md                 |   3 +
 .../cmd/printing-press-golden-pp-mcp/main.go       |  27 ++
 .../printing-press-golden/internal/cli/root.go     |   1 +
 .../printing-press-golden/internal/mcp/tools.go    |  17 +-
 .../printing-press-golden/manifest.json            |  39 +++
 .../expected/generate-golden-api/scorecard.json    |  12 +-
 testdata/golden/fixtures/golden-api.yaml           |  22 ++
 28 files changed, 1355 insertions(+), 178 deletions(-)

diff --git a/internal/cli/bundle.go b/internal/cli/bundle.go
new file mode 100644
index 00000000..ef128541
--- /dev/null
+++ b/internal/cli/bundle.go
@@ -0,0 +1,192 @@
+package cli
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline"
+	"github.com/spf13/cobra"
+)
+
+func newBundleCmd() *cobra.Command {
+	var output string
+	var platform string
+	var skipBuild bool
+	var binaryPath string
+
+	cmd := &cobra.Command{
+		Use:   "bundle <cli-dir>",
+		Short: "Package a printed CLI's MCP server as a .mcpb bundle",
+		Long: `Package a printed CLI's MCP server as an MCPB v0.3 bundle (.mcpb ZIP)
+suitable for drag-drop install in Claude Desktop, Claude Code, MCP for
+Windows, or any MCPB-aware host.
+
+The CLI directory must contain manifest.json (emitted automatically by
+` + "`printing-press generate`" + `). bundle compiles the MCP binary for the
+target platform via ` + "`go build`" + ` and ZIPs it with the manifest.
+
+Use --platform to cross-compile for a different host. Default is the
+current host (e.g., darwin/arm64). Use --skip-build with --binary to
+package an already-built binary instead of recompiling.
+
+` + "`printing-press generate`" + ` runs this automatically for the host platform
+on each generation; you only need to invoke ` + "`bundle`" + ` directly to
+cross-compile, rebuild after manual edits, or pull a pre-built binary
+from another build pipeline.`,
+		Example: `  printing-press bundle ~/printing-press/library/marketing/dub
+  printing-press bundle ~/printing-press/library/marketing/dub --platform linux/amd64
+  printing-press bundle ./generated/notion --output /tmp/notion.mcpb
+  printing-press bundle ./generated/dub --skip-build --binary ./prebuilt/dub-pp-mcp`,
+		Args: cobra.ExactArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cliDir, err := filepath.Abs(args[0])
+			if err != nil {
+				return fmt.Errorf("resolving cli dir: %w", err)
+			}
+
+			info, err := os.Stat(cliDir)
+			if err != nil || !info.IsDir() {
+				return &ExitError{Code: ExitInputError, Err: fmt.Errorf("%s is not a directory", cliDir)}
+			}
+
+			manifestPath := filepath.Join(cliDir, pipeline.MCPBManifestFilename)
+			manifestData, err := os.ReadFile(manifestPath)
+			if err != nil {
+				return &ExitError{
+					Code: ExitInputError,
+					Err:  fmt.Errorf("reading manifest.json (run `printing-press generate` first): %w", err),
+				}
+			}
+			var manifest pipeline.MCPBManifest
+			if err := json.Unmarshal(manifestData, &manifest); err != nil {
+				return fmt.Errorf("parsing manifest.json: %w", err)
+			}
+			if manifest.Name == "" {
+				return fmt.Errorf("manifest.name is empty; cannot determine binary name")
+			}
+
+			goos, goarch, err := resolvePlatform(platform)
+			if err != nil {
+				return &ExitError{Code: ExitInputError, Err: err}
+			}
+
+			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 output == "" {
+				output = pipeline.DefaultBundleOutputPath(cliDir, manifest.Name, goos, goarch)
+			}
+
+			if err := pipeline.BuildMCPBBundle(pipeline.BundleParams{
+				CLIDir:     cliDir,
+				BinaryPath: binaryPath,
+				OutputPath: output,
+			}); err != nil {
+				return fmt.Errorf("packaging bundle: %w", err)
+			}
+
+			fmt.Fprintf(cmd.OutOrStdout(), "wrote %s\n", output)
+			return nil
+		},
+	}
+
+	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().StringVar(&binaryPath, "binary", "", "Pre-built MCP binary path (only meaningful with --skip-build)")
+	return cmd
+}
+
+// autoBundleForHost packages a host-platform .mcpb after generate.
+// Best-effort: skips silently for expected non-bundle states (no manifest,
+// no go.sum) and warns on real failures (malformed manifest, build/zip
+// errors). Users can always re-run via `printing-press bundle <dir>`.
+func autoBundleForHost(cliDir string, w io.Writer) {
+	manifestPath := filepath.Join(cliDir, pipeline.MCPBManifestFilename)
+	manifestData, err := os.ReadFile(manifestPath)
+	if err != nil {
+		// "No manifest" means generate decided this CLI doesn't ship as
+		// a bundle (cli-only readiness, no MCP). That's expected — silent.
+		return
+	}
+	var manifest pipeline.MCPBManifest
+	if err := json.Unmarshal(manifestData, &manifest); err != nil {
+		// A manifest that exists but doesn't parse is a real problem
+		// the user should know about — corruption, partial write, manual
+		// edit gone wrong. Surface it.
+		fmt.Fprintf(w, "warning: skipping bundle — manifest.json is not valid JSON: %v\n", err)
+		return
+	}
+	if manifest.Name == "" {
+		fmt.Fprintf(w, "warning: skipping bundle — manifest.json has empty name\n")
+		return
+	}
+	// Skip silently when the generated module hasn't run `go mod tidy` yet
+	// (e.g. generate --validate=false). Bundling requires a buildable module;
+	// otherwise the user gets a guaranteed-fail build and a confusing
+	// warning. They can run `printing-press bundle <dir>` after tidying.
+	if _, err := os.Stat(filepath.Join(cliDir, "go.sum")); err != nil {
+		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)
+		return
+	}
+	output := pipeline.DefaultBundleOutputPath(cliDir, manifest.Name, runtime.GOOS, runtime.GOARCH)
+	if err := pipeline.BuildMCPBBundle(pipeline.BundleParams{
+		CLIDir:     cliDir,
+		BinaryPath: binaryPath,
+		OutputPath: output,
+	}); err != nil {
+		fmt.Fprintf(w, "warning: could not package MCPB bundle: %v\n", err)
+		return
+	}
+	fmt.Fprintf(w, "Bundled %s\n", output)
+}
+
+// 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.
+func resolvePlatform(s string) (string, string, error) {
+	if s == "" {
+		return runtime.GOOS, runtime.GOARCH, nil
+	}
+	parts := strings.SplitN(s, "/", 2)
+	if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+		return "", "", fmt.Errorf("--platform must be <os>/<arch>, got %q", s)
+	}
+	return parts[0], parts[1], nil
+}
+
+// buildMCPBBinary invokes `go build` on cmd/<name>/main.go inside cliDir,
+// targeting the requested GOOS/GOARCH and writing to outputPath. We pass
+// -trimpath and -ldflags="-s -w" to match the bundle conventions Claude
+// Desktop's prebuilt examples use; users who need debug builds can
+// --skip-build and pass their own binary.
+func buildMCPBBinary(cliDir, mcpName, outputPath, goos, goarch string) error {
+	if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+		return fmt.Errorf("creating bin dir: %w", err)
+	}
+	pkg := "./cmd/" + mcpName
+	cmd := exec.Command("go", "build", "-trimpath", "-ldflags=-s -w", "-o", outputPath, pkg)
+	cmd.Dir = cliDir
+	cmd.Env = append(os.Environ(), "GOOS="+goos, "GOARCH="+goarch)
+	if out, err := cmd.CombinedOutput(); err != nil {
+		return fmt.Errorf("go build %s: %w\n%s", pkg, err, string(out))
+	}
+	return nil
+}
diff --git a/internal/cli/bundle_test.go b/internal/cli/bundle_test.go
new file mode 100644
index 00000000..00024797
--- /dev/null
+++ b/internal/cli/bundle_test.go
@@ -0,0 +1,131 @@
+package cli
+
+import (
+	"bytes"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestResolvePlatform(t *testing.T) {
+	t.Run("empty falls back to host", func(t *testing.T) {
+		goos, goarch, err := resolvePlatform("")
+		require.NoError(t, err)
+		assert.Equal(t, runtime.GOOS, goos)
+		assert.Equal(t, runtime.GOARCH, goarch)
+	})
+
+	t.Run("valid os/arch parses cleanly", func(t *testing.T) {
+		goos, goarch, err := resolvePlatform("linux/amd64")
+		require.NoError(t, err)
+		assert.Equal(t, "linux", goos)
+		assert.Equal(t, "amd64", goarch)
+	})
+
+	t.Run("missing slash errors with useful message", func(t *testing.T) {
+		_, _, err := resolvePlatform("linux-amd64")
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "<os>/<arch>")
+	})
+
+	t.Run("trailing or leading slash rejected", func(t *testing.T) {
+		_, _, err := resolvePlatform("linux/")
+		require.Error(t, err)
+		_, _, err = resolvePlatform("/amd64")
+		require.Error(t, err)
+	})
+}
+
+func TestAutoBundleForHost(t *testing.T) {
+	t.Run("no manifest is silent", func(t *testing.T) {
+		dir := t.TempDir()
+		var out bytes.Buffer
+		autoBundleForHost(dir, &out)
+		assert.Empty(t, out.String(), "missing manifest should not write any output")
+	})
+
+	t.Run("malformed manifest is loud", func(t *testing.T) {
+		dir := t.TempDir()
+		// Write a manifest.json that exists but is not valid JSON. This is
+		// the failure mode the user actually wants to hear about — silently
+		// skipping would hide a partial-write or corruption bug.
+		require.NoError(t, os.WriteFile(filepath.Join(dir, pipeline.MCPBManifestFilename), []byte("not json"), 0o644))
+
+		var out bytes.Buffer
+		autoBundleForHost(dir, &out)
+		assert.Contains(t, out.String(), "warning")
+		assert.Contains(t, out.String(), "not valid JSON")
+	})
+
+	t.Run("empty manifest name is loud", func(t *testing.T) {
+		dir := t.TempDir()
+		writeBundleManifest(t, dir, pipeline.MCPBManifest{ManifestVersion: pipeline.MCPBManifestVersion})
+
+		var out bytes.Buffer
+		autoBundleForHost(dir, &out)
+		assert.Contains(t, out.String(), "warning")
+		assert.Contains(t, out.String(), "empty name")
+	})
+
+	t.Run("missing go.sum is silent", func(t *testing.T) {
+		// generate --validate=false intentionally leaves go.sum empty;
+		// auto-bundle should not warn about a known-incomplete state.
+		dir := t.TempDir()
+		writeBundleManifest(t, dir, pipeline.MCPBManifest{
+			ManifestVersion: pipeline.MCPBManifestVersion,
+			Name:            "demo-pp-mcp",
+			Server:          pipeline.MCPBServer{Type: "binary", EntryPoint: "bin/demo-pp-mcp"},
+		})
+
+		var out bytes.Buffer
+		autoBundleForHost(dir, &out)
+		assert.Empty(t, out.String(), "missing go.sum should be a silent skip")
+	})
+}
+
+func writeBundleManifest(t *testing.T, dir string, m pipeline.MCPBManifest) {
+	t.Helper()
+	data, err := json.Marshal(m)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(dir, pipeline.MCPBManifestFilename), data, 0o644))
+}
+
+func TestNewBundleCmdMissingManifest(t *testing.T) {
+	dir := t.TempDir()
+	cmd := newBundleCmd()
+	var out bytes.Buffer
+	cmd.SetOut(&out)
+	cmd.SetErr(&out)
+	cmd.SilenceUsage = true
+	cmd.SetArgs([]string{dir})
+
+	err := cmd.Execute()
+	require.Error(t, err, "bundle without manifest.json must fail")
+	assert.True(t, strings.Contains(err.Error(), "manifest.json") ||
+		strings.Contains(err.Error(), "generate"),
+		"error should hint at running generate first; got %q", err.Error())
+}
+
+func TestNewBundleCmdNotADirectory(t *testing.T) {
+	dir := t.TempDir()
+	notADir := filepath.Join(dir, "file.txt")
+	require.NoError(t, os.WriteFile(notADir, []byte("x"), 0o644))
+
+	cmd := newBundleCmd()
+	var out bytes.Buffer
+	cmd.SetOut(&out)
+	cmd.SetErr(&out)
+	cmd.SilenceUsage = true
+	cmd.SetArgs([]string{notADir})
+
+	err := cmd.Execute()
+	require.Error(t, err)
+	assert.Contains(t, err.Error(), "not a directory")
+}
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 4b11bd19..d32e65da 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -63,6 +63,7 @@ func Execute() error {
 	rootCmd.AddCommand(newMCPAuditCmd())
 	rootCmd.AddCommand(newProbeReachabilityCmd())
 	rootCmd.AddCommand(newSchemaCmd())
+	rootCmd.AddCommand(newBundleCmd())
 
 	return rootCmd.Execute()
 }
@@ -163,6 +164,7 @@ func newGenerateCmd() *cobra.Command {
 				}
 
 				fmt.Fprintf(os.Stderr, "Generated %s at %s (from docs)\n", parsed.Name, absOut)
+				autoBundleForHost(absOut, os.Stderr)
 				if asJSON {
 					if err := json.NewEncoder(os.Stdout).Encode(map[string]any{
 						"name":       parsed.Name,
@@ -335,6 +337,7 @@ func newGenerateCmd() *cobra.Command {
 			}
 
 			fmt.Fprintf(os.Stderr, "Generated %s at %s\n", apiSpec.Name, absOut)
+			autoBundleForHost(absOut, os.Stderr)
 			if asJSON {
 				if err := json.NewEncoder(os.Stdout).Encode(map[string]any{
 					"name":       apiSpec.Name,
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 627a0e74..24638ba4 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -229,10 +229,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"isGraphQL": isGraphQLSpec,
 		"backtick":  func() string { return "`" },
 		"kebab":     toKebab,
-		"humanName": func(s string) string {
-			// "steam-web" → "Steam Web", "notion" → "Notion"
-			return cases.Title(language.English).String(strings.ReplaceAll(s, "-", " "))
-		},
+		"humanName": naming.HumanName,
 		"lookupEndpoint": func(resources map[string]spec.Resource, ref string) spec.Endpoint {
 			e, _ := lookupEndpointForTemplate(resources, ref)
 			return e
@@ -713,7 +710,7 @@ func (g *Generator) proseName() string {
 	if g.Narrative != nil && strings.TrimSpace(g.Narrative.DisplayName) != "" {
 		return strings.TrimSpace(g.Narrative.DisplayName)
 	}
-	return cases.Title(language.English).String(strings.ReplaceAll(g.Spec.Name, "-", " "))
+	return naming.HumanName(g.Spec.Name)
 }
 
 func hasAuth(auth spec.AuthConfig) bool {
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index 11af21be..da73b5ae 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -27,7 +27,7 @@ const (
 
 func main() {
 	s := server.NewMCPServer(
-		"{{.Name}}-pp-mcp",
+		"{{.EffectiveDisplayName}}",
 		"1.0.0",
 		server.WithToolCapabilities(false),
 	)
@@ -80,7 +80,7 @@ import (
 
 func main() {
 	s := server.NewMCPServer(
-		"{{.Name}}-pp-mcp",
+		"{{.EffectiveDisplayName}}",
 		"1.0.0",
 		server.WithToolCapabilities(false),
 	)
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index a167575d..4e673760 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -47,7 +47,7 @@ func RegisterTools(s *server.MCPServer) {
 {{- end}}
 {{- end}}
 		),
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if .Positional}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 	)
 {{- end}}
 {{- range $subName, $subResource := $resource.SubResources}}
@@ -65,7 +65,7 @@ func RegisterTools(s *server.MCPServer) {
 {{- end}}
 {{- end}}
 		),
-		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if .Positional}}{{printf "%q" .Name}},{{end}}{{end}} }),
+		makeAPIHandler({{printf "%q" (upper $endpoint.Method)}}, {{printf "%q" $endpoint.Path}}, []string{ {{- range $endpoint.Params}}{{if or .Positional .PathParam}}{{printf "%q" .Name}},{{end}}{{end}} }),
 	)
 {{- end}}
 {{- end}}
@@ -372,6 +372,9 @@ 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.",
 {{- if and .Auth.Type (ne .Auth.Type "none")}}
 		"auth": map[string]any{
 			"type": {{printf "%q" .Auth.Type}},
@@ -408,9 +411,13 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		},
 {{- end}}
 {{- if .NovelFeatures}}
-		"unique_capabilities": []map[string]string{
+		// 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{
 {{- range .NovelFeatures}}
-			{"name": {{printf "%q" .Name}}, "command": {{printf "%q" .Command}}, "description": {{printf "%q" (oneline .Description)}}, "rationale": {{printf "%q" (oneline .Rationale)}}},
+			{"name": {{printf "%q" .Name}}, "command": {{printf "%q" .Command}}, "description": {{printf "%q" (oneline .Description)}}, "rationale": {{printf "%q" (oneline .Rationale)}}, "via": "cli"},
 {{- end}}
 		},
 {{- end}}
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 3d3b0524..6bb5da6a 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -5,6 +5,8 @@ import (
 	"strings"
 	"unicode"
 
+	"golang.org/x/text/cases"
+	"golang.org/x/text/language"
 	"golang.org/x/text/unicode/norm"
 )
 
@@ -30,6 +32,17 @@ func ValidationBinary(name string) string {
 	return CLI(name) + "-validation"
 }
 
+// HumanName turns a kebab-case slug into a space-separated title-cased
+// string ("steam-web" → "Steam Web", "company-goat" → "Company Goat").
+// Multi-byte rune safe via cases.Title; previous hand-rolled callers
+// using `s[:1]` would slice mid-codepoint on accented inputs.
+func HumanName(slug string) string {
+	if slug == "" {
+		return ""
+	}
+	return cases.Title(language.English).String(strings.ReplaceAll(slug, "-", " "))
+}
+
 // 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/climanifest.go b/internal/pipeline/climanifest.go
index b01d38f5..15c1385f 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -33,25 +33,35 @@ type CLIManifest struct {
 	// It is not the executable name, and for collision-renamed published copies
 	// it may differ from the package directory key.
 	APIName string `json:"api_name"`
+	// DisplayName is the human-readable brand name used by user-facing
+	// surfaces that don't want a kebab-case slug — Claude Desktop's
+	// connector list, the MCPB manifest's display_name field, the MCP
+	// server's protocol-level name. Sourced from the spec's display_name
+	// (if set) or a matching catalog entry, with a title-cased fallback.
+	DisplayName string `json:"display_name,omitempty"`
 	// CLIName is the executable/binary name (for example "espn-pp-cli").
 	// It does not track the slug-keyed library directory.
-	CLIName            string                 `json:"cli_name"`
-	SpecURL            string                 `json:"spec_url,omitempty"`
-	SpecPath           string                 `json:"spec_path,omitempty"`
-	SpecFormat         string                 `json:"spec_format,omitempty"`
-	SpecChecksum       string                 `json:"spec_checksum,omitempty"`
-	RunID              string                 `json:"run_id,omitempty"`
-	CatalogEntry       string                 `json:"catalog_entry,omitempty"`
-	Category           string                 `json:"category,omitempty"`
-	Description        string                 `json:"description,omitempty"`
-	MCPBinary          string                 `json:"mcp_binary,omitempty"`
-	MCPToolCount       int                    `json:"mcp_tool_count,omitempty"`
-	MCPPublicToolCount int                    `json:"mcp_public_tool_count,omitempty"`
-	MCPReady           string                 `json:"mcp_ready,omitempty"`
-	APIVersion         string                 `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
-	AuthType           string                 `json:"auth_type,omitempty"`
-	AuthEnvVars        []string               `json:"auth_env_vars,omitempty"`
-	NovelFeatures      []NovelFeatureManifest `json:"novel_features,omitempty"`
+	CLIName            string   `json:"cli_name"`
+	SpecURL            string   `json:"spec_url,omitempty"`
+	SpecPath           string   `json:"spec_path,omitempty"`
+	SpecFormat         string   `json:"spec_format,omitempty"`
+	SpecChecksum       string   `json:"spec_checksum,omitempty"`
+	RunID              string   `json:"run_id,omitempty"`
+	CatalogEntry       string   `json:"catalog_entry,omitempty"`
+	Category           string   `json:"category,omitempty"`
+	Description        string   `json:"description,omitempty"`
+	MCPBinary          string   `json:"mcp_binary,omitempty"`
+	MCPToolCount       int      `json:"mcp_tool_count,omitempty"`
+	MCPPublicToolCount int      `json:"mcp_public_tool_count,omitempty"`
+	MCPReady           string   `json:"mcp_ready,omitempty"`
+	APIVersion         string   `json:"api_version,omitempty"` // from the spec's info.version — provenance only, not the CLI version
+	AuthType           string   `json:"auth_type,omitempty"`
+	AuthEnvVars        []string `json:"auth_env_vars,omitempty"`
+	// AuthKeyURL is the page where users register for an API key. Used by
+	// downstream emitters (MCPB manifest user_config descriptions, doctor
+	// hints) to point users at the right credential source.
+	AuthKeyURL    string                 `json:"auth_key_url,omitempty"`
+	NovelFeatures []NovelFeatureManifest `json:"novel_features,omitempty"`
 }
 
 // NovelFeatureManifest is a compact representation of a transcendence feature
@@ -175,6 +185,10 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
 	m.MCPReady = computeMCPReady(parsed.Auth.Type, public)
 	m.AuthType = parsed.Auth.Type
 	m.AuthEnvVars = parsed.Auth.EnvVars
+	m.AuthKeyURL = parsed.Auth.KeyURL
+	if m.DisplayName == "" {
+		m.DisplayName = parsed.EffectiveDisplayName()
+	}
 }
 
 // GenerateManifestParams holds the information available at generate time
@@ -247,6 +261,12 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 		m.CatalogEntry = entry.Name
 		m.Category = entry.Category
 		m.Description = entry.Description
+		// Catalog's display_name wins over spec/title-case fallback when both
+		// are present. Spec authors and catalog curators sometimes both set
+		// it; the catalog is the curated cross-CLI source of truth.
+		if entry.DisplayName != "" {
+			m.DisplayName = entry.DisplayName
+		}
 	}
 
 	// Record the API version from the spec for provenance (not the CLI version).
@@ -262,7 +282,12 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 		m.NovelFeatures = p.NovelFeatures
 	}
 
-	return WriteCLIManifest(p.OutputDir, m)
+	if err := WriteCLIManifest(p.OutputDir, m); err != nil {
+		return err
+	}
+	// Emit MCPB manifest.json next to .printing-press.json. Pass the
+	// in-memory struct so we don't re-read the file we just wrote.
+	return WriteMCPBManifestFromStruct(p.OutputDir, m)
 }
 
 // detectSpecFormat examines the raw spec bytes and returns a format
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index 145d64c3..ab6a4e63 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v2/internal/version"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"gopkg.in/yaml.v3"
 )
 
 func TestWriteCLIManifest(t *testing.T) {
@@ -567,96 +566,143 @@ func TestComputeMCPReady(t *testing.T) {
 	}
 }
 
-func TestWriteSmitheryYAML(t *testing.T) {
-	t.Run("no manifest file — no smithery written", func(t *testing.T) {
+func TestWriteMCPBManifest(t *testing.T) {
+	t.Run("no manifest file → no MCPB manifest written", func(t *testing.T) {
 		dir := t.TempDir()
-		err := writeSmitheryYAML(dir)
+		err := WriteMCPBManifest(dir)
 		require.NoError(t, err)
-		_, statErr := os.Stat(filepath.Join(dir, "smithery.yaml"))
+		_, statErr := os.Stat(filepath.Join(dir, MCPBManifestFilename))
 		assert.True(t, os.IsNotExist(statErr))
 	})
 
-	t.Run("cli-only readiness — no smithery written", func(t *testing.T) {
+	t.Run("cli-only readiness → skipped (host can't use bundle alone)", func(t *testing.T) {
 		dir := t.TempDir()
 		m := CLIManifest{MCPBinary: "test-pp-mcp", MCPReady: "cli-only"}
-		data, _ := json.Marshal(m)
-		require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
+		writeManifest(t, dir, m)
 
-		err := writeSmitheryYAML(dir)
-		require.NoError(t, err)
-		_, statErr := os.Stat(filepath.Join(dir, "smithery.yaml"))
+		require.NoError(t, WriteMCPBManifest(dir))
+		_, statErr := os.Stat(filepath.Join(dir, MCPBManifestFilename))
 		assert.True(t, os.IsNotExist(statErr))
 	})
 
-	t.Run("api_key auth — env vars required", func(t *testing.T) {
+	t.Run("missing MCP binary → skipped", func(t *testing.T) {
 		dir := t.TempDir()
-		m := CLIManifest{
+		writeManifest(t, dir, CLIManifest{APIName: "no-mcp", MCPReady: "full"})
+
+		require.NoError(t, WriteMCPBManifest(dir))
+		_, statErr := os.Stat(filepath.Join(dir, MCPBManifestFilename))
+		assert.True(t, os.IsNotExist(statErr))
+	})
+
+	t.Run("api_key auth emits required user_config fields", func(t *testing.T) {
+		dir := t.TempDir()
+		writeManifest(t, dir, CLIManifest{
+			APIName:     "stripe",
+			DisplayName: "Stripe",
 			MCPBinary:   "stripe-pp-mcp",
 			MCPReady:    "full",
 			AuthType:    "api_key",
 			AuthEnvVars: []string{"STRIPE_API_KEY"},
-			APIName:     "stripe",
+			AuthKeyURL:  "https://dashboard.stripe.com/apikeys",
 			Description: "Stripe payments API",
-		}
-		data, _ := json.Marshal(m)
-		require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
-
-		err := writeSmitheryYAML(dir)
-		require.NoError(t, err)
+		})
 
-		content, err := os.ReadFile(filepath.Join(dir, "smithery.yaml"))
-		require.NoError(t, err)
-		s := string(content)
-		assert.Contains(t, s, "name: stripe-pp-mcp")
-		assert.Contains(t, s, "description: Stripe payments API")
-		assert.Contains(t, s, "command: go run ./cmd/stripe-pp-mcp")
-		assert.Contains(t, s, "STRIPE_API_KEY")
-		assert.Contains(t, s, "required: true")
+		require.NoError(t, WriteMCPBManifest(dir))
+		got := readMCPBManifest(t, dir)
+
+		assert.Equal(t, MCPBManifestVersion, got.ManifestVersion)
+		assert.Equal(t, "stripe-pp-mcp", got.Name)
+		assert.Equal(t, "Stripe", got.DisplayName)
+		assert.Equal(t, "Stripe payments API", got.Description)
+		assert.Equal(t, "binary", got.Server.Type)
+		assert.Equal(t, "bin/stripe-pp-mcp", got.Server.EntryPoint)
+		assert.Equal(t, "${__dirname}/bin/stripe-pp-mcp", got.Server.MCPConfig.Command)
+		assert.Equal(t, "${user_config.stripe_api_key}", got.Server.MCPConfig.Env["STRIPE_API_KEY"])
+
+		key, ok := got.UserConfig["stripe_api_key"]
+		require.True(t, ok, "user_config must include the env var key")
+		assert.Equal(t, "STRIPE_API_KEY", key.Title)
+		assert.True(t, key.Sensitive)
+		assert.True(t, key.Required, "api_key auth must be required")
+		assert.Contains(t, key.Description, "https://dashboard.stripe.com/apikeys")
 	})
 
-	t.Run("cookie auth — env vars optional", func(t *testing.T) {
+	t.Run("composed auth emits optional user_config fields", func(t *testing.T) {
 		dir := t.TempDir()
-		m := CLIManifest{
+		writeManifest(t, dir, CLIManifest{
+			APIName:     "pizza",
 			MCPBinary:   "pizza-pp-mcp",
 			MCPReady:    "partial",
-			AuthType:    "cookie",
+			AuthType:    "composed",
 			AuthEnvVars: []string{"PIZZA_AUTH"},
-			APIName:     "pizza",
-		}
-		data, _ := json.Marshal(m)
-		require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
+		})
 
-		err := writeSmitheryYAML(dir)
-		require.NoError(t, err)
+		require.NoError(t, WriteMCPBManifest(dir))
+		got := readMCPBManifest(t, dir)
 
-		content, err := os.ReadFile(filepath.Join(dir, "smithery.yaml"))
-		require.NoError(t, err)
-		assert.Contains(t, string(content), "required: false")
+		key, ok := got.UserConfig["pizza_auth"]
+		require.True(t, ok)
+		assert.False(t, key.Required, "composed auth keeps user_config optional")
+		assert.Contains(t, key.Description, "Optional.")
 	})
 
-	t.Run("description with special characters is safely escaped", func(t *testing.T) {
+	t.Run("multiple optional env vars (company-goat shape)", func(t *testing.T) {
 		dir := t.TempDir()
-		m := CLIManifest{
-			MCPBinary:   "test-pp-mcp",
+		writeManifest(t, dir, CLIManifest{
+			APIName:     "company-goat",
+			DisplayName: "Company GOAT",
+			MCPBinary:   "company-goat-pp-mcp",
 			MCPReady:    "full",
-			APIName:     "test",
-			Description: `Notion: "All-in-one" workspace & collaboration`,
+			AuthType:    "none",
+			AuthEnvVars: []string{"GITHUB_TOKEN", "COMPANIES_HOUSE_API_KEY"},
+		})
+
+		require.NoError(t, WriteMCPBManifest(dir))
+		got := readMCPBManifest(t, dir)
+
+		// Both env vars surface as user_config slots; auth_type "none" keeps
+		// them optional even when env vars exist (sub-source credentials).
+		assert.Len(t, got.UserConfig, 2)
+		for _, key := range []string{"github_token", "companies_house_api_key"} {
+			v, ok := got.UserConfig[key]
+			require.True(t, ok, "user_config must include %q", key)
+			assert.False(t, v.Required, "auth_type=none keeps env vars optional")
 		}
-		data, _ := json.Marshal(m)
-		require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
+	})
 
-		err := writeSmitheryYAML(dir)
-		require.NoError(t, err)
+	t.Run("no auth env vars → no user_config or env block", func(t *testing.T) {
+		dir := t.TempDir()
+		writeManifest(t, dir, CLIManifest{
+			APIName:   "espn",
+			MCPBinary: "espn-pp-mcp",
+			MCPReady:  "full",
+			AuthType:  "none",
+		})
 
-		// Verify the file is valid YAML by re-parsing it
-		content, err := os.ReadFile(filepath.Join(dir, "smithery.yaml"))
-		require.NoError(t, err)
-		var parsed map[string]any
-		require.NoError(t, yaml.Unmarshal(content, &parsed), "smithery.yaml should be valid YAML even with special chars in description")
-		assert.Contains(t, parsed["description"], "Notion")
+		require.NoError(t, WriteMCPBManifest(dir))
+		got := readMCPBManifest(t, dir)
+
+		assert.Empty(t, got.UserConfig)
+		assert.Empty(t, got.Server.MCPConfig.Env)
 	})
 }
 
+func writeManifest(t *testing.T, dir string, m CLIManifest) {
+	t.Helper()
+	data, err := json.Marshal(m)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
+}
+
+func readMCPBManifest(t *testing.T, dir string) MCPBManifest {
+	t.Helper()
+	data, err := os.ReadFile(filepath.Join(dir, MCPBManifestFilename))
+	require.NoError(t, err)
+	var got MCPBManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+	return got
+}
+
 func TestDetectSpecFormat(t *testing.T) {
 	tests := []struct {
 		name     string
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 44d9f0ca..f45f9083 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -227,9 +227,10 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
 		return fmt.Errorf("writing CLI manifest: %w", err)
 	}
 
-	// Generate smithery.yaml for MCP marketplace listing if applicable.
-	if err := writeSmitheryYAML(stagingDir); err != nil {
-		fmt.Fprintf(os.Stderr, "warning: could not write smithery.yaml: %v\n", err)
+	// Refresh the MCPB manifest.json in the staging dir so the lock-and-promote
+	// flow keeps it in sync with the post-publish CLIManifest fields.
+	if err := WriteMCPBManifest(stagingDir); err != nil {
+		fmt.Fprintf(os.Stderr, "warning: could not write MCPB manifest.json: %v\n", err)
 	}
 
 	// Remove any stale backup from a prior successful swap before we create a
diff --git a/internal/pipeline/mcpb_bundle.go b/internal/pipeline/mcpb_bundle.go
new file mode 100644
index 00000000..d7d8dbe7
--- /dev/null
+++ b/internal/pipeline/mcpb_bundle.go
@@ -0,0 +1,126 @@
+package pipeline
+
+import (
+	"archive/zip"
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+	"path/filepath"
+	"runtime"
+)
+
+// BundleParams describes one MCPB bundle build. CLIDir must contain a
+// manifest.json (emitted by WriteMCPBManifest) and a built MCP binary at
+// 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.
+type BundleParams struct {
+	CLIDir     string
+	BinaryPath string
+	OutputPath string
+}
+
+// BuildMCPBBundle assembles an MCPB ZIP at OutputPath. The bundle layout is:
+//
+//	manifest.json
+//	bin/<binary>            (the path declared by manifest's server.entry_point)
+//
+// Returns nil and creates no file when manifest.json is missing — the
+// caller's CLI dir is presumably one we don't want to bundle (cli-only
+// readiness, no MCP, etc., the same gates WriteMCPBManifest uses).
+func BuildMCPBBundle(params BundleParams) error {
+	manifestPath := filepath.Join(params.CLIDir, MCPBManifestFilename)
+	manifestData, err := os.ReadFile(manifestPath)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil
+		}
+		return fmt.Errorf("reading manifest: %w", err)
+	}
+
+	var manifest MCPBManifest
+	if err := json.Unmarshal(manifestData, &manifest); err != nil {
+		return fmt.Errorf("parsing manifest: %w", err)
+	}
+	if manifest.Server.EntryPoint == "" {
+		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)
+	}
+
+	out, err := os.Create(params.OutputPath)
+	if err != nil {
+		return fmt.Errorf("creating bundle file: %w", err)
+	}
+	defer func() { _ = out.Close() }()
+
+	zw := zip.NewWriter(out)
+	if err := writeZipBytes(zw, MCPBManifestFilename, manifestData, 0o644); err != nil {
+		_ = 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 {
+		_ = zw.Close()
+		return fmt.Errorf("writing binary into bundle: %w", err)
+	}
+	if err := zw.Close(); err != nil {
+		return fmt.Errorf("finalizing bundle archive: %w", err)
+	}
+	return nil
+}
+
+func writeZipBytes(zw *zip.Writer, name string, data []byte, mode os.FileMode) error {
+	return writeZipReader(zw, name, bytes.NewReader(data), mode)
+}
+
+func writeZipReader(zw *zip.Writer, name string, r io.Reader, mode os.FileMode) error {
+	header := &zip.FileHeader{Name: name, Method: zip.Deflate}
+	header.SetMode(mode)
+	w, err := zw.CreateHeader(header)
+	if err != nil {
+		return err
+	}
+	_, err = io.Copy(w, r)
+	return err
+}
+
+// DefaultBundleOutputPath returns the conventional path the generator and
+// `printing-press bundle` use when no --output is set. Platform suffix in
+// the filename keeps cross-compiled bundles from clobbering each other.
+func DefaultBundleOutputPath(cliDir, mcpBinary, goos, goarch string) string {
+	if goos == "" {
+		goos = runtime.GOOS
+	}
+	if goarch == "" {
+		goarch = runtime.GOARCH
+	}
+	name := fmt.Sprintf("%s-%s-%s.mcpb", mcpBinary, goos, goarch)
+	return filepath.Join(cliDir, "build", name)
+}
+
+// StagedMCPBinaryPath returns the conventional path where bundle's
+// pre-zip staging copies of the MCP binary live (cliDir/build/stage/bin/).
+// Exposed so internal/cli callers don't reach into pipeline internals
+// to construct the path themselves.
+func StagedMCPBinaryPath(cliDir, mcpBinary string) string {
+	return filepath.Join(cliDir, "build", "stage", "bin", mcpBinary)
+}
diff --git a/internal/pipeline/mcpb_bundle_test.go b/internal/pipeline/mcpb_bundle_test.go
new file mode 100644
index 00000000..15b17291
--- /dev/null
+++ b/internal/pipeline/mcpb_bundle_test.go
@@ -0,0 +1,148 @@
+package pipeline
+
+import (
+	"archive/zip"
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestBuildMCPBBundle(t *testing.T) {
+	t.Run("packages manifest and binary into ZIP", func(t *testing.T) {
+		dir := t.TempDir()
+
+		manifest := MCPBManifest{
+			ManifestVersion: MCPBManifestVersion,
+			Name:            "demo-pp-mcp",
+			Version:         "1.0.0",
+			Description:     "demo",
+			Author:          MCPBAuthor{Name: "Test"},
+			Server: MCPBServer{
+				Type:       "binary",
+				EntryPoint: "bin/demo-pp-mcp",
+				MCPConfig: MCPBLaunchSpec{
+					Command: "${__dirname}/bin/demo-pp-mcp",
+					Args:    []string{},
+				},
+			},
+		}
+		mData, _ := json.Marshal(manifest)
+		require.NoError(t, os.WriteFile(filepath.Join(dir, MCPBManifestFilename), mData, 0o644))
+
+		binPath := filepath.Join(dir, "fake-binary")
+		require.NoError(t, os.WriteFile(binPath, []byte("#!/bin/sh\necho fake\n"), 0o755))
+
+		out := filepath.Join(dir, "demo.mcpb")
+		err := BuildMCPBBundle(BundleParams{
+			CLIDir:     dir,
+			BinaryPath: binPath,
+			OutputPath: out,
+		})
+		require.NoError(t, err)
+
+		entries := readZipEntries(t, out)
+		assert.Contains(t, entries, MCPBManifestFilename, "bundle must include manifest.json at root")
+		assert.Contains(t, entries, "bin/demo-pp-mcp", "bundle must place binary at server.entry_point")
+	})
+
+	t.Run("missing manifest skips silently", func(t *testing.T) {
+		dir := t.TempDir()
+		// No manifest.json written; bundle call should no-op.
+		out := filepath.Join(dir, "missing.mcpb")
+		err := BuildMCPBBundle(BundleParams{
+			CLIDir:     dir,
+			BinaryPath: "/nonexistent",
+			OutputPath: out,
+		})
+		require.NoError(t, err)
+
+		_, statErr := os.Stat(out)
+		assert.True(t, os.IsNotExist(statErr), "no bundle should be written")
+	})
+
+	t.Run("manifest with empty entry_point is rejected", func(t *testing.T) {
+		dir := t.TempDir()
+		bad := MCPBManifest{Name: "demo-pp-mcp", Server: MCPBServer{Type: "binary"}}
+		data, _ := json.Marshal(bad)
+		require.NoError(t, os.WriteFile(filepath.Join(dir, MCPBManifestFilename), data, 0o644))
+
+		err := BuildMCPBBundle(BundleParams{
+			CLIDir:     dir,
+			BinaryPath: filepath.Join(dir, "anything"),
+			OutputPath: filepath.Join(dir, "out.mcpb"),
+		})
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "entry_point")
+	})
+
+	t.Run("binary executable bit is preserved", func(t *testing.T) {
+		// Skip on Windows: zip mode bits semantics differ and this is a
+		// macOS/Linux-targeted assertion (those hosts launch the binary
+		// directly, where the +x bit matters).
+		if runtime.GOOS == "windows" {
+			t.Skip("executable bit semantics are POSIX-specific")
+		}
+
+		dir := t.TempDir()
+		manifest := MCPBManifest{
+			ManifestVersion: MCPBManifestVersion,
+			Name:            "demo-pp-mcp",
+			Server: MCPBServer{
+				Type:       "binary",
+				EntryPoint: "bin/demo-pp-mcp",
+				MCPConfig:  MCPBLaunchSpec{Command: "${__dirname}/bin/demo-pp-mcp"},
+			},
+		}
+		mData, _ := json.Marshal(manifest)
+		require.NoError(t, os.WriteFile(filepath.Join(dir, MCPBManifestFilename), mData, 0o644))
+
+		binPath := filepath.Join(dir, "fake-binary")
+		require.NoError(t, os.WriteFile(binPath, []byte("payload"), 0o755))
+
+		out := filepath.Join(dir, "demo.mcpb")
+		require.NoError(t, BuildMCPBBundle(BundleParams{CLIDir: dir, BinaryPath: binPath, OutputPath: out}))
+
+		mode := readZipEntryMode(t, out, "bin/demo-pp-mcp")
+		assert.NotZero(t, mode&0o111, "binary entry must keep at least one execute bit set")
+	})
+}
+
+func TestDefaultBundleOutputPath(t *testing.T) {
+	got := DefaultBundleOutputPath("/tmp/cli", "demo-pp-mcp", "darwin", "arm64")
+	assert.Equal(t, filepath.Join("/tmp/cli", "build", "demo-pp-mcp-darwin-arm64.mcpb"), got)
+
+	hostFallback := DefaultBundleOutputPath("/tmp/cli", "demo-pp-mcp", "", "")
+	assert.True(t, strings.HasPrefix(filepath.Base(hostFallback), "demo-pp-mcp-"+runtime.GOOS+"-"+runtime.GOARCH))
+}
+
+func readZipEntries(t *testing.T, path string) []string {
+	t.Helper()
+	zr, err := zip.OpenReader(path)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = zr.Close() })
+	names := make([]string, 0, len(zr.File))
+	for _, f := range zr.File {
+		names = append(names, f.Name)
+	}
+	return names
+}
+
+func readZipEntryMode(t *testing.T, path, entry string) os.FileMode {
+	t.Helper()
+	zr, err := zip.OpenReader(path)
+	require.NoError(t, err)
+	t.Cleanup(func() { _ = zr.Close() })
+	for _, f := range zr.File {
+		if f.Name == entry {
+			return f.Mode()
+		}
+	}
+	t.Fatalf("entry %q not found in %s", entry, path)
+	return 0
+}
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
new file mode 100644
index 00000000..9cc0ce1b
--- /dev/null
+++ b/internal/pipeline/mcpb_manifest.go
@@ -0,0 +1,299 @@
+package pipeline
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/mvanhorn/cli-printing-press/v2/internal/version"
+)
+
+// MCPB-bundle constants. Promoted from string literals so a typo here can't
+// silently flip semantics — particularly authRequiresCredential, where a
+// renamed auth type would otherwise default to "not required."
+const (
+	mcpbServerTypeBinary = "binary"
+	mcpbVarTypeString    = "string"
+
+	authTypeAPIKey      = "api_key"
+	authTypeBearerToken = "bearer_token"
+	authTypeOAuth2      = "oauth2"
+)
+
+// defaultMCPBPlatforms is the set of host platforms our generated bundles
+// target. Matches goreleaser's default Go cross-compile matrix.
+var defaultMCPBPlatforms = []string{"darwin", "linux", "win32"}
+
+// minClaudeDesktopVersion is the minimum Claude Desktop release that
+// understands the MCPB bundle format we emit. 1.0.0 is the version that
+// introduced MCPB support (Nov 2025); bump this if we adopt schema fields
+// that older Claude Desktop releases reject. Living in one place beats
+// hunting it down across goldens and templates if/when that day comes.
+const minClaudeDesktopVersion = ">=1.0.0"
+
+// MCPBManifestFilename is the file the host (Claude Desktop, Claude Code,
+// MCP for Windows, future MCPB-aware clients) reads when installing a
+// .mcpb bundle. Spec: https://github.com/modelcontextprotocol/mcpb
+const MCPBManifestFilename = "manifest.json"
+
+// MCPBManifestVersion pins the manifest schema version we emit. Bump when
+// the upstream MCPB spec advances and we adopt newer fields.
+const MCPBManifestVersion = "0.3"
+
+// MCPBManifest is the on-disk shape of the manifest.json sitting at the
+// root of an MCPB bundle ZIP. Field names and JSON tags match the upstream
+// schema at https://github.com/modelcontextprotocol/mcpb/blob/main/MANIFEST.md.
+// We do not exhaustively model every optional field — only what the
+// generator can fill from existing spec/catalog metadata. Authors who need
+// niche fields (icons, screenshots, prompts, localization) can hand-edit
+// the emitted manifest.json before bundling, which lives next to the CLI
+// source like .printing-press.json does.
+type MCPBManifest struct {
+	ManifestVersion string             `json:"manifest_version"`
+	Name            string             `json:"name"`
+	DisplayName     string             `json:"display_name,omitempty"`
+	Version         string             `json:"version"`
+	Description     string             `json:"description"`
+	LongDescription string             `json:"long_description,omitempty"`
+	Author          MCPBAuthor         `json:"author"`
+	Repository      *MCPBRepo          `json:"repository,omitempty"`
+	License         string             `json:"license,omitempty"`
+	Keywords        []string           `json:"keywords,omitempty"`
+	Server          MCPBServer         `json:"server"`
+	UserConfig      map[string]MCPBVar `json:"user_config,omitempty"`
+	Compatibility   *MCPBCompat        `json:"compatibility,omitempty"`
+}
+
+// MCPBAuthor identifies the bundle publisher. The upstream schema accepts
+// either a string or this object form; the object form gives Claude Desktop
+// a clickable URL on the install page.
+type MCPBAuthor struct {
+	Name  string `json:"name"`
+	Email string `json:"email,omitempty"`
+	URL   string `json:"url,omitempty"`
+}
+
+// MCPBRepo points the host at the bundle's source for "view repository" links.
+type MCPBRepo struct {
+	Type string `json:"type"`
+	URL  string `json:"url"`
+}
+
+// MCPBServer describes how to launch the server inside the unpacked bundle.
+// For our generated CLIs we always emit type "binary" — Go produces a
+// pre-compiled native executable, no Node/Python runtime needed on the
+// user's machine.
+type MCPBServer struct {
+	Type       string         `json:"type"`
+	EntryPoint string         `json:"entry_point"`
+	MCPConfig  MCPBLaunchSpec `json:"mcp_config"`
+}
+
+// MCPBLaunchSpec is the command/args/env triple the host substitutes at
+// runtime. Use ${__dirname} for paths inside the bundle and
+// ${user_config.<key>} for values the user filled in at install time.
+type MCPBLaunchSpec struct {
+	Command string            `json:"command"`
+	Args    []string          `json:"args"`
+	Env     map[string]string `json:"env,omitempty"`
+}
+
+// MCPBVar is one entry in user_config — a value the host collects from the
+// user during install. Sensitive fields are masked in the input UI and
+// persisted to the OS keychain on hosts that support it.
+type MCPBVar struct {
+	Type        string `json:"type"`
+	Title       string `json:"title"`
+	Description string `json:"description,omitempty"`
+	Sensitive   bool   `json:"sensitive,omitempty"`
+	Required    bool   `json:"required,omitempty"`
+	Default     string `json:"default,omitempty"`
+}
+
+// MCPBCompat declares supported host versions and platforms. We default to
+// claude_desktop >=1.0.0 (the version that introduced MCPB support) and the
+// three desktop platforms goreleaser builds for.
+type MCPBCompat struct {
+	ClaudeDesktop string   `json:"claude_desktop,omitempty"`
+	Platforms     []string `json:"platforms,omitempty"`
+}
+
+// WriteMCPBManifest emits manifest.json for a published CLI directory by
+// reading .printing-press.json. Skips silently when the CLI dir has no
+// manifest, no MCP binary, or cli-only readiness — none of those should
+// produce a draggable .mcpb. Callers that already have the CLIManifest
+// in memory should use WriteMCPBManifestFromStruct to avoid the re-read.
+func WriteMCPBManifest(dir string) error {
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	if err != nil {
+		return nil
+	}
+	var m CLIManifest
+	if err := json.Unmarshal(data, &m); err != nil {
+		return fmt.Errorf("parsing manifest for MCPB: %w", err)
+	}
+	return WriteMCPBManifestFromStruct(dir, m)
+}
+
+// WriteMCPBManifestFromStruct is the in-memory variant of WriteMCPBManifest.
+// Use it when the CLIManifest was just built and writing it back to disk
+// only to re-read it would be wasted work.
+func WriteMCPBManifestFromStruct(dir string, m CLIManifest) error {
+	if m.MCPBinary == "" || m.MCPReady == "cli-only" {
+		return nil
+	}
+	// SetEscapeHTML(false) so `>=1.0.0` stays readable instead of `>=1.0.0`.
+	var buf bytes.Buffer
+	enc := json.NewEncoder(&buf)
+	enc.SetEscapeHTML(false)
+	enc.SetIndent("", "  ")
+	if err := enc.Encode(buildMCPBManifest(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 {
+	displayName := m.DisplayName
+	if displayName == "" {
+		displayName = m.APIName
+	}
+
+	return MCPBManifest{
+		ManifestVersion: MCPBManifestVersion,
+		Name:            m.MCPBinary,
+		DisplayName:     displayName,
+		// 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),
+		Author:      MCPBAuthor{Name: "CLI Printing Press"},
+		License:     "Apache-2.0",
+		Server: MCPBServer{
+			Type:       mcpbServerTypeBinary,
+			EntryPoint: "bin/" + m.MCPBinary,
+			MCPConfig: MCPBLaunchSpec{
+				Command: "${__dirname}/bin/" + m.MCPBinary,
+				Args:    []string{},
+				Env:     buildMCPBEnv(m),
+			},
+		},
+		UserConfig: buildMCPBUserConfig(m),
+		Compatibility: &MCPBCompat{
+			ClaudeDesktop: minClaudeDesktopVersion,
+			Platforms:     defaultMCPBPlatforms,
+		},
+	}
+}
+
+// bundleVersion returns a semver-shaped version for the manifest. Prefers
+// the manifest's recorded printing-press version (so two bundles built
+// from different generator releases differ), falls back to the linker-
+// stamped version when the manifest field is empty (older runs).
+func bundleVersion(m CLIManifest) string {
+	if m.PrintingPressVersion != "" {
+		return m.PrintingPressVersion
+	}
+	if version.Version != "" {
+		return version.Version
+	}
+	return "0.0.0"
+}
+
+// manifestDescription prefers the catalog/spec description verbatim and
+// 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 {
+	if m.Description != "" {
+		return m.Description
+	}
+	return displayName + " API surface as MCP tools."
+}
+
+// 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
+// has cached). Empty list returns nil so the manifest stays compact.
+func buildMCPBEnv(m CLIManifest) map[string]string {
+	if len(m.AuthEnvVars) == 0 {
+		return nil
+	}
+	env := make(map[string]string, len(m.AuthEnvVars))
+	for _, name := range m.AuthEnvVars {
+		env[name] = "${user_config." + userConfigKey(name) + "}"
+	}
+	return env
+}
+
+// buildMCPBUserConfig translates each declared auth env var into a
+// user_config entry. Required-ness depends on auth type: composed/cookie
+// flows mean some tools work unauthenticated, so we keep the field optional
+// and let the user skip it; api_key/bearer_token mean the API needs the
+// credential to do anything useful, so we mark required.
+func buildMCPBUserConfig(m CLIManifest) map[string]MCPBVar {
+	if len(m.AuthEnvVars) == 0 {
+		return nil
+	}
+	required := authRequiresCredential(m.AuthType)
+	vars := make(map[string]MCPBVar, len(m.AuthEnvVars))
+	for _, name := range m.AuthEnvVars {
+		vars[userConfigKey(name)] = MCPBVar{
+			Type:        mcpbVarTypeString,
+			Title:       name,
+			Description: envVarDescription(m, name, required),
+			Sensitive:   true,
+			Required:    required,
+		}
+	}
+	return vars
+}
+
+// userConfigKey lowercases the env var so manifest user_config keys match
+// the `${user_config.foo_bar}` substitution syntax in mcp_config.env.
+func userConfigKey(envVar string) string {
+	return strings.ToLower(envVar)
+}
+
+// envVarDescription is the help text under each user_config field. The
+// registration URL (when we have one) is what makes the difference between
+// "fill this in" and "I don't know where to get this value."
+func envVarDescription(m CLIManifest, envVar string, required bool) string {
+	var b strings.Builder
+	if !required {
+		b.WriteString("Optional. ")
+	}
+	b.WriteString("Sets ")
+	b.WriteString(envVar)
+	b.WriteString(" for the ")
+	if m.DisplayName != "" {
+		b.WriteString(m.DisplayName)
+	} else {
+		b.WriteString(m.APIName)
+	}
+	b.WriteString(" MCP server.")
+	if m.AuthKeyURL != "" {
+		b.WriteString(" Get a credential from ")
+		b.WriteString(m.AuthKeyURL)
+		b.WriteString(".")
+	}
+	return b.String()
+}
+
+// authRequiresCredential decides whether a user_config field is required.
+// api_key/bearer_token/oauth2 gate every API call on the credential.
+// cookie/composed flows have unauth fallbacks for some tools, so we let
+// the user skip and hit the parts that work without credentials.
+func authRequiresCredential(authType string) bool {
+	switch authType {
+	case authTypeAPIKey, authTypeBearerToken, authTypeOAuth2:
+		return true
+	default:
+		return false
+	}
+}
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index ecf4f51a..3ffc001d 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -18,8 +18,6 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v2/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/version"
-
-	"gopkg.in/yaml.v3"
 )
 
 type RunManifest struct {
@@ -116,10 +114,11 @@ func PublishWorkingCLI(state *PipelineState, targetDir string) (string, error) {
 		return "", err
 	}
 
-	// Generate smithery.yaml for MCP marketplace listing if applicable.
-	if err := writeSmitheryYAML(finalDir); err != nil {
-		// Non-blocking: log warning but don't fail the publish.
-		fmt.Fprintf(os.Stderr, "warning: could not write smithery.yaml: %v\n", err)
+	// Refresh the MCPB manifest.json for the final published location.
+	// Generate already wrote one alongside .printing-press.json; rewriting
+	// here picks up any provenance fields the publish step added.
+	if err := WriteMCPBManifest(finalDir); err != nil {
+		fmt.Fprintf(os.Stderr, "warning: could not write MCPB manifest.json: %v\n", err)
 	}
 
 	if err := state.Save(); err != nil {
@@ -434,78 +433,6 @@ func pickNovelFeaturesForManifest(research *ResearchResult) []NovelFeature {
 	return research.NovelFeatures
 }
 
-// smitheryConfig is the marketplace metadata schema for Smithery.
-type smitheryConfig struct {
-	Name         string                    `yaml:"name"`
-	Description  string                    `yaml:"description"`
-	StartCommand smitheryStartCommand      `yaml:"startCommand"`
-	Env          map[string]smitheryEnvVar `yaml:"env,omitempty"`
-}
-
-type smitheryStartCommand struct {
-	Command string `yaml:"command"`
-}
-
-type smitheryEnvVar struct {
-	Description string `yaml:"description"`
-	Required    bool   `yaml:"required"`
-}
-
-// writeSmitheryYAML generates a smithery.yaml marketplace metadata file
-// alongside the CLI manifest. Reads .printing-press.json from dir to get
-// MCP metadata. Skips writing if MCPReady is "cli-only" or if no MCP
-// metadata is present.
-func writeSmitheryYAML(dir string) error {
-	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
-	if err != nil {
-		return nil // no manifest, nothing to do
-	}
-	var m CLIManifest
-	if err := json.Unmarshal(data, &m); err != nil {
-		return fmt.Errorf("parsing manifest for smithery: %w", err)
-	}
-	if m.MCPBinary == "" || m.MCPReady == "cli-only" {
-		return nil // no MCP or cli-only — skip
-	}
-
-	desc := m.Description
-	if desc == "" {
-		desc = m.APIName + " API"
-	}
-
-	cfg := smitheryConfig{
-		Name:        m.MCPBinary,
-		Description: desc,
-		StartCommand: smitheryStartCommand{
-			Command: "go run ./cmd/" + m.MCPBinary,
-		},
-	}
-
-	if len(m.AuthEnvVars) > 0 {
-		cfg.Env = make(map[string]smitheryEnvVar)
-		isCookieAuth := m.AuthType == "cookie" || m.AuthType == "composed"
-		for _, envVar := range m.AuthEnvVars {
-			if isCookieAuth {
-				cfg.Env[envVar] = smitheryEnvVar{
-					Description: "Required for authenticated endpoints only — some tools work without credentials",
-					Required:    false,
-				}
-			} else {
-				cfg.Env[envVar] = smitheryEnvVar{
-					Description: m.APIName + " API credential",
-					Required:    true,
-				}
-			}
-		}
-	}
-
-	out, err := yaml.Marshal(cfg)
-	if err != nil {
-		return fmt.Errorf("marshaling smithery.yaml: %w", err)
-	}
-	return os.WriteFile(filepath.Join(dir, "smithery.yaml"), out, 0o644)
-}
-
 func CopyDir(src, dst string) error {
 	info, err := os.Stat(src)
 	if err != nil {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 118dcf63..73810b04 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -7,6 +7,7 @@ import (
 	"regexp"
 	"strings"
 
+	"github.com/mvanhorn/cli-printing-press/v2/internal/naming"
 	"gopkg.in/yaml.v3"
 )
 
@@ -49,6 +50,15 @@ const DefaultEmbeddedJSONScriptSelector = "script#__NEXT_DATA__"
 
 type APISpec struct {
 	Name string `yaml:"name" json:"name"`
+	// DisplayName is the human-readable brand name used in user-facing
+	// surfaces that aren't a kebab-case slug — Claude Desktop's connector
+	// list, MCPB manifest display_name, the MCP server's protocol-level
+	// name in `server.NewMCPServer(...)`. Authors can set it explicitly
+	// (e.g. "Company GOAT", "Cal.com", "PokéAPI") to preserve unusual
+	// capitalization or punctuation; when empty the generator title-cases
+	// Name as a fallback. The generate command also fills this from a
+	// matching catalog entry's display_name when available.
+	DisplayName string `yaml:"display_name,omitempty" json:"display_name,omitempty"`
 	// Description describes the API itself ("REST API for ordering pizza").
 	// It flows into generated docs and SKILL.md but is intentionally NOT used
 	// as the printed CLI's --help text; that's CLIDescription's job.
@@ -102,6 +112,21 @@ func (s *APISpec) IsSynthetic() bool {
 	return s != nil && s.Kind == KindSynthetic
 }
 
+// EffectiveDisplayName returns the human-readable brand name for this CLI.
+// Explicit DisplayName wins (preserves "Company GOAT", "Cal.com", "PokéAPI"
+// shape); otherwise we title-case Name. Used by the MCP server's protocol
+// name, the MCPB manifest, and any surface that wants a friendly identity
+// instead of the kebab-case slug.
+func (s *APISpec) EffectiveDisplayName() string {
+	if s == nil {
+		return ""
+	}
+	if strings.TrimSpace(s.DisplayName) != "" {
+		return s.DisplayName
+	}
+	return naming.HumanName(s.Name)
+}
+
 func (s *APISpec) EffectiveHTTPTransport() string {
 	if s == nil {
 		return HTTPTransportStandard
@@ -701,6 +726,21 @@ func enrichEndpointPathParams(e *Endpoint) {
 		}
 		seen[name] = struct{}{}
 		if _, exists := declared[name]; exists {
+			// The path template wins over how the author declared the param.
+			// A placeholder like {cik} in /submissions/CIK{cik}.json is a path
+			// substitution regardless of whether the author wrote location:query
+			// or omitted location entirely. Promote the existing param so URL
+			// substitution and MCP positionalParams emission see it as such.
+			// Use PathParam=true (not Positional=true) to preserve the author's
+			// CLI-rendering intent — a param can be a flag that also fills a
+			// path slot (e.g. pagination, dates).
+			for i := range e.Params {
+				if e.Params[i].Name == name {
+					e.Params[i].PathParam = true
+					e.Params[i].Required = true
+					break
+				}
+			}
 			continue
 		}
 		e.Params = append(e.Params, Param{
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 65258b02..05bd8227 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -612,6 +612,110 @@ resources:
 	assert.Contains(t, items.Endpoints, "fallback")
 }
 
+func TestEnrichEndpointPathParams(t *testing.T) {
+	t.Run("placeholder not declared adds positional param", func(t *testing.T) {
+		input := `
+name: demo
+base_url: http://x
+auth: {type: none}
+resources:
+  filings:
+    description: SEC filings
+    endpoints:
+      get:
+        method: GET
+        path: /submissions/CIK{cik}.json
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		params := s.Resources["filings"].Endpoints["get"].Params
+		require.Len(t, params, 1)
+		assert.Equal(t, "cik", params[0].Name)
+		assert.True(t, params[0].Positional, "auto-injected placeholder should be positional")
+		assert.True(t, params[0].Required, "path placeholder must be required")
+		assert.False(t, params[0].PathParam, "auto-injected uses Positional, not PathParam")
+	})
+
+	t.Run("placeholder declared as flag is promoted to PathParam", func(t *testing.T) {
+		// Reproduces the company-goat bug: spec author declares a param without
+		// location:path (or with location:query) while the path template uses
+		// {name} as a substitution. Path template wins; existing param must be
+		// promoted to PathParam=true so URL substitution and MCP positionalParams
+		// emission see it.
+		input := `
+name: demo
+base_url: http://x
+auth: {type: none}
+resources:
+  filings:
+    description: SEC filings
+    endpoints:
+      get:
+        method: GET
+        path: /submissions/CIK{cik}.json
+        params:
+          - name: cik
+            type: string
+            description: 10-digit zero-padded SEC Central Index Key
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		params := s.Resources["filings"].Endpoints["get"].Params
+		require.Len(t, params, 1, "should not duplicate the existing param")
+		assert.Equal(t, "cik", params[0].Name)
+		assert.True(t, params[0].PathParam, "declared param matching {placeholder} must be promoted to PathParam=true")
+		assert.True(t, params[0].Required, "path placeholder must be required")
+		assert.Equal(t, "10-digit zero-padded SEC Central Index Key", params[0].Description, "author description preserved")
+	})
+
+	t.Run("repeated placeholder is enriched once", func(t *testing.T) {
+		input := `
+name: demo
+base_url: http://x
+auth: {type: none}
+resources:
+  duplicates:
+    description: weird API
+    endpoints:
+      twice:
+        method: GET
+        path: /a/{x}/b/{x}
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		params := s.Resources["duplicates"].Endpoints["twice"].Params
+		require.Len(t, params, 1)
+		assert.Equal(t, "x", params[0].Name)
+	})
+
+	t.Run("body param with same name as placeholder is not promoted", func(t *testing.T) {
+		// When the author declared "id" in body AND the path has {id},
+		// the body declaration is authoritative — we don't add a phantom
+		// path param. Pins this behavior so the promotion path doesn't
+		// accidentally widen.
+		input := `
+name: demo
+base_url: http://x
+auth: {type: none}
+resources:
+  things:
+    description: things
+    endpoints:
+      update:
+        method: PATCH
+        path: /things/{id}
+        body:
+          - name: id
+            type: string
+`
+		s, err := ParseBytes([]byte(input))
+		require.NoError(t, err)
+		params := s.Resources["things"].Endpoints["update"].Params
+		// id is in body, not promoted in params — pinning current behavior.
+		assert.Empty(t, params, "params should remain empty when placeholder is declared in body")
+	})
+}
+
 func TestExtraCommandsParse(t *testing.T) {
 	input := `
 name: demo
diff --git a/scripts/golden.sh b/scripts/golden.sh
index a1826783..00493ec8 100755
--- a/scripts/golden.sh
+++ b/scripts/golden.sh
@@ -46,6 +46,11 @@ normalize_json() {
         | .printing_press_version = "<PRINTING_PRESS_VERSION>"
       ' "$file" | normalize_text
       ;;
+    */manifest.json)
+      # MCPB manifest version stamps the printing-press release; normalize
+      # so the golden doesn't drift on every release bump.
+      jq -S '.version = "<PRINTING_PRESS_VERSION>"' "$file" | normalize_text
+      ;;
     *)
       jq -S . "$file" | normalize_text
       ;;
diff --git a/testdata/golden/cases/generate-golden-api/artifacts.txt b/testdata/golden/cases/generate-golden-api/artifacts.txt
index 35129396..18e834f6 100644
--- a/testdata/golden/cases/generate-golden-api/artifacts.txt
+++ b/testdata/golden/cases/generate-golden-api/artifacts.txt
@@ -1,4 +1,5 @@
 printing-press-golden/.printing-press.json
+printing-press-golden/manifest.json
 printing-press-golden/go.mod
 printing-press-golden/cmd/printing-press-golden-pp-cli/main.go
 printing-press-golden/internal/client/client.go
@@ -13,6 +14,7 @@ printing-press-golden/internal/cli/promoted_public.go
 printing-press-golden/internal/cliutil/text.go
 printing-press-golden/internal/config/config.go
 printing-press-golden/internal/mcp/tools.go
+printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
 printing-press-golden/internal/types/types.go
 dogfood.json
 scorecard.json
diff --git a/testdata/golden/expected/generate-golden-api/dogfood.json b/testdata/golden/expected/generate-golden-api/dogfood.json
index c44370b5..68688ab2 100644
--- a/testdata/golden/expected/generate-golden-api/dogfood.json
+++ b/testdata/golden/expected/generate-golden-api/dogfood.json
@@ -30,7 +30,7 @@
     "example check skipped: could not build CLI binary: exit status 1"
   ],
   "naming_check": {
-    "checked": 27
+    "checked": 30
   },
   "novel_features_check": {
     "found": 0,
@@ -59,8 +59,8 @@
   "verdict": "WARN",
   "wiring_check": {
     "command_tree": {
-      "defined": 35,
-      "registered": 35
+      "defined": 38,
+      "registered": 38
     },
     "config_consistency": {
       "consistent": true
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json b/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json
index 79dd3895..e41458e5 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.json
@@ -6,14 +6,15 @@
   ],
   "auth_type": "api_key",
   "cli_name": "printing-press-golden-pp-cli",
+  "display_name": "Printing Press Golden",
   "generated_at": "<GENERATED_AT>",
   "mcp_binary": "printing-press-golden-pp-mcp",
   "mcp_public_tool_count": 1,
   "mcp_ready": "full",
-  "mcp_tool_count": 6,
+  "mcp_tool_count": 7,
   "printing_press_version": "<PRINTING_PRESS_VERSION>",
   "schema_version": 1,
-  "spec_checksum": "sha256:afe8312a5660273a91a5ae54b4b7402a9c993b12ebf3b969fd63522f25d68836",
+  "spec_checksum": "sha256:eef12554186abf8e871a9a5bb166d23555d219de4c9b0513c1d482ecfbe34e0f",
   "spec_format": "openapi3",
   "spec_path": "testdata/golden/fixtures/golden-api.yaml",
   "spec_url": "file://testdata/golden/fixtures/golden-api.yaml"
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
index 58992484..84404786 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/README.md
@@ -64,6 +64,11 @@ Manage public
 
 - **`printing-press-golden-pp-cli public get-status`** - Get public service status
 
+### reports
+
+Manage reports
+
+
 
 ## Output Formats
 
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
index f247cdc3..e81d8edf 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.md
@@ -22,6 +22,9 @@ Purpose-built fixture for golden generation coverage.
 
 - `printing-press-golden-pp-cli public` — Get public service status
 
+**reports** — Manage reports
+
+
 
 ### Finding the right command
 
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
new file mode 100644
index 00000000..d54751b5
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.go
@@ -0,0 +1,27 @@
+// 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 main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/mark3labs/mcp-go/server"
+	mcptools "printing-press-golden-pp-cli/internal/mcp"
+)
+
+func main() {
+	s := server.NewMCPServer(
+		"Printing Press Golden",
+		"1.0.0",
+		server.WithToolCapabilities(false),
+	)
+
+	mcptools.RegisterTools(s)
+
+	if err := server.ServeStdio(s); err != nil {
+		fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err)
+		os.Exit(1)
+	}
+}
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 af791954..ba23a6ae 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
@@ -136,6 +136,7 @@ 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(newAgentContextCmd(rootCmd))
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 8513a7c5..a6c910fb 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
@@ -68,6 +68,13 @@ func RegisterTools(s *server.MCPServer) {
 		),
 		makeAPIHandler("GET", "/public/status", []string{ }),
 	)
+	s.AddTool(
+		mcplib.NewTool("reports_summary_get-report-year",
+			mcplib.WithDescription("Get a report summary for a year"),
+			mcplib.WithString("year", mcplib.Required(), mcplib.Description("Year")),
+		),
+		makeAPIHandler("GET", "/reports/{year}/summary", []string{"year", }),
+	)
 	// Sync tool — populates local database for offline search and sql queries
 	s.AddTool(
 		mcplib.NewTool("sync",
@@ -276,7 +283,10 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 		"api":         "printing-press-golden",
 		"description": "Purpose-built fixture for golden generation coverage.",
 		"archetype":   "project-management",
-		"tool_count":  6,
+		"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.",
 		"auth": map[string]any{
 			"type": "api_key",
 			"env_vars": []string{"PRINTING_PRESS_GOLDEN_API_KEY_AUTH",  },
@@ -294,6 +304,11 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo
 				"description": "Manage public",
 				"endpoints": []string{"get-status",  },
 			},
+			{
+				"name": "reports",
+				"description": "Manage reports",
+				"endpoints": []string{ },
+			},
 		},
 		"query_tips": []string{
 			"Pagination uses cursor-based paging. Pass cursor parameter for subsequent pages.",
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
new file mode 100644
index 00000000..962b0f42
--- /dev/null
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/manifest.json
@@ -0,0 +1,39 @@
+{
+  "author": {
+    "name": "CLI Printing Press"
+  },
+  "compatibility": {
+    "claude_desktop": ">=1.0.0",
+    "platforms": [
+      "darwin",
+      "linux",
+      "win32"
+    ]
+  },
+  "description": "Printing Press Golden API surface as MCP tools.",
+  "display_name": "Printing Press Golden",
+  "license": "Apache-2.0",
+  "manifest_version": "0.3",
+  "name": "printing-press-golden-pp-mcp",
+  "server": {
+    "entry_point": "bin/printing-press-golden-pp-mcp",
+    "mcp_config": {
+      "args": [],
+      "command": "${__dirname}/bin/printing-press-golden-pp-mcp",
+      "env": {
+        "PRINTING_PRESS_GOLDEN_API_KEY_AUTH": "${user_config.printing_press_golden_api_key_auth}"
+      }
+    },
+    "type": "binary"
+  },
+  "user_config": {
+    "printing_press_golden_api_key_auth": {
+      "description": "Sets PRINTING_PRESS_GOLDEN_API_KEY_AUTH for the Printing Press Golden MCP server.",
+      "required": true,
+      "sensitive": true,
+      "title": "PRINTING_PRESS_GOLDEN_API_KEY_AUTH",
+      "type": "string"
+    }
+  },
+  "version": "<PRINTING_PRESS_VERSION>"
+}
diff --git a/testdata/golden/expected/generate-golden-api/scorecard.json b/testdata/golden/expected/generate-golden-api/scorecard.json
index a2e3ad8c..9fd0dddd 100644
--- a/testdata/golden/expected/generate-golden-api/scorecard.json
+++ b/testdata/golden/expected/generate-golden-api/scorecard.json
@@ -3,8 +3,7 @@
     "api_name": "printing-press-golden",
     "competitor_scores": null,
     "gap_report": [
-      "breadth scored 4/10 - needs improvement",
-      "MCP: 6 tools (1 public, 5 auth-required) — readiness: full"
+      "MCP: 7 tools (1 public, 6 auth-required) — readiness: full"
     ],
     "overall_grade": "A",
     "steinberger": {
@@ -12,7 +11,7 @@
       "agent_workflow_readiness": 9,
       "auth": 10,
       "auth_protocol": 0,
-      "breadth": 4,
+      "breadth": 5,
       "cache_freshness": 5,
       "data_pipeline_integrity": 7,
       "dead_code": 5,
@@ -25,20 +24,19 @@
       "mcp_remote_transport": 5,
       "mcp_surface_strategy": 0,
       "mcp_token_efficiency": 7,
-      "mcp_tool_design": 0,
+      "mcp_tool_design": 5,
       "output_modes": 10,
       "path_validity": 0,
-      "percentage": 82,
+      "percentage": 81,
       "readme": 8,
       "sync_correctness": 10,
       "terminal_ux": 8,
-      "total": 82,
+      "total": 81,
       "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/golden-api.yaml b/testdata/golden/fixtures/golden-api.yaml
index 79864a06..da3caa95 100644
--- a/testdata/golden/fixtures/golden-api.yaml
+++ b/testdata/golden/fixtures/golden-api.yaml
@@ -222,3 +222,25 @@ paths:
       responses:
         "200":
           description: OK
+  # Exercises the path-param reclassification path: `year` is in:path, but the
+  # OpenAPI parser reclassifies date/year/month-named params to PathParam=true,
+  # Positional=false (rendered as a flag, but still substituted in the URL).
+  # This regression-tests that the MCP template emits such params into
+  # positionalParams via the .Positional || .PathParam check.
+  /reports/{year}/summary:
+    get:
+      tags: [reports]
+      operationId: getReportYearSummary
+      summary: Get a report summary for a year
+      parameters:
+        - $ref: "#/components/parameters/ApiVersion"
+        - name: year
+          in: path
+          required: true
+          schema:
+            type: integer
+            minimum: 2000
+            maximum: 2100
+      responses:
+        "200":
+          description: OK

← ff70c7eb chore(main): release 2.4.0 (#330)  ·  back to Cli Printing Press  ·  feat(cli): emit MCP tools for novel CLI features via shell-o 7f248d59 →