← 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
A internal/cli/bundle.goA internal/cli/bundle_test.goM internal/cli/root.goM internal/generator/generator.goM internal/generator/templates/main_mcp.go.tmplM internal/generator/templates/mcp_tools.go.tmplM internal/naming/naming.goM internal/pipeline/climanifest.goM internal/pipeline/climanifest_test.goM internal/pipeline/lock.goA internal/pipeline/mcpb_bundle.goA internal/pipeline/mcpb_bundle_test.goA internal/pipeline/mcpb_manifest.goM internal/pipeline/publish.goM internal/spec/spec.goM internal/spec/spec_test.goM scripts/golden.shM testdata/golden/cases/generate-golden-api/artifacts.txtM testdata/golden/expected/generate-golden-api/dogfood.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/.printing-press.jsonM testdata/golden/expected/generate-golden-api/printing-press-golden/README.mdM testdata/golden/expected/generate-golden-api/printing-press-golden/SKILL.mdA testdata/golden/expected/generate-golden-api/printing-press-golden/cmd/printing-press-golden-pp-mcp/main.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/root.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/mcp/tools.goA testdata/golden/expected/generate-golden-api/printing-press-golden/manifest.jsonM testdata/golden/expected/generate-golden-api/scorecard.jsonM testdata/golden/fixtures/golden-api.yaml
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 →