[object Object]

← back to Cli Printing Press

feat(cli): MCP readiness layer — per-endpoint auth awareness and metadata (#145)

51afd77877ca1d2e07f8eb56bc831ebf74d62a0c · 2026-04-06 12:52:47 -0700 · Trevin Chow

* feat(cli): plan MCP readiness layer with per-endpoint auth awareness

Add implementation plan for surfacing MCP metadata in the public
library registry and annotating MCP tool descriptions with auth
requirements. Covers OpenAPI parser changes, manifest extensions,
smithery.yaml generation, and existing CLI fixups across both the
generator repo and printing-press-library.

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

* feat(cli): refine MCP readiness plan from document review findings

Address 14 findings from 5-persona document review:
- Reframe problem: fli is aspirational analogy, not direct precedent
- Add global security: [] detection path alongside per-op detection
- Fix Unit 6: target publish.go not cli/publish.go, enumerate 3 callers
- Enumerate all 7 MCP rename template touch points in Unit 4
- Scorecard reads manifest instead of re-parsing full spec
- Prepend [No auth] instead of appending (survives truncation)
- Add cli-only to registry schema, scope docs-sourced CLI limitation
- Fix Auth.Inferred guard explanation to match parser semantics
- Add dual-interface positioning to library README (Unit 10)
- Explicit Unit 8/9 sequencing dependency
- VisionSet.MCP split guard acknowledged in Unit 3

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

* feat(cli): address re-review findings — annotation format, caller count, test gaps

- Standardize annotation format to [No auth] throughout (was mixed
  with "(no auth required)" in goal statements and test scenarios)
- Fix Unit 6: 2 callers not 3 (pipeline calls PromoteWorkingCLI)
- Add Unit 7 test scenario for missing manifest (standalone scorecard)
- Add README.md to Unit 9 per-CLI file list (known-affected)
- Remove stale generator.go line numbers, use pattern descriptions
- Add Unit 9 as explicit dependency for Unit 8

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

* feat(cli): flip annotation logic, add about tool, note future work

Three updates from design discussion:

1. Annotation flip: annotate the minority auth side only. If all
   tools share the same auth status, no annotations. Mixed CLIs
   annotate whichever side is smaller with auth-type-specific tags
   (requires API key/auth/browser login).

2. Add Unit 3b: 'about' tool on every MCP server. Returns API
   identity, auth requirements, tool counts, and transcendence
   features (novel_features_built). Agents call this to discover
   what makes the CLI special at runtime.

3. Future Considerations: mega MCP (library as single aggregated
   MCP server) and library directory reorganization (api-slug
   directories replacing cli-named directories).

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

* feat(cli): frame plan as Step 1 of 2, define mega MCP contract

Restructure the plan to make the relationship with the follow-up
Mega MCP plan explicit:

- Overview reframed as Step 1 of 2 toward a library-wide MCP server
- Future Considerations replaced with detailed "Contract with
  Follow-Up Plan" section defining what the mega MCP consumes
- Lock-in table: 6 design decisions this plan makes that the mega
  MCP depends on (registry schema, NoAuth, MCPReady, AuthEnvVars,
  naming, about tool shape)
- Add novel_features to CLIManifest (Unit 4) so mega MCP's
  library_info can surface transcendence features

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

* fix(cli): fix caller count, doc parameter note, makefile occurrence count

Auto-fixes from review pass 3:
- Unit 6: "three callers" → "two callers" (only 2 call sites exist)
- Unit 2 Path 2: remove misleading "(needs doc passed in)" — doc is
  already mapResources' first parameter
- Unit 4: makefile.tmpl has 3 string occurrences across 2 lines, not 2

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

* feat(cli): add per-endpoint NoAuth detection and MCP README section

- Add NoAuth bool to spec.Endpoint for per-endpoint auth awareness
- Parse security:[] per-operation in OpenAPI parser (3 detection paths:
  per-op explicit, global security:[], post-parse sweep for no-auth specs)
- Add mixed-auth.yaml test fixture and 4 NoAuth detection tests
- Add "Use as MCP Server" section to README template with auth-type
  conditional install instructions for Claude Code and Claude Desktop

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

* feat(cli): add MCP manifest metadata, naming.MCP(), binary rename to -pp-mcp

- Add naming.MCP() returning name+"-pp-mcp" with MCPSuffix constant
- Add 7 MCP fields to CLIManifest: binary, tool counts, readiness,
  auth type, env vars, novel features
- Add countMCPTools() and computeMCPReady() helpers
- Thread Spec through GenerateManifestParams to both callers in root.go
- Replace inline "-mcp" with naming.MCP() in generator.go (2 sites)
- Rename all template occurrences: goreleaser (4), makefile (3),
  main_mcp (1) from {name}-mcp to {name}-pp-mcp
- Update publish skill SKILL.md with MCP registry fields schema

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

* feat(cli): add minority-side auth annotations and about tool to MCP servers

- Register mcpDescription() template function with minority-side logic:
  annotates only when mixed auth, marks whichever side is smaller
  ([No auth] prefix for public minority, auth-type suffix for auth minority)
- Update both WithDescription calls in mcp_tools.go.tmpl (top-level
  and sub-resource endpoints) to use mcpDescription
- Add MCPTotalCount/MCPPublicCount to MCP template data struct
- Add about tool to every MCP server: returns API identity, auth
  requirements, tool counts, and transcendence features (novel_features)

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

* feat(cli): generate smithery.yaml at publish time, add MCP line to scorecard

- writeSmitheryYAML() generates marketplace metadata from .printing-press.json
  manifest at both publish call sites (publish.go, lock.go). Skips for
  cli-only readiness. Non-blocking on failure.
- Scorecard reads manifest MCPToolCount/MCPPublicToolCount and appends
  informational "MCP: N tools (N public, N auth-required)" to gap report.
  Handles missing manifest gracefully.

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

* fix(cli): populate MCP manifest fields in publish path, fix orphaned doc comment

Review fixes from ce:review maintainability pass:

- writeCLIManifestForPublish now parses spec.json to populate MCPBinary,
  MCPToolCount, MCPPublicToolCount, MCPReady, AuthType, AuthEnvVars.
  Without this, smithery.yaml was silently skipped for pipeline-published
  CLIs because MCPBinary was always empty.
- Load novel_features_built from research.json at publish time.
- Fix orphaned doc comment: GenerateManifestParams comment was attached
  to CountMCPToolsFromSpec instead of the struct.

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

* test(cli): add naming.MCP() test

Review fix from testing pass: verify MCP() returns expected
{name}-pp-mcp suffix for standard and hyphenated API names.

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

* fix(cli): guard EnvVars index in README MCP section to prevent template panic

Review fix from correctness pass: the api_key branch of the MCP
install section used {{index .Auth.EnvVars 0}} without checking
that EnvVars is non-empty. Now guards with {{if and (eq .Auth.Type
"api_key") .Auth.EnvVars}} to match the Claude Desktop block pattern.

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

* fix(cli): address P1/P2 review findings — tests, dedup, escaping, tie-breaking

Review fixes:
- Move countMCPTools to spec.APISpec.CountMCPTools() method, eliminating
  duplication between generator.go and climanifest.go (P2)
- Fix mcpDescription tie-breaking: exact 50/50 split now produces no
  annotations on either side (was annotating public side on ties) (P2)
- Replace smithery.yaml string concatenation with yaml.Marshal via typed
  structs, fixing YAML injection from special chars in descriptions (P2)
- Add table-driven tests for mcpDescription (10 cases including tie,
  all auth types, oneline cleanup) (P1)
- Add table-driven tests for computeMCPReady (9 cases including oauth2
  default, cookie/composed partial/cli-only) (P1)
- Add tests for writeSmitheryYAML (5 cases: no manifest, cli-only skip,
  api_key required env, cookie optional env, special char escaping) (P1)
- Add test for spec.CountMCPTools with sub-resources (P2)

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

* feat(cli): change public annotation from [No auth] prefix to (public) suffix

All annotations are now appended suffixes for consistency:
- Public minority: "Find store locations (public)"
- Auth minority: "Place an order (requires API key)"
- Tie or uniform: no annotation

Description-first approach — the tool's purpose is always the first
thing an agent reads, with the auth qualifier as a trailing hint.

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

* fix(cli): fix publish-time MCP metadata for non-OpenAPI specs, smithery path, bearer README

Fixes from PR review:
- Carry forward MCP metadata from generated manifest before re-parsing,
  so non-OpenAPI specs (GraphQL, internal) don't lose MCP fields at
  publish time. Re-parse with format-appropriate parser when possible.
- Extract populateMCPMetadata() helper shared by generate and publish paths
- Change smithery.yaml startCommand from "./<binary>" to
  "go run ./cmd/<binary>" since library ships source, not binaries
- Split bearer_token README into three cases: with env vars (pass -e),
  without env vars (auth set-token), separate from oauth2 (auth login)
- Add regression tests for internal-spec publish and bearer_token README

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

* feat(cli): add MCP binary awareness to CLI rename pipeline

RenameCLI now also renames MCP references alongside CLI references:
- Derives old/new MCP names from CLI names via naming.MCP()
- Replaces MCP binary name in all source files (goreleaser, Makefile,
  README, import paths)
- Updates MCPBinary field in .printing-press.json manifest
- Renames cmd/{old-mcp}/ directory to cmd/{new-mcp}/
- Adds MCP-aware test fixtures and assertions to rename tests

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 51afd77877ca1d2e07f8eb56bc831ebf74d62a0c
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Mon Apr 6 12:52:47 2026 -0700

    feat(cli): MCP readiness layer — per-endpoint auth awareness and metadata (#145)
    
    * feat(cli): plan MCP readiness layer with per-endpoint auth awareness
    
    Add implementation plan for surfacing MCP metadata in the public
    library registry and annotating MCP tool descriptions with auth
    requirements. Covers OpenAPI parser changes, manifest extensions,
    smithery.yaml generation, and existing CLI fixups across both the
    generator repo and printing-press-library.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): refine MCP readiness plan from document review findings
    
    Address 14 findings from 5-persona document review:
    - Reframe problem: fli is aspirational analogy, not direct precedent
    - Add global security: [] detection path alongside per-op detection
    - Fix Unit 6: target publish.go not cli/publish.go, enumerate 3 callers
    - Enumerate all 7 MCP rename template touch points in Unit 4
    - Scorecard reads manifest instead of re-parsing full spec
    - Prepend [No auth] instead of appending (survives truncation)
    - Add cli-only to registry schema, scope docs-sourced CLI limitation
    - Fix Auth.Inferred guard explanation to match parser semantics
    - Add dual-interface positioning to library README (Unit 10)
    - Explicit Unit 8/9 sequencing dependency
    - VisionSet.MCP split guard acknowledged in Unit 3
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): address re-review findings — annotation format, caller count, test gaps
    
    - Standardize annotation format to [No auth] throughout (was mixed
      with "(no auth required)" in goal statements and test scenarios)
    - Fix Unit 6: 2 callers not 3 (pipeline calls PromoteWorkingCLI)
    - Add Unit 7 test scenario for missing manifest (standalone scorecard)
    - Add README.md to Unit 9 per-CLI file list (known-affected)
    - Remove stale generator.go line numbers, use pattern descriptions
    - Add Unit 9 as explicit dependency for Unit 8
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): flip annotation logic, add about tool, note future work
    
    Three updates from design discussion:
    
    1. Annotation flip: annotate the minority auth side only. If all
       tools share the same auth status, no annotations. Mixed CLIs
       annotate whichever side is smaller with auth-type-specific tags
       (requires API key/auth/browser login).
    
    2. Add Unit 3b: 'about' tool on every MCP server. Returns API
       identity, auth requirements, tool counts, and transcendence
       features (novel_features_built). Agents call this to discover
       what makes the CLI special at runtime.
    
    3. Future Considerations: mega MCP (library as single aggregated
       MCP server) and library directory reorganization (api-slug
       directories replacing cli-named directories).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): frame plan as Step 1 of 2, define mega MCP contract
    
    Restructure the plan to make the relationship with the follow-up
    Mega MCP plan explicit:
    
    - Overview reframed as Step 1 of 2 toward a library-wide MCP server
    - Future Considerations replaced with detailed "Contract with
      Follow-Up Plan" section defining what the mega MCP consumes
    - Lock-in table: 6 design decisions this plan makes that the mega
      MCP depends on (registry schema, NoAuth, MCPReady, AuthEnvVars,
      naming, about tool shape)
    - Add novel_features to CLIManifest (Unit 4) so mega MCP's
      library_info can surface transcendence features
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): fix caller count, doc parameter note, makefile occurrence count
    
    Auto-fixes from review pass 3:
    - Unit 6: "three callers" → "two callers" (only 2 call sites exist)
    - Unit 2 Path 2: remove misleading "(needs doc passed in)" — doc is
      already mapResources' first parameter
    - Unit 4: makefile.tmpl has 3 string occurrences across 2 lines, not 2
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add per-endpoint NoAuth detection and MCP README section
    
    - Add NoAuth bool to spec.Endpoint for per-endpoint auth awareness
    - Parse security:[] per-operation in OpenAPI parser (3 detection paths:
      per-op explicit, global security:[], post-parse sweep for no-auth specs)
    - Add mixed-auth.yaml test fixture and 4 NoAuth detection tests
    - Add "Use as MCP Server" section to README template with auth-type
      conditional install instructions for Claude Code and Claude Desktop
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add MCP manifest metadata, naming.MCP(), binary rename to -pp-mcp
    
    - Add naming.MCP() returning name+"-pp-mcp" with MCPSuffix constant
    - Add 7 MCP fields to CLIManifest: binary, tool counts, readiness,
      auth type, env vars, novel features
    - Add countMCPTools() and computeMCPReady() helpers
    - Thread Spec through GenerateManifestParams to both callers in root.go
    - Replace inline "-mcp" with naming.MCP() in generator.go (2 sites)
    - Rename all template occurrences: goreleaser (4), makefile (3),
      main_mcp (1) from {name}-mcp to {name}-pp-mcp
    - Update publish skill SKILL.md with MCP registry fields schema
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add minority-side auth annotations and about tool to MCP servers
    
    - Register mcpDescription() template function with minority-side logic:
      annotates only when mixed auth, marks whichever side is smaller
      ([No auth] prefix for public minority, auth-type suffix for auth minority)
    - Update both WithDescription calls in mcp_tools.go.tmpl (top-level
      and sub-resource endpoints) to use mcpDescription
    - Add MCPTotalCount/MCPPublicCount to MCP template data struct
    - Add about tool to every MCP server: returns API identity, auth
      requirements, tool counts, and transcendence features (novel_features)
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): generate smithery.yaml at publish time, add MCP line to scorecard
    
    - writeSmitheryYAML() generates marketplace metadata from .printing-press.json
      manifest at both publish call sites (publish.go, lock.go). Skips for
      cli-only readiness. Non-blocking on failure.
    - Scorecard reads manifest MCPToolCount/MCPPublicToolCount and appends
      informational "MCP: N tools (N public, N auth-required)" to gap report.
      Handles missing manifest gracefully.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): populate MCP manifest fields in publish path, fix orphaned doc comment
    
    Review fixes from ce:review maintainability pass:
    
    - writeCLIManifestForPublish now parses spec.json to populate MCPBinary,
      MCPToolCount, MCPPublicToolCount, MCPReady, AuthType, AuthEnvVars.
      Without this, smithery.yaml was silently skipped for pipeline-published
      CLIs because MCPBinary was always empty.
    - Load novel_features_built from research.json at publish time.
    - Fix orphaned doc comment: GenerateManifestParams comment was attached
      to CountMCPToolsFromSpec instead of the struct.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * test(cli): add naming.MCP() test
    
    Review fix from testing pass: verify MCP() returns expected
    {name}-pp-mcp suffix for standard and hyphenated API names.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): guard EnvVars index in README MCP section to prevent template panic
    
    Review fix from correctness pass: the api_key branch of the MCP
    install section used {{index .Auth.EnvVars 0}} without checking
    that EnvVars is non-empty. Now guards with {{if and (eq .Auth.Type
    "api_key") .Auth.EnvVars}} to match the Claude Desktop block pattern.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): address P1/P2 review findings — tests, dedup, escaping, tie-breaking
    
    Review fixes:
    - Move countMCPTools to spec.APISpec.CountMCPTools() method, eliminating
      duplication between generator.go and climanifest.go (P2)
    - Fix mcpDescription tie-breaking: exact 50/50 split now produces no
      annotations on either side (was annotating public side on ties) (P2)
    - Replace smithery.yaml string concatenation with yaml.Marshal via typed
      structs, fixing YAML injection from special chars in descriptions (P2)
    - Add table-driven tests for mcpDescription (10 cases including tie,
      all auth types, oneline cleanup) (P1)
    - Add table-driven tests for computeMCPReady (9 cases including oauth2
      default, cookie/composed partial/cli-only) (P1)
    - Add tests for writeSmitheryYAML (5 cases: no manifest, cli-only skip,
      api_key required env, cookie optional env, special char escaping) (P1)
    - Add test for spec.CountMCPTools with sub-resources (P2)
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): change public annotation from [No auth] prefix to (public) suffix
    
    All annotations are now appended suffixes for consistency:
    - Public minority: "Find store locations (public)"
    - Auth minority: "Place an order (requires API key)"
    - Tie or uniform: no annotation
    
    Description-first approach — the tool's purpose is always the first
    thing an agent reads, with the auth qualifier as a trailing hint.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): fix publish-time MCP metadata for non-OpenAPI specs, smithery path, bearer README
    
    Fixes from PR review:
    - Carry forward MCP metadata from generated manifest before re-parsing,
      so non-OpenAPI specs (GraphQL, internal) don't lose MCP fields at
      publish time. Re-parse with format-appropriate parser when possible.
    - Extract populateMCPMetadata() helper shared by generate and publish paths
    - Change smithery.yaml startCommand from "./<binary>" to
      "go run ./cmd/<binary>" since library ships source, not binaries
    - Split bearer_token README into three cases: with env vars (pass -e),
      without env vars (auth set-token), separate from oauth2 (auth login)
    - Add regression tests for internal-spec publish and bearer_token README
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * feat(cli): add MCP binary awareness to CLI rename pipeline
    
    RenameCLI now also renames MCP references alongside CLI references:
    - Derives old/new MCP names from CLI names via naming.MCP()
    - Replaces MCP binary name in all source files (goreleaser, Makefile,
      README, import paths)
    - Updates MCPBinary field in .printing-press.json manifest
    - Renames cmd/{old-mcp}/ directory to cmd/{new-mcp}/
    - Adds MCP-aware test fixtures and assertions to rename tests
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 ...2026-04-05-001-feat-mcp-readiness-layer-plan.md | 650 +++++++++++++++++++++
 internal/cli/root.go                               |   2 +
 internal/generator/generator.go                    |  48 +-
 internal/generator/generator_test.go               | 124 ++++
 internal/generator/templates/goreleaser.yaml.tmpl  |   8 +-
 internal/generator/templates/main_mcp.go.tmpl      |   2 +-
 internal/generator/templates/makefile.tmpl         |   4 +-
 internal/generator/templates/mcp_tools.go.tmpl     |  41 +-
 internal/generator/templates/readme.md.tmpl        |  63 ++
 internal/naming/naming.go                          |   5 +
 internal/naming/naming_test.go                     |  13 +
 internal/openapi/parser.go                         |  59 ++
 internal/openapi/parser_test.go                    | 168 +++++-
 internal/pipeline/climanifest.go                   |  92 ++-
 internal/pipeline/climanifest_test.go              | 195 +++++++
 internal/pipeline/lock.go                          |   5 +
 internal/pipeline/publish.go                       | 127 ++++
 internal/pipeline/renamecli.go                     |  15 +
 internal/pipeline/renamecli_test.go                |  43 +-
 internal/pipeline/scorecard.go                     |  23 +
 internal/spec/spec.go                              |  23 +
 internal/spec/spec_test.go                         |  93 +++
 skills/printing-press-publish/SKILL.md             |  22 +-
 testdata/openapi/mixed-auth.yaml                   |  59 ++
 24 files changed, 1849 insertions(+), 35 deletions(-)

diff --git a/docs/plans/2026-04-05-001-feat-mcp-readiness-layer-plan.md b/docs/plans/2026-04-05-001-feat-mcp-readiness-layer-plan.md
new file mode 100644
index 00000000..c5fa5722
--- /dev/null
+++ b/docs/plans/2026-04-05-001-feat-mcp-readiness-layer-plan.md
@@ -0,0 +1,650 @@
+---
+title: "feat: MCP Readiness Layer — Per-Endpoint Auth Awareness and Public Library MCP Catalog"
+type: feat
+status: active
+date: 2026-04-05
+---
+
+# feat: MCP Readiness Layer — Per-Endpoint Auth Awareness and Public Library MCP Catalog
+
+## Overview
+
+**Step 1 of 2** toward a Printing Press Mega MCP — a single installable MCP server that gives agents access to every API in the library.
+
+This plan builds the data layer: per-endpoint auth awareness, MCP metadata in manifests and registry, self-describing `about` tools, marketplace metadata files, and minority-side auth annotations. Every artifact is designed to be consumed by the Mega MCP (Step 2, separate plan) which aggregates all CLIs into one server with catalog discovery and passthrough execution.
+
+Without this plan, the Mega MCP has no data to work with — no way to know which tools need auth, what env vars to check, or which APIs are MCP-ready. Without the Mega MCP, this plan's metadata describes things users can't easily access. Both steps are needed; this one goes first because the data layer must be correct before the access layer is built on top.
+
+This plan covers three layers: machine changes in this repo (generator, parser, templates, manifest, publish), public library repo changes (registry schema, README), and targeted fixups to the 6 existing published CLIs (without regeneration).
+
+**Target repos:**
+- **Primary:** `cli-printing-press` (this repo) — all machine changes
+- **Secondary:** `mvanhorn/printing-press-library` — registry, README, and existing CLI fixups
+
+## Problem Frame
+
+Every printed CLI already ships an MCP binary — but without metadata, those binaries are invisible. MCP tool descriptions don't indicate auth requirements, the public library registry has no MCP fields, and marketplace listing metadata doesn't exist. This is table-stakes infrastructure: any tool that ships an MCP binary should be self-describing about what it can do and what credentials it needs.
+
+The `fli` project (Google Flights CLI, 1,400 stars, 12K PyPI downloads/month) is an aspirational analogy, not a direct precedent — fli succeeded with a single high-demand API (Google Flights), while the printing press library covers niche APIs (Pagliacci Pizza, ESPN, Postman Explore). Whether marketplace presence drives adoption for *these* specific CLIs is unvalidated. The infrastructure is worth building regardless: it makes every MCP binary self-describing, gives agents auth hints before the first 401, and positions the library for when high-demand APIs are added.
+
+Even cookie-auth CLIs like Pagliacci Pizza have public endpoints (store finder, menus) that work as MCP tools without any auth. But today, the MCP tool descriptions don't say this, the registry doesn't surface it, and users have no way to know which tools work out of the box.
+
+## Requirements Trace
+
+- R1. Per-endpoint `NoAuth` detection from OpenAPI specs (`security: []` override)
+- R2. MCP tool descriptions annotate the **minority** auth side — only when a CLI has a mix of public and auth-required tools. If all tools share the same auth status, no annotations (the default speaks for itself).
+- R3. CLI manifest extended with MCP metadata (binary name, tool counts, readiness level)
+- R4. README template includes MCP server install section
+- R5. Publish pipeline generates `smithery.yaml` marketplace metadata
+- R6. Public library `registry.json` extended with MCP fields
+- R7. Existing 6 published CLIs updated with auth annotations (no regeneration)
+- R8. Scorecard reports public/auth tool split (informational, not new scored dimension)
+
+## Scope Boundaries
+
+- **In scope:** OpenAPI-sourced specs only. Sniffed/internal specs get `NoAuth` support later.
+- **In scope:** Informational scorecard reporting of public tool counts. NOT a new scored dimension (avoids tier constant changes).
+- **Out of scope:** Auto-submission to MCP marketplaces. We generate metadata files; humans decide whether to list.
+- **Out of scope:** MCP OAuth2 native auth (MCP spec auth RFC). Worth monitoring; not building against today.
+- **Out of scope:** Auto-refresh of cookies from MCP context. Separate feature.
+- **Out of scope:** Changes to the MCP server binary structure or transport (stdio is correct).
+- **Known limitation:** CLIs generated via `--docs` (no spec.json produced) will not have MCP metadata in the manifest or registry until a spec.json is produced. MCP fields are left empty — the publish skill treats an empty `MCPBinary` as "omit the `mcp` block from registry.json."
+- **Registry includes all readiness levels:** CLIs with `mcp_ready: "cli-only"` still appear in the registry with an `mcp` block — the block signals "MCP binary exists but all tools need CLI auth setup." Smithery.yaml is NOT generated for cli-only CLIs (no useful MCP without browser setup).
+
+## Context & Research
+
+### Relevant Code and Patterns
+
+- **Scorecard already parses per-operation security:** `parseSecurityRequirementSet()` at `internal/pipeline/scorecard.go:1113-1157` correctly handles `security: []` (empty array → `AllowsAnonymous = true`) and `security: [{}]` (empty object → anonymous). This is reference code for the parser change.
+- **OpenAPI parser endpoint construction:** `mapResources()` at `internal/openapi/parser.go:800-851` iterates operations via `pathItem.Operations()`. Each `op` is `*openapi3.Operation` with `Security *SecurityRequirements` field. Currently ignored.
+- **kin-openapi types:** `SecurityRequirements` is `[]SecurityRequirement`. When spec says `security: []`, kin-openapi sets `op.Security` to non-nil pointer to empty slice. When no per-operation security is declared, `op.Security` is nil (inherits global).
+- **Template conditionals:** Existing pattern `{{- if and .Auth.Type (ne .Auth.Type "none")}}` in `mcp_tools.go.tmpl` shows how auth-conditional rendering works.
+- **Endpoint.Meta:** Exists as `map[string]string` on Endpoint but is only used by crowd-sniff for source provenance. A dedicated `NoAuth bool` field is cleaner and matches the pattern of `Required`, `Positional` on `Param`.
+- **CLIManifest:** Schema version 1, clean struct at `internal/pipeline/climanifest.go:27-41`. Adding fields with `omitempty` is backward-compatible.
+- **Publish skill registry schema:** Documented in `skills/printing-press-publish/SKILL.md` lines 564-579.
+
+### Institutional Learnings
+
+- **Scorecard update is mandatory when adding capabilities** (AGENTS.md + `docs/solutions/logic-errors/scorecard-accuracy-broadened-pattern-matching`). For this feature, we add informational reporting only — no new scored dimension — so tier constants stay unchanged.
+- **Traversal protection for spec-derived strings** (`docs/solutions/security-issues/filepath-join-traversal-with-user-input`). MCP binary names derived from API slugs flow through existing `naming.CLI()` which is already safe. Smithery.yaml values are written to known paths, not user-derived paths.
+- **Validation must not mutate source directory** (`docs/solutions/best-practices/validation-must-not-mutate-source-directory`). The smithery.yaml is written during `publish package` (which copies to staging), not during validation.
+- **Layout contract** (`docs/solutions/best-practices/checkout-scoped-printing-press-output-layout`). MCP metadata extends `.printing-press.json` (the provenance manifest), not a separate file. Smithery.yaml is a new file alongside it in the published CLI directory.
+
+## Key Technical Decisions
+
+- **Dedicated `NoAuth bool` field over `Meta["no_auth"]`:** Type-safe, template-friendly (`{{if .NoAuth}}`), matches existing boolean field patterns on `Param`. Serialized as `no_auth,omitempty` to stay backward-compatible with existing YAML specs.
+- **Detection logic: explicit override only, not inheritance:** Only set `NoAuth = true` when `op.Security` is non-nil AND empty. When `op.Security` is nil, the operation inherits global auth — which could be anything. This avoids false positives where a spec has no global security but individual operations aren't explicitly marked.
+- **Post-parse sweep for no-auth specs, guarded by `!Auth.Inferred`:** When `Auth.Type == "none"` AND `!Auth.Inferred`, mark all endpoints `NoAuth = true`. The `Inferred` field is set to `true` by the parser when auth was guessed from description keywords (e.g., "Bearer" found in description text). In practice, the keyword-inference paths set a non-none type when they find a match, so `Type == "none" && Inferred == true` is unlikely. The `!Inferred` guard acts as a safety catch — verify during implementation whether this combination can actually occur and add a concrete test if so.
+- **MCP binary naming: `{name}-pp-mcp` with `-pp-` infix:** Consistent with the CLI's `{name}-pp-cli` collision avoidance rationale. If the infix was worth doing for CLIs, it's worth doing for MCPs — especially since MCP marketplaces are where vendor collisions are most likely. The generator currently produces `{name}-mcp` (no infix) and all 6 published CLIs use that convention. This plan renames to `{name}-pp-mcp` everywhere: generator template, naming function, published CLI directories, goreleaser configs, smithery listings, and registry entries. The rename is cheapest now — before any marketplace listings exist and before users have wired `claude mcp add` configs.
+- **`naming.MCP()` function:** Add to `internal/naming/naming.go` returning `name + "-pp-mcp"`. Update the generator at `generator.go:441` to use this function instead of the current inline `name + "-mcp"`. Centralizes the convention and applies the `-pp-` infix consistently.
+- **Minority-side annotation logic:** Only annotate when a CLI has a mix of public and auth-required tools. Annotate whichever side is the minority:
+  - All tools same auth status → no annotations (default speaks for itself)
+  - Mixed, public is minority → append `(public)` on public tools
+  - Mixed, auth-required is minority → append auth-type-specific suffix on auth-required tools: `(requires API key)`, `(requires auth)`, or `(requires browser login)`
+  The `mcpDescription` template function handles this: it takes the endpoint description, `NoAuth` flag, the spec-level auth type, and the total/public tool counts. It decides whether and how to annotate based on the minority logic, then applies oneline cleanup/truncation. Prepending/appending is chosen so the annotation is always visible after truncation.
+- **`GenerateManifestParams` needs the parsed spec:** `WriteManifestForGenerate()` currently takes only `APIName`, `SpecSrcs`, `DocsURL`, `OutputDir` — no access to the parsed `*spec.APISpec` for tool counting. Add a `Spec *spec.APISpec` field to `GenerateManifestParams` and thread it through both callers in `internal/cli/root.go` (lines 189 and 331). Both callers already have the parsed spec in scope (`parsed` and `apiSpec` respectively). For `writeCLIManifestForPublish()`, which operates on `PipelineState`, re-parse the spec from the output directory's `spec.json` — the file is always present at publish time.
+- **`AuthEnvVars` in manifest:** Add `AuthEnvVars []string` to `CLIManifest` alongside `AuthType`. Without this, the publish skill cannot populate `env_vars` in the registry.json `mcp` block — it would have to parse CLI source code. The manifest is the right place to carry this data from generation to publish.
+- **Informational scorecard reporting, not a new dimension:** Adding a scored MCP dimension would require tier constant changes, unscored handling for non-MCP CLIs, and broader calibration. Instead, the scorecard's summary output gets a one-line note: "MCP: 42 tools (15 public, 27 auth-required)". This satisfies the AGENTS.md rule (scorer reflects capability) without inflating scores.
+- **Scorecard reads MCP counts from manifest, not by re-parsing:** The scorecard reads `.printing-press.json` (which already exists when the scorecard runs) and uses `MCPToolCount` / `MCPPublicToolCount` from Unit 4. This avoids calling `openapi.Parse()` from the scorecard, which would be disproportionate — adding a full-spec-parse dependency and latency (especially for large specs like Steam's 164 tools) for an informational one-line summary.
+- **Smithery.yaml generated at publish time, not generate time:** The smithery file needs category, description, and auth metadata that may be enriched during publish. It lives alongside `.printing-press.json` in the published directory.
+- **Registry.json `mcp` block is additive:** New fields are optional. Existing registry entries without `mcp` remain valid. The publish skill adds `mcp` when packaging.
+- **Smithery.yaml `env` semantics for partial-readiness CLIs:** For cookie/composed CLIs, env vars are marked `required: false` with a description noting "required for authenticated endpoints only — some tools work without credentials." This accurately reflects MCP server startup behavior (server starts fine without cookies) while signaling that not all tools will work.
+
+## Open Questions
+
+### Resolved During Planning
+
+- **Should `NoAuth` go on Endpoint or AuthConfig?** → Endpoint. Auth config is spec-level. NoAuth is per-endpoint. These are different granularities.
+- **Should the OpenAPI parser also detect when the global security array is empty?** → Yes, handled via post-parse sweep. Guarded by `Auth.Type == "none" && !Auth.Inferred` to avoid false positives from specs that simply omit security declarations.
+- **What about operations that have security but also allow anonymous (`security: [{}, {"api_key": []}]`)?** → Set `NoAuth = true`. If anonymous is one of the alternatives, the tool works without auth. The agent can always provide auth for better results.
+- **How does `WriteManifestForGenerate` get spec data for tool counts?** → Add `Spec *spec.APISpec` to `GenerateManifestParams`. Both callers in `root.go` already have the parsed spec in scope.
+- **Should MCP binaries use `-pp-` infix?** → Yes. `{name}-pp-mcp` everywhere — binary, marketplace listing, registry, naming function. Consistent with CLI's `{name}-pp-cli` collision avoidance. Cheapest to rename now before any marketplace listings exist. The 6 published CLIs get renamed in the same library repo PR as the auth annotations (Unit 9).
+- **How does the scorecard access NoAuth data?** → Read the `.printing-press.json` manifest which already has `MCPToolCount` and `MCPPublicToolCount` from Unit 4. Avoids calling the full parser from the scorecard.
+- **Where do env var names come from in the registry?** → Add `AuthEnvVars []string` to `CLIManifest`, populated from `spec.Auth.EnvVars` at generation time. Publish skill reads from manifest.
+- **How to handle `oneline()` truncation with auth annotation?** → `mcpDescription()` template function applies the minority-side annotation (prepend or append), then calls `oneline()` internally. Annotation is part of the truncation input.
+- **Should we annotate all public tools or all auth-required tools?** → Neither uniformly. Annotate the minority side only, and only when the CLI has a mix. If all tools share the same auth status, no annotations — the default speaks for itself. Auth-type-specific suffixes (`(requires API key)`, `(requires browser login)`) tell the agent what action is needed, not just that auth is needed.
+
+### Deferred to Implementation
+
+- **Smithery.yaml field names** — verify against current Smithery docs (https://smithery.ai/docs) at the start of Unit 6 implementation. If the schema has changed from the assumed `name`/`description`/`startCommand`/`env` structure, adjust before writing the generator code.
+- **Which Pagliacci/Steam endpoints are actually public** — determined during Unit 9 by making unauthenticated HTTP requests to each candidate endpoint. If 200 with meaningful data → public. If 401/403 → auth-required. Results documented in the PR.
+
+## Implementation Units
+
+### This Repo (cli-printing-press)
+
+- [ ] **Unit 1: Add `NoAuth` field to Endpoint spec**
+
+  **Goal:** Extend the spec model so endpoints can declare whether they require auth.
+
+  **Requirements:** R1
+
+  **Dependencies:** None
+
+  **Files:**
+  - Modify: `internal/spec/spec.go`
+  - Modify: `internal/spec/spec_test.go`
+
+  **Approach:**
+  Add `NoAuth bool` to `Endpoint` struct with `yaml:"no_auth,omitempty" json:"no_auth,omitempty"` tags. Place it after `Meta` to keep auth-related fields grouped. Ensure the `Validate()` method doesn't reject it.
+
+  **Patterns to follow:**
+  - `Param.Required`, `Param.Positional` — same boolean-with-omitempty pattern
+  - `Endpoint.Meta` — neighboring field in the struct
+
+  **Test scenarios:**
+  - Happy path: Endpoint with `NoAuth: true` round-trips through YAML marshal/unmarshal
+  - Happy path: Endpoint with `NoAuth: false` (or unset) omits the field in JSON/YAML output
+  - Edge case: Existing spec fixtures that don't have `no_auth` still parse without error (backward compat)
+
+  **Verification:** `go test ./internal/spec/...` passes. Existing parser tests unaffected.
+
+- [ ] **Unit 2: Parse per-operation `security: []` in OpenAPI parser**
+
+  **Goal:** Detect when an OpenAPI operation explicitly opts out of auth and set `NoAuth = true` on the corresponding endpoint.
+
+  **Requirements:** R1
+
+  **Dependencies:** Unit 1
+
+  **Files:**
+  - Modify: `internal/openapi/parser.go` (in `mapResources()`, ~line 839-845)
+  - Modify: `internal/openapi/parser_test.go`
+  - Create: `testdata/openapi/mixed-auth.yaml` (test fixture)
+
+  **Approach:**
+  Two detection paths:
+
+  **Path 1 — Per-operation detection (in `mapResources()`):**
+  After constructing each `endpoint` struct (line 839), check `op.Security`:
+  - If `op.Security != nil && len(*op.Security) == 0` → `endpoint.NoAuth = true` (explicit `security: []`)
+  - If `op.Security != nil` and any element is an empty map → `endpoint.NoAuth = true` (anonymous alternative)
+  - If `op.Security == nil` → leave `NoAuth` as false (inherits global)
+
+  **Path 2 — Global security detection (in `mapResources()`, `doc` already available as first parameter):**
+  Also check the global `security` array on the OpenAPI document. When `doc.Security` is non-nil but empty (`security: []` at root level), ALL operations without their own per-operation security inherit anonymous access. For each endpoint where `op.Security == nil` and global security is explicitly empty, set `endpoint.NoAuth = true`. This handles specs that define securitySchemes (for optional use) but declare `security: []` globally.
+
+  **Path 3 — Post-parse sweep:**
+  After all resources are built, if `out.Auth.Type == "none"`, iterate all endpoints and set `NoAuth = true`. The `Auth.Inferred` field is set to `true` by the parser when auth was guessed from description keywords (e.g., "Bearer" mentioned in description). The combination `Auth.Type == "none" && Auth.Inferred == true` is unlikely in practice because the keyword-inference paths set a non-none type when they find a match. The `!Inferred` guard acts as a safety catch — verify during implementation whether this case can actually occur, and add a concrete test if so.
+
+  Reference `parseSecurityRequirementSet()` in `scorecard.go:1113-1157` for the anonymous-detection logic.
+
+  **Patterns to follow:**
+  - `scorecard.go` `parseSecurityRequirementSet()` — same security array interpretation
+  - Existing `mapResources()` patterns for setting endpoint fields
+
+  **Test scenarios:**
+  - Happy path: Operation with `security: []` → `endpoint.NoAuth == true`
+  - Happy path: Operation with `security: [{}]` (empty object alternative) → `endpoint.NoAuth == true`
+  - Happy path: Operation with `security: [{"api_key": []}]` (normal auth) → `endpoint.NoAuth == false`
+  - Happy path: Operation with no per-operation security (nil) inheriting global auth → `endpoint.NoAuth == false`
+  - Happy path: Spec with global `security: []` (root level) → operations without own security get `NoAuth == true`
+  - Happy path: Spec with no securitySchemes and no global security (`Auth.Type == "none"`, `Inferred: false`) → all endpoints get `NoAuth == true`
+  - Edge case: Spec with securitySchemes defined but global `security: []` → operations inherit anonymous despite schemes existing
+  - Edge case: Mixed spec — some operations with `security: []`, some with auth, some inheriting → correct per-endpoint flags
+  - Edge case: Spec with `Auth.Type == "none"` but `Inferred: true` → endpoints do NOT get `NoAuth == true` (fails safe)
+  - Edge case: Existing petstore fixture still parses identically (no regression)
+
+  **Verification:** `go test ./internal/openapi/...` passes including new tests. Petstore tests unchanged.
+
+- [ ] **Unit 3: Annotate MCP tool descriptions with auth status**
+
+  **Goal:** MCP tool descriptions annotate the minority auth side — helping agents identify which tools need setup without cluttering descriptions when the answer is uniform.
+
+  **Requirements:** R2
+
+  **Dependencies:** Unit 1, Unit 2
+
+  **Approach:**
+  The annotation logic depends on the mix of public vs auth-required tools across the whole CLI:
+
+  - **All tools same auth status** → no annotations. If everything needs an API key, the agent learns that from the first 401 hint or the `about` tool (Unit 3b). If everything is public, nothing to say.
+  - **Mixed, public is minority** → append `(public)` on public tools. Example: Pagliacci has 41 tools, 7 public → the 7 get `Find store locations (public)`.
+  - **Mixed, auth-required is minority** → append auth-type-specific suffix on auth-required tools:
+    - API key: `(requires API key)`
+    - OAuth/bearer: `(requires auth)`
+    - Cookie/composed: `(requires browser login)`
+
+  Register a template function `mcpDescription(desc string, noAuth bool, authType string, publicCount int, totalCount int) string` in `generator.go`'s FuncMap. The function:
+  1. Determines whether to annotate (mixed tools only) and which side is the minority
+  2. Prepends or appends the appropriate annotation
+  3. Calls `oneline()` internally on the combined string (reusing all sanitization + truncation)
+
+  The template passes spec-level data: `{{mcpDescription $endpoint.Description $endpoint.NoAuth .Auth.Type .MCPPublicCount .MCPTotalCount}}`. The generator pre-computes `MCPPublicCount` and `MCPTotalCount` and adds them to the template data struct.
+
+  **Files:**
+  - Modify: `internal/generator/templates/mcp_tools.go.tmpl`
+  - Modify: `internal/generator/generator.go` (register `mcpDescription` in FuncMap, add counts to template data)
+  - Modify: `internal/generator/generator_test.go`
+
+  **Patterns to follow:**
+  - Existing template function registration: `"oneline": oneline` in FuncMap at `generator.go:104`
+  - Generator test pattern: `gen.Generate()` then `os.ReadFile()` and `assert.Contains()`
+
+  **Pre-existing context:** `generator.go:439` creates `cmd/{name}-mcp/` and renders `main_mcp.go.tmpl` guarded by `VisionSet.MCP || true` (always runs). But `generator.go:579` renders `mcp_tools.go.tmpl` guarded by just `VisionSet.MCP`. The `mcpDescription` change only takes effect when `tools.go` is rendered. In practice, `VisionSet.MCP` is always true today (`|| true` override), but test scenarios should set the flag explicitly to be safe.
+
+  **Test scenarios:**
+  - Happy path: All-auth-required spec (api_key, 0 public) → no annotations on any tool
+  - Happy path: All-public spec (Auth.Type == "none") → no annotations on any tool
+  - Happy path: Mixed spec, public is minority (30 auth, 5 public) → public tools get `(public)` suffix, auth tools unannotated
+  - Happy path: Mixed spec, auth is minority (30 public, 5 auth, api_key) → auth tools get `(requires API key)` suffix, public tools unannotated
+  - Happy path: Mixed cookie auth (30 cookie, 5 public) → public tools get `(public)`, cookie tools unannotated (cookie is majority)
+  - Edge case: Exactly 50/50 split → annotate the auth-required side (when tied, mark what needs setup)
+
+  **Verification:** `go test ./internal/generator/...` passes. Generated MCP tools file compiles.
+
+- [ ] **Unit 3b: Add `about` tool to every MCP server**
+
+  **Goal:** Every MCP server has a self-describing `about` tool that returns API identity, auth requirements, tool count, and transcendence features — so agents can ask "what can you do?" at runtime.
+
+  **Requirements:** R2, R3
+
+  **Dependencies:** Unit 1
+
+  **Files:**
+  - Modify: `internal/generator/templates/mcp_tools.go.tmpl`
+  - Modify: `internal/generator/generator.go` (pass novel features to MCP template data)
+
+  **Approach:**
+  Register an `about` tool in `RegisterTools()` that returns a JSON object:
+  - `api`: API name and description (from spec)
+  - `tool_count`: total tools registered
+  - `auth`: type, env var names, key URL, readiness level
+  - `unique_capabilities`: array of transcendence features from `NovelFeatures` (name, command, description) — only present when the CLI has novel features
+
+  The generator already passes `NovelFeatures` to the README template data. Pass the same list to the MCP template data struct. The `about` handler is a static response — no API calls, no config needed. It works even when auth isn't configured, which makes it the natural first tool an agent calls.
+
+  **Patterns to follow:**
+  - Existing vision tool registration pattern (sync, search, sql) in `mcp_tools.go.tmpl`
+  - `readmeData()` pattern for threading `NovelFeatures` through template data
+
+  **Test scenarios:**
+  - Happy path: CLI with novel features → `about` tool response includes `unique_capabilities` array
+  - Happy path: CLI without novel features → `about` tool response omits `unique_capabilities`
+  - Happy path: CLI with api_key auth → `about` response includes env var name and key URL
+  - Happy path: CLI with no auth → `about` response shows auth type "none"
+  - Edge case: `about` tool works even when auth is not configured (no API call needed)
+
+  **Verification:** `go test ./internal/generator/...` passes. Generated `about` tool compiles and returns valid JSON.
+
+- [ ] **Unit 4: Add MCP metadata to CLI manifest**
+
+  **Goal:** The `.printing-press.json` provenance manifest includes MCP metadata so published CLIs are self-describing for MCP capabilities.
+
+  **Requirements:** R3
+
+  **Dependencies:** Unit 1
+
+  **Files:**
+  - Modify: `internal/pipeline/climanifest.go`
+  - Modify: `internal/pipeline/publish.go` (where manifest is populated)
+  - Modify: `internal/naming/naming.go` (add `MCP()` function)
+  - Modify: `internal/cli/root.go` (thread spec to manifest writer at lines 189 and 331)
+  - Modify: `internal/generator/generator.go` (replace inline `name + "-mcp"` with `naming.MCP()`)
+  - Modify: `internal/generator/templates/main_mcp.go.tmpl` (update server name string)
+  - Modify: `internal/generator/templates/makefile.tmpl` (update MCP build target binary name)
+  - Modify: `internal/generator/templates/goreleaser.yaml.tmpl` (update MCP build ID and binary name)
+
+  **Approach:**
+  Add fields to `CLIManifest`:
+  - `MCPBinary string` (`json:"mcp_binary,omitempty"`) — e.g., "notion-pp-mcp"
+  - `MCPToolCount int` (`json:"mcp_tool_count,omitempty"`) — total tools registered
+  - `MCPPublicToolCount int` (`json:"mcp_public_tool_count,omitempty"`) — tools with `NoAuth`
+  - `MCPReady string` (`json:"mcp_ready,omitempty"`) — "full", "partial", or "cli-only"
+  - `AuthType string` (`json:"auth_type,omitempty"`) — from spec auth config
+  - `AuthEnvVars []string` (`json:"auth_env_vars,omitempty"`) — from spec.Auth.EnvVars
+  - `NovelFeatures []NovelFeatureManifest` (`json:"novel_features,omitempty"`) — transcendence features from `research.json`'s `novel_features_built`. Each entry: `{name, command, description}`. The mega MCP's `library_info` tool surfaces these as highlights.
+
+  **Data plumbing for `WriteManifestForGenerate()`:** Add `Spec *spec.APISpec` to `GenerateManifestParams`. Both callers in `root.go` already have the parsed spec in scope — the `--docs` path has `parsed` (line 189) and the `--spec` path has `apiSpec` (line 331). Thread it through. Tool counts are computed by a new helper `countMCPTools(spec *spec.APISpec) (total, public int)` that iterates `spec.Resources` and their sub-resources.
+
+  **Data plumbing for `writeCLIManifestForPublish()`:** `PipelineState` does not carry the parsed spec. Re-parse from `spec.json` in the working directory via `openapi.Parse()`. Three callers: `publish.go:109` (PublishWorkingCLI), `lock.go:221` (PromoteWorkingCLI), and indirectly via the pipeline. All have access to the working directory. If `openapi.Parse()` fails or spec.json is missing (e.g., `--docs` runs), log a warning and leave MCP fields empty (non-blocking). The publish skill treats an empty `MCPBinary` as "omit the `mcp` block."
+
+  Add `naming.MCP(name string) string` returning `name + "-pp-mcp"` to `internal/naming/naming.go`. Update both inline `g.Spec.Name+"-mcp"` references in generator.go (the directory path `cmd/{name}-mcp` and the template rendering path `cmd/{name}-mcp/main.go`) to use `naming.MCP()`.
+
+  **MCP binary rename — all template occurrences:**
+  - `goreleaser.yaml.tmpl`: build id (line 21), main path (line 22), binary name (line 23), brew install (line 54) — 4 occurrences of `{{.Name}}-mcp`
+  - `makefile.tmpl`: build-mcp target (line 20, 2 occurrences), install-mcp target (line 23, 1 occurrence) — 3 string replacements across 2 lines
+  - `main_mcp.go.tmpl`: server name string (line 16) — 1 occurrence
+  - Verification: grep generated output for old suffix `-mcp"` (without `-pp-`) to confirm no remnants
+
+  MCP readiness is computed: "full" if auth is `none` or `api_key`/`bearer_token` (env-var passable), "partial" if some endpoints are public but auth is cookie/composed, "cli-only" if all endpoints need cookie/composed auth and none are public.
+
+  **Patterns to follow:**
+  - Existing `CLIManifest` field patterns with `omitempty`
+  - `WriteManifestForGenerate()` catalog lookup enrichment pattern
+  - `naming.CLI()` pattern for `naming.MCP()`
+
+  **Test scenarios:**
+  - Happy path: Manifest for api-key CLI → `mcp_ready: "full"`, correct tool counts
+  - Happy path: Manifest for no-auth CLI → `mcp_ready: "full"`, all tools are public
+  - Happy path: Manifest for cookie CLI with mixed endpoints → `mcp_ready: "partial"`, public count < total
+  - Happy path: Manifest for cookie CLI with no public endpoints → `mcp_ready: "cli-only"`
+  - Edge case: Manifest fields omitted when zero (backward compat with schema_version 1)
+
+  **Verification:** `go test ./internal/pipeline/...` passes. Manifests are valid JSON.
+
+- [ ] **Unit 5: Add MCP section to README template**
+
+  **Goal:** Every generated CLI README includes instructions for using the MCP server.
+
+  **Requirements:** R4
+
+  **Dependencies:** Unit 1
+
+  **Files:**
+  - Modify: `internal/generator/templates/readme.md.tmpl`
+
+  **Approach:**
+  Add a new "## Use as MCP Server" section after the "Agent Usage" section. Content varies by auth type:
+  - API key: show `claude mcp add` with `-e ENV_VAR=<key>` and link to key URL
+  - OAuth/bearer: show `claude mcp add` and note to run CLI `auth login` first
+  - Cookie/composed: show `claude mcp add` and note partial availability, list that some tools work without auth
+  - No auth: show bare `claude mcp add` — works immediately
+
+  Also include Claude Desktop JSON config snippet for all auth types.
+
+  **Patterns to follow:**
+  - Existing auth-type conditional blocks in the README template (lines 24-83)
+  - `{{.Name}}-pp-cli` naming pattern
+
+  **Test scenarios:**
+  - Test expectation: none — README template rendering is verified indirectly through compilation tests and manual review. No behavioral logic to unit test.
+
+  **Verification:** Generated README for a test spec includes the MCP section with correct auth-type-specific content.
+
+- [ ] **Unit 6: Generate smithery.yaml at publish time**
+
+  **Goal:** The publish pipeline produces a `smithery.yaml` marketplace metadata file alongside each published CLI.
+
+  **Requirements:** R5
+
+  **Dependencies:** Unit 4
+
+  **Files:**
+  - Modify: `internal/pipeline/publish.go` (companion to `writeCLIManifestForPublish()`)
+
+  **Approach:**
+  Write a `smithery.yaml` as a companion function called from the same sites as `writeCLIManifestForPublish()`. There are two callers:
+  1. `PublishWorkingCLI()` at `internal/pipeline/publish.go:109`
+  2. `PromoteWorkingCLI()` at `internal/pipeline/lock.go:221` (primary fullrun path — the pipeline calls this)
+
+  Content is derived from the manifest:
+  - `name`: MCP binary name
+  - `description`: from manifest description (no spec-source suffix — keep it clean for marketplace display)
+  - `startCommand.command`: `./` + MCP binary name
+  - `env`: map of env var name → `{description, required}` from auth config
+
+  Only write smithery.yaml when `MCPReady` is "full" or "partial". Skip for "cli-only" (no useful MCP without browser setup).
+
+  Apply traversal protection per institutional learning: smithery values don't flow into path construction, but validate that the MCP binary name matches expected patterns.
+
+  **Patterns to follow:**
+  - `WriteCLIManifest()` pattern — write file to dir with known name
+  - Non-mutating publish pattern — write during `publish package`, not during `publish validate`
+
+  **Test scenarios:**
+  - Happy path: API-key CLI → smithery.yaml with env var marked required
+  - Happy path: No-auth CLI → smithery.yaml with no env section
+  - Happy path: Cookie CLI with partial readiness → smithery.yaml generated with env vars optional
+  - Edge case: CLI-only readiness → no smithery.yaml written
+
+  **Verification:** `go test ./internal/pipeline/...` passes. Generated smithery.yaml is valid YAML.
+
+- [ ] **Unit 7: Report MCP tool split in scorecard**
+
+  **Goal:** The scorecard summary includes an informational line about MCP tool counts (public vs auth-required) without adding a new scored dimension.
+
+  **Requirements:** R8
+
+  **Dependencies:** Unit 4
+
+  **Files:**
+  - Modify: `internal/pipeline/scorecard.go`
+
+  **Approach:**
+  In the scorecard's summary output section, add a one-line note when MCP tools exist: "MCP: {total} tools ({public} public, {auth} auth-required)". This is informational only — does not affect the score, tier constants, or grade.
+
+  Read the CLI manifest (`.printing-press.json`) from the CLI directory and use the `MCPToolCount` and `MCPPublicToolCount` fields from Unit 4. This avoids calling `openapi.Parse()` from the scorecard (which would be disproportionate — adding a full-spec-parse dependency for an informational one-line summary). The manifest is always available when the scorecard runs because the pipeline writes it before scoring.
+
+  If the manifest has no MCP fields (empty `MCPBinary`), skip the MCP line.
+
+  **Patterns to follow:**
+  - Existing summary/gap reporting in scorecard output
+  - Manifest reading pattern already used elsewhere in the pipeline
+
+  **Test scenarios:**
+  - Happy path: Scorecard for CLI with mixed auth endpoints → summary line shows correct split
+  - Happy path: Scorecard for CLI with all-public endpoints → summary shows "X tools (X public, 0 auth-required)"
+  - Edge case: Scorecard for CLI without MCP → no MCP line in summary
+  - Edge case: Scorecard for CLI directory without `.printing-press.json` (standalone usage) → no MCP line, no error
+
+  **Verification:** `go test ./internal/pipeline/...` passes. Scorecard output includes MCP line when applicable.
+
+### Public Library Repo (mvanhorn/printing-press-library)
+
+- [ ] **Unit 8: Extend registry.json with MCP metadata**
+
+  **Goal:** Each entry in `registry.json` includes an `mcp` block with binary name, tool counts, auth type, and readiness level.
+
+  **Requirements:** R6
+
+  **Dependencies:** Unit 4 (schema), Unit 9 (Pagliacci/Steam public tool counts)
+
+  **Files:**
+  - Modify: `registry.json` (in `mvanhorn/printing-press-library`)
+
+  **Approach:**
+  Add `mcp` object to each of the 6 entries:
+  ```json
+  "mcp": {
+    "binary": "<name>-pp-mcp",
+    "transport": "stdio",
+    "tool_count": <N>,
+    "public_tool_count": <N>,
+    "auth_type": "<api_key|none|composed>",
+    "env_vars": ["<VAR>"],
+    "mcp_ready": "<full|partial|cli-only>"
+  }
+  ```
+
+  Values per CLI:
+  - `dub-pp-cli`: 53 tools, api_key (DUB_TOKEN), mcp_ready: full
+  - `espn-pp-cli`: 3 tools, none, mcp_ready: full (all public)
+  - `linear-pp-cli`: tools TBD, api_key (LINEAR_TOKEN), mcp_ready: full
+  - `pagliacci-pizza-pp-cli`: 41 tools, composed, mcp_ready: partial (public endpoints TBD during fixup)
+  - `postman-explore-pp-cli`: 9 tools, none, mcp_ready: full (all public)
+  - `steam-web-pp-cli`: 164 tools, api_key (STEAM_WEB_API_KEY), mcp_ready: full
+
+  **Sequencing note:** `public_tool_count` values for Pagliacci and Steam depend on Unit 9 completion (HTTP verification of which endpoints are public). Finalize those registry values after Unit 9.
+
+  **Patterns to follow:**
+  - Existing registry.json entry schema
+  - Additive schema extension (new fields, existing fields unchanged)
+
+  **Test scenarios:**
+  - Test expectation: none — registry.json is a data file. Validated by the publish skill reading it successfully.
+
+  **Verification:** `registry.json` is valid JSON. All 6 entries have `mcp` blocks.
+
+- [ ] **Unit 9: Annotate existing CLI MCP tool descriptions**
+
+  **Goal:** Each published CLI gets two changes: (1) MCP binary renamed from `{name}-mcp` to `{name}-pp-mcp` for collision avoidance, and (2) minority-side auth annotations added to tool descriptions in `internal/mcp/tools.go`. Targeted source edits — no regeneration.
+
+  **Requirements:** R7
+
+  **Dependencies:** None (can run in parallel with machine changes)
+
+  **Files:**
+  For each of the 6 CLIs:
+  - Rename: `cmd/{name}-mcp/` → `cmd/{name}-pp-mcp/`
+  - Modify: `cmd/{name}-pp-mcp/main.go` (update server name string from `"{name}-mcp"` to `"{name}-pp-mcp"`)
+  - Modify: `internal/mcp/tools.go` (auth annotations)
+  - Modify: `Makefile` (update MCP build target)
+  - Modify: `.goreleaser.yaml` (update MCP binary name and build ID)
+  - Modify: `README.md` (update MCP binary name in install/usage instructions)
+  - Grep all files for old binary name (`{name}-mcp` without `-pp-`) to catch remaining references in Dockerfiles, CI configs, and shell scripts
+
+  **Approach:**
+  For each CLI, rename the MCP binary and annotate public endpoints:
+
+  **Step 0 — Rename MCP binary:**
+  For all 6 CLIs: rename `cmd/{name}-mcp/` to `cmd/{name}-pp-mcp/`, update the server name string in `main.go`, update `Makefile` and `.goreleaser.yaml` build targets. This is a mechanical find-and-replace per CLI.
+
+  **Step 1 — Classify endpoints and determine annotation strategy per CLI:**
+  - **espn, postman-explore**: Auth type is `none` → ALL endpoints are public. No annotations needed (uniform).
+  - **dub, linear**: Auth type is api_key → ALL endpoints require auth. No annotations needed (uniform).
+  - **pagliacci-pizza**: Composed cookie auth. For each candidate public endpoint (store finder, menu, location search), make one actual HTTP request without credentials using `curl`. If 200 with meaningful data → public. If 401/403 → auth-required. Document results in the PR. Public endpoints are the minority → append `(public)` on them.
+  - **steam-web**: API key auth but Steam has many public endpoints. Same verification protocol: test each endpoint path with `curl` without the API key. Count public vs auth-required. Annotate the minority side:
+    - If most tools are public → append `(requires API key)` on auth-required tools
+    - If most tools need auth → append `(public)` on public tools
+
+  **Step 2 — Annotate `tools.go`:**
+  Apply the minority-side annotation to tool descriptions in `mcplib.WithDescription("...")` calls. Be conservative — only annotate endpoints verified via actual HTTP requests.
+
+  **Step 3 — Verify:**
+  After editing, run `gofmt -w` on each modified file, then `go build ./...` in each CLI directory.
+
+  **Patterns to follow:**
+  - Minority is public → append ` (public)` before closing `"`
+  - Minority is auth-required → append ` (requires API key)` (or `(requires browser login)`) before closing `"`
+
+  **Test scenarios:**
+  - Happy path: espn tools.go → no annotations (all public, uniform)
+  - Happy path: dub tools.go → no annotations (all auth, uniform)
+  - Happy path: pagliacci tools.go → verified-public tools get `(public)` suffix, auth tools unannotated
+  - Happy path: steam tools.go → minority side annotated based on actual count
+  - Edge case: Annotations don't break Go compilation — verify with `go build ./...` in each CLI dir
+
+  **Verification:** Each modified CLI compiles: `cd <cli-dir> && go build ./...`. Every annotated endpoint was verified with an actual unauthenticated HTTP request. Verification results documented in the PR body.
+
+- [ ] **Unit 10: Update library README with dual-interface install paths**
+
+  **Goal:** The public library README shows both CLI and MCP install options for each published CLI.
+
+  **Requirements:** R6
+
+  **Dependencies:** Unit 8
+
+  **Files:**
+  - Modify: `README.md` (in `mvanhorn/printing-press-library`)
+
+  **Approach:**
+  Update the library's main README to include MCP install commands alongside CLI install commands for each entry. Show the `claude mcp add` one-liner with the correct env vars. Group entries by MCP readiness level to highlight what works immediately vs. what needs setup.
+
+  Also update the library's positioning to acknowledge the dual-interface story: "The printing press generates both CLIs and MCP servers from the same spec. CLIs are the efficiency layer — fewer tokens, composable with pipes, works with any shell-based agent. MCP servers are the discovery layer — show up in Claude Desktop, Cursor, and marketplace listings. Use the CLI to set up auth and explore interactively. Use the MCP to let your AI editor call the API."
+
+  **Patterns to follow:**
+  - Existing README structure in the library repo
+
+  **Test scenarios:**
+  - Test expectation: none — documentation file, validated by manual review.
+
+  **Verification:** README renders correctly in GitHub. All 6 CLIs have both install paths shown.
+
+### Publish Skill Update (this repo)
+
+- [ ] **Unit 11: Update publish skill to write MCP registry fields**
+
+  **Goal:** The `/printing-press-publish` skill populates the `mcp` block in `registry.json` entries when packaging CLIs.
+
+  **Requirements:** R6
+
+  **Dependencies:** Unit 4, Unit 8
+
+  **Files:**
+  - Modify: `skills/printing-press-publish/SKILL.md`
+
+  **Approach:**
+  Update the registry entry construction in the publish skill to include the `mcp` block. The skill reads `.printing-press.json` for metadata — the new MCP fields from Unit 4 provide `mcp_binary`, `mcp_tool_count`, `mcp_public_tool_count`, `mcp_ready`, and `auth_type`. The skill maps these into the registry entry format.
+
+  Also update the skill's documentation of the registry schema to include the new `mcp` block.
+
+  **Patterns to follow:**
+  - Existing registry entry construction in SKILL.md Step 8
+
+  **Test scenarios:**
+  - Test expectation: none — skill definitions are tested through end-to-end pipeline runs, not unit tests.
+
+  **Verification:** Skill documentation accurately reflects the new registry schema. A manual publish dry-run produces correct registry entries.
+
+## System-Wide Impact
+
+- **Interaction graph:** The `NoAuth` field flows: OpenAPI parser → spec model → generator templates (MCP + potentially CLI help) → manifest → publish pipeline → registry.json → library README. Each hop is additive — no existing behavior changes.
+- **Error propagation:** No new error paths. `NoAuth` defaults to `false` (zero value), so missing data fails safe (assume auth required).
+- **State lifecycle risks:** None. `NoAuth` is computed at parse time and immutable thereafter.
+- **API surface parity:** The CLI commands themselves don't surface `NoAuth` today. The MCP description annotation is the first consumer. CLI `--help` output could be enhanced later but is out of scope.
+- **Integration coverage:** The end-to-end path (parse spec → generate MCP → read manifest → write registry) should be verified with one full pipeline run on a mixed-auth spec.
+- **Unchanged invariants:** CLI binary generation, auth flows, doctor command, verify/dogfood, scorecard scoring formula — none of these change. The scorecard gets an informational line but no scoring changes.
+
+## Risks & Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Existing published CLIs may not compile after annotation edits | Run `go build ./...` in each CLI directory after editing. Revert on failure. |
+| Some OpenAPI specs may have ambiguous security (e.g., `security: [{}]` meaning different things) | Follow the same interpretation as the scorecard's `parseSecurityRequirementSet()` which is already battle-tested. |
+| Smithery.yaml schema may change | Pin to current schema. The file is opt-in metadata — if Smithery changes, regeneration fixes it. |
+| Registry.json schema extension breaks existing consumers | All new fields are optional (`mcp` block). Existing consumers that don't read `mcp` are unaffected. |
+| Steam/Pagliacci public endpoint identification may be wrong | Verify every candidate with actual HTTP request without credentials. Only annotate confirmed-public endpoints. Document results in PR. |
+| MCP binary rename breaks existing users | No marketplace listings exist yet. The 6 published CLIs are source-only in the library repo — no users have `claude mcp add` configs pointing to the old names. Rename cost is zero. |
+| Specs that omit security but actually require auth (false public sweep) | Post-parse sweep guarded by `!Auth.Inferred`. Only marks all endpoints public when parser actively concluded no auth exists, not when detection failed. |
+| `oneline()` truncation drops auth annotation on long descriptions | `mcpDescription()` appends annotation then calls `oneline()`. Both `(public)` and `(requires API key)` are suffixes that could be cut on very long descriptions. In practice, most MCP tool descriptions are under 100 chars, leaving room for the suffix within the 120-char limit. |
+
+## Contract with Follow-Up Plan: Printing Press Mega MCP
+
+This plan is **Step 1 of 2.** It builds the data layer. Step 2 (separate plan) builds the Printing Press Mega MCP — a single MCP server that aggregates all published CLIs into one installable tool with catalog discovery and passthrough execution.
+
+### What the Mega MCP needs from this plan
+
+Every artifact this plan produces is designed to be consumed by the mega MCP:
+
+| This plan produces | Mega MCP consumes it as |
+|-------------------|------------------------|
+| `registry.json` with `mcp` block per CLI | Backend for `library_search`, `library_info`, `library_install` catalog tools |
+| `NoAuth` on endpoints | Decides which tools work without auth — registered as immediately usable vs gated |
+| `AuthType` + `AuthEnvVars` in manifest | Per-API env var checking at call time — helpful error when env var not set |
+| `MCPReady` levels | Determines which APIs are included in the mega MCP (full/partial yes, cli-only no) |
+| `MCPToolCount` / `MCPPublicToolCount` | `library_info` response: "Steam Web — 164 tools, 80 work without auth" |
+| `about` tool pattern (Unit 3b) | Individual MCP servers have `about`; mega MCP has `library_info` (same data, different scope) |
+| `novel_features_built` in manifest | `library_info` response includes transcendence features as highlights |
+| `smithery.yaml` per CLI | Mega MCP gets its own smithery.yaml; individual CLIs have theirs for standalone listing |
+| Minority-side auth annotations | Same annotation logic applied to mega MCP tool descriptions |
+| `{name}-pp-mcp` naming | Mega MCP: `printing-press-library-pp-mcp`. Individual: `{name}-pp-mcp`. No collision. |
+
+### What the Mega MCP will deliver (Step 2)
+
+- Single binary: `printing-press-library-pp-mcp`
+- Catalog tools: `library_search` (find by name/category/keyword), `library_info` (details + transcendence features + install instructions), `library_install` (CLI and MCP install commands per platform)
+- Passthrough tools: all individual API tools under namespaced names (`steam_web__app_details`, `dub__links_create`), using the same generic `makeAPIHandler` pattern the generator already produces
+- Per-API auth: env vars checked at call time, not startup. Missing env var → helpful error with setup instructions. Public tools work immediately.
+- Cookie-auth APIs: public tools work, auth-required tools return "install the standalone CLI and run `auth login --chrome`"
+- User journey: one `brew install` + one `claude mcp add` → every public tool across all APIs works → set env vars to unlock specific APIs → graduate to standalone MCP if desired
+
+### Design decisions this plan locks in for the mega MCP
+
+These choices in this plan constrain the mega MCP's design. They should not be changed without considering the downstream impact:
+
+1. **`registry.json` schema** — The `mcp` block is the mega MCP's source of truth for which APIs exist and what they need. Fields must be stable.
+2. **`NoAuth` on Endpoint** — The mega MCP registers tools as "works without auth" or "needs env var" based on this flag. The flag must be set correctly by the parser.
+3. **`MCPReady` levels** — The mega MCP only includes `full` and `partial` CLIs. `cli-only` CLIs are excluded (need browser, can't passthrough).
+4. **Manifest `AuthEnvVars`** — The mega MCP checks these env vars at call time per-API. The field must be populated.
+5. **Naming: `{name}-pp-mcp`** — Individual servers use this pattern. The mega MCP uses `printing-press-library-pp-mcp`. No collision.
+6. **`about` tool response shape** — Individual MCP servers return this shape; the mega MCP's `library_info` returns a compatible superset.
+
+### Separate follow-up: Library Reorganization
+
+Rename published CLI directories from `{api}-pp-cli` to `{api-slug}` (e.g., `dub-pp-cli/` → `dub/`). The directory contains both CLI and MCP binaries, so naming it after the CLI is misleading. Impacts: go.mod module paths, publish collision detection, registry paths, `go install` paths, branch naming. Full problem description and migration strategy documented separately.
+
+**Depends on:** Both this plan and the mega MCP plan completing first (avoid conflicting migrations).
+
+## Sources & References
+
+- **Inspiration:** [fli project](https://www.punitarani.com/projects/fli) — Google Flights CLI/MCP that grew via marketplace listings
+- Scorecard security parsing: `internal/pipeline/scorecard.go:1113-1157`
+- OpenAPI parser endpoint construction: `internal/openapi/parser.go:800-851`
+- MCP tools template: `internal/generator/templates/mcp_tools.go.tmpl`
+- CLI manifest: `internal/pipeline/climanifest.go`
+- Publish pipeline: `internal/pipeline/publish.go`, `internal/cli/publish.go`
+- README template: `internal/generator/templates/readme.md.tmpl`
+- Publish skill: `skills/printing-press-publish/SKILL.md`
+- Institutional learnings: scorecard accuracy, filepath traversal, non-mutating validation, layout contract
+- kin-openapi Operation.Security: `*SecurityRequirements` (`[]SecurityRequirement`)
diff --git a/internal/cli/root.go b/internal/cli/root.go
index b88d2d89..ff283832 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -192,6 +192,7 @@ func newGenerateCmd() *cobra.Command {
 					APIName:   parsed.Name,
 					DocsURL:   docsURL,
 					OutputDir: absOut,
+					Spec:      parsed,
 				}); err != nil {
 					fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
 				}
@@ -341,6 +342,7 @@ func newGenerateCmd() *cobra.Command {
 				SpecSrcs:  specFiles,
 				SpecURL:   specURL,
 				OutputDir: absOut,
+				Spec:      apiSpec,
 			}); err != nil {
 				fmt.Fprintf(os.Stderr, "warning: could not write manifest: %v\n", err)
 			}
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index e0cca208..b85286cb 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -111,6 +111,7 @@ func New(s *spec.APISpec, outputDir string) *Generator {
 		"envVarPlaceholder":  envVarPlaceholder,
 		"add":                func(a, b int) int { return a + b },
 		"oneline":            oneline,
+		"mcpDescription":     mcpDescription,
 		"flagName":           flagName,
 		"safeTypeName":       safeTypeName,
 		"hasNonScalarType": func(types map[string]spec.TypeDef) bool {
@@ -476,10 +477,10 @@ func (g *Generator) Generate() error {
 		return fmt.Errorf("rendering auth: %w", err)
 	}
 
-	// MCP server: generate cmd/{name}-mcp/ entry point and internal/mcp/ package
+	// MCP server: generate cmd/{name}-pp-mcp/ entry point and internal/mcp/ package
 	if g.VisionSet.MCP || true { // Always generate MCP for now
 		mcpDirs := []string{
-			filepath.Join("cmd", g.Spec.Name+"-mcp"),
+			filepath.Join("cmd", naming.MCP(g.Spec.Name)),
 			filepath.Join("internal", "mcp"),
 		}
 		for _, d := range mcpDirs {
@@ -487,7 +488,7 @@ func (g *Generator) Generate() error {
 				return fmt.Errorf("creating MCP dir %s: %w", d, err)
 			}
 		}
-		if err := g.renderTemplate("main_mcp.go.tmpl", filepath.Join("cmd", g.Spec.Name+"-mcp", "main.go"), g.Spec); err != nil {
+		if err := g.renderTemplate("main_mcp.go.tmpl", filepath.Join("cmd", naming.MCP(g.Spec.Name), "main.go"), g.Spec); err != nil {
 			return fmt.Errorf("rendering MCP main: %w", err)
 		}
 	}
@@ -616,20 +617,27 @@ func (g *Generator) Generate() error {
 		}
 	}
 
-	// Render MCP tools registration (needs VisionSet + store data)
+	// Render MCP tools registration (needs VisionSet + store data + tool counts for annotations)
 	if g.VisionSet.MCP {
+		mcpTotal, mcpPublic := g.Spec.CountMCPTools()
 		mcpData := struct {
 			*spec.APISpec
 			SyncableResources []profiler.SyncableResource
 			SearchableFields  map[string][]string
 			Tables            []TableDef
 			VisionSet         VisionTemplateSet
+			MCPTotalCount     int
+			MCPPublicCount    int
+			NovelFeatures     []NovelFeature
 		}{
 			APISpec:           g.Spec,
 			SyncableResources: g.profile.SyncableResources,
 			SearchableFields:  g.profile.SearchableFields,
 			Tables:            schema,
 			VisionSet:         g.VisionSet,
+			MCPTotalCount:     mcpTotal,
+			MCPPublicCount:    mcpPublic,
+			NovelFeatures:     g.NovelFeatures,
 		}
 		if err := g.renderTemplate("mcp_tools.go.tmpl", filepath.Join("internal", "mcp", "tools.go"), mcpData); err != nil {
 			return fmt.Errorf("rendering MCP tools: %w", err)
@@ -1061,6 +1069,38 @@ func oneline(s string) string {
 	return s
 }
 
+// mcpDescription builds an MCP tool description with optional minority-side
+// auth annotation. Only annotates when the CLI has a mix of public and
+// auth-required tools. The minority side gets annotated:
+//   - Public is minority → append "(public)"
+//   - Auth-required is minority → append auth-type-specific suffix
+//   - All same status or exact tie → no annotation
+func mcpDescription(desc string, noAuth bool, authType string, publicCount, totalCount int) string {
+	authCount := totalCount - publicCount
+	mixed := publicCount > 0 && authCount > 0
+
+	if mixed {
+		if noAuth && publicCount < authCount {
+			// Public endpoints are the minority — mark them
+			desc = desc + " (public)"
+		} else if !noAuth && authCount < publicCount {
+			// Auth-required endpoints are the minority — mark them
+			switch authType {
+			case "api_key":
+				desc = desc + " (requires API key)"
+			case "cookie", "composed":
+				desc = desc + " (requires browser login)"
+			case "oauth2", "bearer_token":
+				desc = desc + " (requires auth)"
+			default:
+				desc = desc + " (requires auth)"
+			}
+		}
+	}
+
+	return oneline(desc)
+}
+
 func exampleValue(p spec.Param) string {
 	nameLower := strings.ToLower(p.Name)
 
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 76e03f23..200efca1 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -84,6 +84,48 @@ func TestGenerateOAuth2AuthTemplateConditionally(t *testing.T) {
 	})
 }
 
+func TestGeneratedOutput_READMEBearerTokenMCPSetup(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "bearer",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "bearer_token",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"BEARER_TOKEN"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/bearer-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Description: "Manage items",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/items",
+						Description: "List items",
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+	require.NoError(t, err)
+	content := string(readme)
+	assert.Contains(t, content, "claude mcp add bearer bearer-pp-mcp -e BEARER_TOKEN=<your-token>")
+	assert.NotContains(t, content, "bearer-pp-cli auth login\n\nclaude mcp add bearer bearer-pp-mcp")
+}
+
 func countFiles(t *testing.T, root string) int {
 	t.Helper()
 
@@ -1521,3 +1563,85 @@ func TestGeneratedOutput_ObjectFieldsUseRawMessage(t *testing.T) {
 	assert.Contains(t, content, "json.RawMessage", "types.go should use json.RawMessage for object/array fields")
 	assert.Contains(t, content, `import "encoding/json"`, "types.go should import encoding/json when RawMessage is used")
 }
+
+func TestMCPDescription(t *testing.T) {
+	t.Parallel()
+	tests := []struct {
+		name        string
+		desc        string
+		noAuth      bool
+		authType    string
+		publicCount int
+		totalCount  int
+		want        string
+	}{
+		{
+			name: "all auth — no annotation",
+			desc: "List orders", noAuth: false, authType: "api_key",
+			publicCount: 0, totalCount: 10,
+			want: "List orders",
+		},
+		{
+			name: "all public — no annotation",
+			desc: "List items", noAuth: true, authType: "none",
+			publicCount: 10, totalCount: 10,
+			want: "List items",
+		},
+		{
+			name: "public minority — append (public)",
+			desc: "Find stores", noAuth: true, authType: "api_key",
+			publicCount: 3, totalCount: 10,
+			want: "Find stores (public)",
+		},
+		{
+			name: "public minority — auth endpoint not annotated",
+			desc: "Create order", noAuth: false, authType: "api_key",
+			publicCount: 3, totalCount: 10,
+			want: "Create order",
+		},
+		{
+			name: "auth minority api_key — append suffix",
+			desc: "Create order", noAuth: false, authType: "api_key",
+			publicCount: 8, totalCount: 10,
+			want: "Create order (requires API key)",
+		},
+		{
+			name: "auth minority cookie — append browser login",
+			desc: "View account", noAuth: false, authType: "cookie",
+			publicCount: 8, totalCount: 10,
+			want: "View account (requires browser login)",
+		},
+		{
+			name: "auth minority oauth2 — append requires auth",
+			desc: "Update profile", noAuth: false, authType: "oauth2",
+			publicCount: 8, totalCount: 10,
+			want: "Update profile (requires auth)",
+		},
+		{
+			name: "exact tie — no annotation on either side",
+			desc: "Get item", noAuth: true, authType: "api_key",
+			publicCount: 5, totalCount: 10,
+			want: "Get item",
+		},
+		{
+			name: "exact tie — auth side also not annotated",
+			desc: "Delete item", noAuth: false, authType: "api_key",
+			publicCount: 5, totalCount: 10,
+			want: "Delete item",
+		},
+		{
+			name: "oneline cleanup applied",
+			desc: "First line\nSecond line", noAuth: false, authType: "none",
+			publicCount: 0, totalCount: 5,
+			want: "First line Second line",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			got := mcpDescription(tt.desc, tt.noAuth, tt.authType, tt.publicCount, tt.totalCount)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
diff --git a/internal/generator/templates/goreleaser.yaml.tmpl b/internal/generator/templates/goreleaser.yaml.tmpl
index 5c02a392..6548ac1b 100644
--- a/internal/generator/templates/goreleaser.yaml.tmpl
+++ b/internal/generator/templates/goreleaser.yaml.tmpl
@@ -18,9 +18,9 @@ builds:
       - windows_amd64
       - windows_arm64
 {{- if .VisionSet.MCP}}
-  - id: {{.Name}}-mcp
-    main: ./cmd/{{.Name}}-mcp
-    binary: {{.Name}}-mcp
+  - id: {{.Name}}-pp-mcp
+    main: ./cmd/{{.Name}}-pp-mcp
+    binary: {{.Name}}-pp-mcp
     env:
       - CGO_ENABLED=0
     ldflags:
@@ -51,5 +51,5 @@ brews:
     install: |
       bin.install "{{.Name}}-pp-cli"
 {{- if .VisionSet.MCP}}
-      bin.install "{{.Name}}-mcp"
+      bin.install "{{.Name}}-pp-mcp"
 {{- end}}
diff --git a/internal/generator/templates/main_mcp.go.tmpl b/internal/generator/templates/main_mcp.go.tmpl
index 80161b23..415463f7 100644
--- a/internal/generator/templates/main_mcp.go.tmpl
+++ b/internal/generator/templates/main_mcp.go.tmpl
@@ -13,7 +13,7 @@ import (
 
 func main() {
 	s := server.NewMCPServer(
-		"{{.Name}}-mcp",
+		"{{.Name}}-pp-mcp",
 		"1.0.0",
 		server.WithToolCapabilities(false),
 	)
diff --git a/internal/generator/templates/makefile.tmpl b/internal/generator/templates/makefile.tmpl
index 79e1843a..86392c33 100644
--- a/internal/generator/templates/makefile.tmpl
+++ b/internal/generator/templates/makefile.tmpl
@@ -17,10 +17,10 @@ clean:
 {{- if .VisionSet.MCP}}
 
 build-mcp:
-	go build -o bin/{{.Name}}-mcp ./cmd/{{.Name}}-mcp
+	go build -o bin/{{.Name}}-pp-mcp ./cmd/{{.Name}}-pp-mcp
 
 install-mcp:
-	go install ./cmd/{{.Name}}-mcp
+	go install ./cmd/{{.Name}}-pp-mcp
 
 build-all: build build-mcp
 {{- end}}
diff --git a/internal/generator/templates/mcp_tools.go.tmpl b/internal/generator/templates/mcp_tools.go.tmpl
index 3bbe74ca..a3e26be6 100644
--- a/internal/generator/templates/mcp_tools.go.tmpl
+++ b/internal/generator/templates/mcp_tools.go.tmpl
@@ -64,7 +64,7 @@ func RegisterTools(s *server.MCPServer) {
 {{- range $eName, $endpoint := $resource.Endpoints}}
 	s.AddTool(
 		mcplib.NewTool("{{snake $name}}_{{snake $eName}}",
-			mcplib.WithDescription("{{oneline $endpoint.Description}}"),
+			mcplib.WithDescription("{{mcpDescription $endpoint.Description $endpoint.NoAuth $.Auth.Type $.MCPPublicCount $.MCPTotalCount}}"),
 {{- range $endpoint.Params}}
 {{- if eq .Type "integer"}}
 			mcplib.WithNumber("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
@@ -82,7 +82,7 @@ func RegisterTools(s *server.MCPServer) {
 {{- range $eName, $endpoint := $subResource.Endpoints}}
 	s.AddTool(
 		mcplib.NewTool("{{snake $name}}_{{snake $subName}}_{{snake $eName}}",
-			mcplib.WithDescription("{{oneline $endpoint.Description}}"),
+			mcplib.WithDescription("{{mcpDescription $endpoint.Description $endpoint.NoAuth $.Auth.Type $.MCPPublicCount $.MCPTotalCount}}"),
 {{- range $endpoint.Params}}
 {{- if eq .Type "integer"}}
 			mcplib.WithNumber("{{.Name}}"{{if .Required}}, mcplib.Required(){{end}}, mcplib.Description("{{oneline .Description}}")),
@@ -134,6 +134,14 @@ func RegisterTools(s *server.MCPServer) {
 		handleSQL,
 	)
 {{- end}}
+
+	// About tool — self-describing metadata for this MCP server
+	s.AddTool(
+		mcplib.NewTool("about",
+			mcplib.WithDescription("Describe this MCP server's capabilities, auth requirements, and unique features"),
+		),
+		handleAbout,
+	)
 }
 
 // makeAPIHandler creates a generic MCP tool handler for an API endpoint.
@@ -371,3 +379,32 @@ func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToo
 	return mcplib.NewToolResultText(string(data)), nil
 }
 {{- end}}
+
+func handleAbout(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
+	about := map[string]any{
+		"api":              "{{.Name}}",
+		"description":      "{{oneline .Description}}",
+		"tool_count":       {{.MCPTotalCount}},
+		"public_tool_count": {{.MCPPublicCount}},
+{{- if and .Auth.Type (ne .Auth.Type "none")}}
+		"auth": map[string]any{
+			"type": "{{.Auth.Type}}",
+{{- if .Auth.EnvVars}}
+			"env_vars": []string{ {{- range .Auth.EnvVars}}"{{.}}", {{end}} },
+{{- end}}
+{{- if .Auth.KeyURL}}
+			"key_url": "{{.Auth.KeyURL}}",
+{{- end}}
+		},
+{{- end}}
+{{- if .NovelFeatures}}
+		"unique_capabilities": []map[string]string{
+{{- range .NovelFeatures}}
+			{"name": "{{.Name}}", "command": "{{.Command}}", "description": "{{oneline .Description}}"},
+{{- end}}
+		},
+{{- end}}
+	}
+	data, _ := json.MarshalIndent(about, "", "  ")
+	return mcplib.NewToolResultText(string(data)), nil
+}
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 4e62904d..56fd11b3 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -158,6 +158,69 @@ This CLI is designed for AI agent consumption:
 
 Exit codes: `0` success, `2` usage error, `3` not found, `4` auth error, `5` API error, `7` rate limited, `10` config error.
 
+## Use as MCP Server
+
+This CLI ships a companion MCP server for use with Claude Desktop, Cursor, and other MCP-compatible tools.
+
+### Claude Code
+{{- if and (eq .Auth.Type "api_key") .Auth.EnvVars}}
+
+```bash
+claude mcp add {{.Name}} {{.Name}}-pp-mcp -e {{index .Auth.EnvVars 0}}=<your-key>
+```
+{{- else if or (eq .Auth.Type "cookie") (eq .Auth.Type "composed")}}
+
+```bash
+# Some tools work without auth. For full access, set up auth first:
+{{.Name}}-pp-cli auth login --chrome
+
+claude mcp add {{.Name}} {{.Name}}-pp-mcp
+```
+{{- else if eq .Auth.Type "oauth2"}}
+
+```bash
+# Set up auth first:
+{{.Name}}-pp-cli auth login
+
+claude mcp add {{.Name}} {{.Name}}-pp-mcp
+```
+{{- else if and (eq .Auth.Type "bearer_token") .Auth.EnvVars}}
+
+```bash
+claude mcp add {{.Name}} {{.Name}}-pp-mcp -e {{index .Auth.EnvVars 0}}=<your-token>
+```
+{{- else if eq .Auth.Type "bearer_token"}}
+
+```bash
+# Set up auth first:
+{{.Name}}-pp-cli auth set-token YOUR_TOKEN_HERE
+
+claude mcp add {{.Name}} {{.Name}}-pp-mcp
+```
+{{- else}}
+
+```bash
+claude mcp add {{.Name}} {{.Name}}-pp-mcp
+```
+{{- end}}
+
+### Claude Desktop
+
+Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
+
+```json
+{
+  "mcpServers": {
+    "{{.Name}}": {
+      "command": "{{.Name}}-pp-mcp"{{- if and .Auth.EnvVars (ne .Auth.Type "none") (ne .Auth.Type "cookie") (ne .Auth.Type "composed")}},
+      "env": {
+        "{{index .Auth.EnvVars 0}}": "<your-key>"
+      }{{- end}}
+    }
+  }
+}
+```
+
 ## Cookbook
 
 Common workflows and recipes:
diff --git a/internal/naming/naming.go b/internal/naming/naming.go
index 0e973078..1c342e6b 100644
--- a/internal/naming/naming.go
+++ b/internal/naming/naming.go
@@ -5,12 +5,17 @@ import "strings"
 const (
 	CurrentCLISuffix = "-pp-cli"
 	LegacyCLISuffix  = "-cli"
+	MCPSuffix        = "-pp-mcp"
 )
 
 func CLI(name string) string {
 	return name + CurrentCLISuffix
 }
 
+func MCP(name string) string {
+	return name + MCPSuffix
+}
+
 func LegacyCLI(name string) string {
 	return name + LegacyCLISuffix
 }
diff --git a/internal/naming/naming_test.go b/internal/naming/naming_test.go
index 6b81052f..43968fb1 100644
--- a/internal/naming/naming_test.go
+++ b/internal/naming/naming_test.go
@@ -18,6 +18,19 @@ func TestTrimCLISuffix(t *testing.T) {
 	}
 }
 
+func TestMCP(t *testing.T) {
+	tests := map[string]string{
+		"stripe":  "stripe-pp-mcp",
+		"cal-com": "cal-com-pp-mcp",
+		"notion":  "notion-pp-mcp",
+	}
+	for input, want := range tests {
+		if got := MCP(input); got != want {
+			t.Fatalf("MCP(%q) = %q, want %q", input, got, want)
+		}
+	}
+}
+
 func TestIsCLIDirName(t *testing.T) {
 	if !IsCLIDirName("stripe-pp-cli-3") {
 		t.Fatal("expected suffixed pp-cli directory to be recognized")
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 3bad51c8..e71e9d3e 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -260,6 +260,15 @@ func parse(data []byte, lenient bool) (*spec.APISpec, error) {
 	}
 	mapResources(doc, result, resourceBasePath)
 	mapTypes(doc, result)
+
+	// Post-parse sweep: if the spec has no authentication at all (not inferred
+	// from description keywords), mark every endpoint as NoAuth. The per-operation
+	// detection in mapResources handles explicit security:[] overrides; this sweep
+	// handles the case where the entire API is public.
+	if result.Auth.Type == "none" && !result.Auth.Inferred {
+		markAllEndpointsNoAuth(result.Resources)
+	}
+
 	var perEndpointHeaders map[string]map[string]string
 	result.RequiredHeaders, perEndpointHeaders = detectRequiredHeaders(doc, result.Auth)
 	applyHeaderOverrides(result, perEndpointHeaders)
@@ -1028,6 +1037,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			if strings.ToUpper(method) == "GET" {
 				endpoint.Pagination = detectPagination(endpoint.Params, op)
 			}
+			endpoint.NoAuth = operationAllowsAnonymous(op, doc)
 			targetEndpoints[endpointName] = endpoint
 		}
 
@@ -1049,6 +1059,55 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 	filterGlobalParams(out.Resources)
 }
 
+// operationAllowsAnonymous checks whether an operation can be called without
+// authentication. Returns true when:
+//   - The operation has security: [] (explicit opt-out)
+//   - The operation has security: [{}] (empty object = anonymous alternative)
+//   - The operation inherits global security and the global security is empty
+func operationAllowsAnonymous(op *openapi3.Operation, doc *openapi3.T) bool {
+	if op.Security != nil {
+		// Per-operation security declared
+		if len(*op.Security) == 0 {
+			return true // security: []
+		}
+		for _, req := range *op.Security {
+			if len(req) == 0 {
+				return true // security: [{}]
+			}
+		}
+		return false
+	}
+	// op.Security is nil — inherits global security
+	if doc.Security != nil && len(doc.Security) == 0 {
+		return true // global security: []
+	}
+	for _, req := range doc.Security {
+		if len(req) == 0 {
+			return true // global security: [{}]
+		}
+	}
+	return false
+}
+
+// markAllEndpointsNoAuth sets NoAuth=true on every endpoint across all
+// resources and sub-resources. Used when the spec has no authentication.
+func markAllEndpointsNoAuth(resources map[string]spec.Resource) {
+	for name, r := range resources {
+		for eName, e := range r.Endpoints {
+			e.NoAuth = true
+			r.Endpoints[eName] = e
+		}
+		for subName, sub := range r.SubResources {
+			for eName, e := range sub.Endpoints {
+				e.NoAuth = true
+				sub.Endpoints[eName] = e
+			}
+			r.SubResources[subName] = sub
+		}
+		resources[name] = r
+	}
+}
+
 func assignEndpointAliases(resources map[string]spec.Resource) {
 	resourceNames := make([]string, 0, len(resources))
 	for name := range resources {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index cc1274c9..c872da54 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -1,6 +1,7 @@
 package openapi
 
 import (
+	"encoding/json"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -723,7 +724,7 @@ func TestAuthTierPrecedence(t *testing.T) {
 		assert.False(t, parsed.Auth.Inferred, "explicit auth from securitySchemes should not be marked as inferred")
 	})
 
-	t.Run("query-param auth (tier 2) wins over description (tier 3)", func(t *testing.T) {
+	t.Run("query-param auth tier 2 wins over description tier 3", func(t *testing.T) {
 		// Build a minimal spec with auth-like query params on >30% of ops
 		// AND bearer keyword in description. Tier 2 should win.
 		doc := &openapi3.T{
@@ -760,3 +761,168 @@ func TestAuthTierPrecedence(t *testing.T) {
 		assert.False(t, result.Inferred, "query-param auth is not 'inferred from description'")
 	})
 }
+
+func TestNoAuthDetection(t *testing.T) {
+	t.Parallel()
+
+	t.Run("mixed-auth fixture: per-operation security overrides", func(t *testing.T) {
+		t.Parallel()
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "mixed-auth.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		// stores.listStores has security: [] — should be NoAuth
+		stores := parsed.Resources["stores"]
+		require.NotNil(t, stores)
+		for _, e := range stores.Endpoints {
+			if e.Path == "/stores" && e.Method == "GET" {
+				assert.True(t, e.NoAuth, "stores GET with security:[] should be NoAuth")
+			}
+		}
+
+		// menus.getMenu has security: [{}] — should be NoAuth
+		menus := parsed.Resources["menus"]
+		require.NotNil(t, menus)
+		for _, e := range menus.Endpoints {
+			if e.Path == "/menus" && e.Method == "GET" {
+				assert.True(t, e.NoAuth, "menus GET with security:[{}] should be NoAuth")
+			}
+		}
+
+		// orders.listOrders inherits global ApiKeyAuth — should NOT be NoAuth
+		orders := parsed.Resources["orders"]
+		require.NotNil(t, orders)
+		for _, e := range orders.Endpoints {
+			if e.Path == "/orders" && e.Method == "GET" {
+				assert.False(t, e.NoAuth, "orders GET inheriting global auth should not be NoAuth")
+			}
+			if e.Path == "/orders" && e.Method == "POST" {
+				assert.False(t, e.NoAuth, "orders POST with explicit ApiKeyAuth should not be NoAuth")
+			}
+		}
+
+		// account.getAccount inherits global ApiKeyAuth — should NOT be NoAuth
+		account := parsed.Resources["account"]
+		require.NotNil(t, account)
+		for _, e := range account.Endpoints {
+			if e.Path == "/account" && e.Method == "GET" {
+				assert.False(t, e.NoAuth, "account GET inheriting global auth should not be NoAuth")
+			}
+		}
+	})
+
+	t.Run("spec with no auth at all marks all endpoints NoAuth", func(t *testing.T) {
+		t.Parallel()
+		// Build a spec with no securitySchemes, no global security
+		doc := &openapi3.T{
+			OpenAPI: "3.0.3",
+			Info:    &openapi3.Info{Title: "No Auth API", Version: "1.0.0"},
+			Paths:   &openapi3.Paths{},
+			Servers: openapi3.Servers{{URL: "https://api.example.com"}},
+		}
+		doc.Paths.Set("/items", &openapi3.PathItem{
+			Get: &openapi3.Operation{
+				Summary:   "List items",
+				Responses: openapi3.NewResponses(),
+			},
+		})
+		doc.Paths.Set("/items/{id}", &openapi3.PathItem{
+			Get: &openapi3.Operation{
+				Summary:   "Get item",
+				Responses: openapi3.NewResponses(),
+				Parameters: openapi3.Parameters{
+					&openapi3.ParameterRef{Value: &openapi3.Parameter{
+						Name: "id", In: "path", Required: true,
+						Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}},
+					}},
+				},
+			},
+		})
+
+		parsed, err := Parse(mustMarshalJSON(t, doc))
+		require.NoError(t, err)
+
+		assert.Equal(t, "none", parsed.Auth.Type)
+		// All endpoints should be NoAuth via post-parse sweep
+		for _, r := range parsed.Resources {
+			for eName, e := range r.Endpoints {
+				assert.True(t, e.NoAuth, "endpoint %s should be NoAuth in no-auth spec", eName)
+			}
+		}
+	})
+
+	t.Run("global security empty array marks inherited endpoints NoAuth", func(t *testing.T) {
+		t.Parallel()
+		// Use raw YAML to preserve the security: [] distinction
+		yamlSpec := []byte(`openapi: "3.0.3"
+info:
+  title: Global Empty Security
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com
+security: []
+components:
+  securitySchemes:
+    ApiKey:
+      type: apiKey
+      name: X-Api-Key
+      in: header
+paths:
+  /public:
+    get:
+      summary: Public endpoint
+      responses:
+        "200":
+          description: OK
+  /private:
+    get:
+      summary: Private endpoint
+      security:
+        - ApiKey: []
+      responses:
+        "200":
+          description: OK
+`)
+		parsed, err := Parse(yamlSpec)
+		require.NoError(t, err)
+
+		// /public inherits global security:[] — should be NoAuth
+		foundPublic := false
+		foundPrivate := false
+		for _, r := range parsed.Resources {
+			for _, e := range r.Endpoints {
+				if e.Path == "/public" {
+					assert.True(t, e.NoAuth, "/public should be NoAuth from global security:[]")
+					foundPublic = true
+				}
+				if e.Path == "/private" {
+					assert.False(t, e.NoAuth, "/private has explicit ApiKey requirement")
+					foundPrivate = true
+				}
+			}
+		}
+		assert.True(t, foundPublic, "should have found /public endpoint")
+		assert.True(t, foundPrivate, "should have found /private endpoint")
+	})
+
+	t.Run("petstore still parses without regression", func(t *testing.T) {
+		t.Parallel()
+		data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "openapi", "petstore.yaml"))
+		require.NoError(t, err)
+
+		parsed, err := Parse(data)
+		require.NoError(t, err)
+
+		assert.Equal(t, "petstore", parsed.Name)
+		assert.True(t, len(parsed.Resources) > 0, "petstore should have resources")
+	})
+}
+
+func mustMarshalJSON(t *testing.T, v any) []byte {
+	t.Helper()
+	data, err := json.Marshal(v)
+	require.NoError(t, err)
+	return data
+}
diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index d8646244..15619747 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -14,6 +14,7 @@ import (
 	catalogpkg "github.com/mvanhorn/cli-printing-press/internal/catalog"
 	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/mvanhorn/cli-printing-press/internal/openapi"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/internal/version"
 )
 
@@ -25,19 +26,35 @@ const CLIManifestFilename = ".printing-press.json"
 // It is written to the root of each published CLI directory so the
 // folder is self-describing even in isolation.
 type CLIManifest struct {
-	SchemaVersion        int       `json:"schema_version"`
-	GeneratedAt          time.Time `json:"generated_at"`
-	PrintingPressVersion string    `json:"printing_press_version"`
-	APIName              string    `json:"api_name"`
-	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"`
+	SchemaVersion        int                    `json:"schema_version"`
+	GeneratedAt          time.Time              `json:"generated_at"`
+	PrintingPressVersion string                 `json:"printing_press_version"`
+	APIName              string                 `json:"api_name"`
+	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"`
+	AuthType             string                 `json:"auth_type,omitempty"`
+	AuthEnvVars          []string               `json:"auth_env_vars,omitempty"`
+	NovelFeatures        []NovelFeatureManifest `json:"novel_features,omitempty"`
+}
+
+// NovelFeatureManifest is a compact representation of a transcendence feature
+// for the CLI manifest and registry. Stripped of Rationale (which stays in
+// research.json and the README).
+type NovelFeatureManifest struct {
+	Name        string `json:"name"`
+	Command     string `json:"command"`
+	Description string `json:"description"`
 }
 
 // WriteCLIManifest marshals m as indented JSON and writes it to
@@ -68,16 +85,47 @@ func specChecksum(path string) (string, error) {
 	return "sha256:" + hex.EncodeToString(h[:]), nil
 }
 
+// computeMCPReady determines the MCP readiness level based on the auth type
+// and the public/total tool split.
+func computeMCPReady(authType string, publicTools int) string {
+	switch authType {
+	case "none", "api_key", "bearer_token":
+		return "full"
+	case "cookie", "composed":
+		if publicTools > 0 {
+			return "partial"
+		}
+		return "cli-only"
+	default:
+		return "full"
+	}
+}
+
+func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
+	if parsed == nil {
+		return
+	}
+	total, public := parsed.CountMCPTools()
+	m.MCPBinary = naming.MCP(parsed.Name)
+	m.MCPToolCount = total
+	m.MCPPublicToolCount = public
+	m.MCPReady = computeMCPReady(parsed.Auth.Type, public)
+	m.AuthType = parsed.Auth.Type
+	m.AuthEnvVars = parsed.Auth.EnvVars
+}
+
 // GenerateManifestParams holds the information available at generate time
 // for writing a CLI manifest. Unlike PublishWorkingCLI (which has full
 // PipelineState), the standalone generate command only knows the spec
 // sources and output directory.
 type GenerateManifestParams struct {
-	APIName   string
-	SpecSrcs  []string // --spec args (URLs or file paths)
-	SpecURL   string   // --spec-url: explicit provenance URL (when --spec is a local downloaded file)
-	DocsURL   string   // --docs URL, if used
-	OutputDir string
+	APIName       string
+	SpecSrcs      []string // --spec args (URLs or file paths)
+	SpecURL       string   // --spec-url: explicit provenance URL (when --spec is a local downloaded file)
+	DocsURL       string   // --docs URL, if used
+	OutputDir     string
+	Spec          *spec.APISpec          // parsed spec for MCP metadata (nil if unavailable)
+	NovelFeatures []NovelFeatureManifest // transcendence features from research (nil if unavailable)
 }
 
 // WriteManifestForGenerate writes a .printing-press.json manifest into the
@@ -145,6 +193,14 @@ func WriteManifestForGenerate(p GenerateManifestParams) error {
 		m.Description = entry.Description
 	}
 
+	// Populate MCP metadata from the parsed spec.
+	if p.Spec != nil {
+		populateMCPMetadata(&m, p.Spec)
+	}
+	if len(p.NovelFeatures) > 0 {
+		m.NovelFeatures = p.NovelFeatures
+	}
+
 	return WriteCLIManifest(p.OutputDir, m)
 }
 
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index e2dbc9c4..7dd71966 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -9,9 +9,11 @@ import (
 	"testing"
 	"time"
 
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/internal/version"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v3"
 )
 
 func TestWriteCLIManifest(t *testing.T) {
@@ -290,6 +292,58 @@ func TestPublishWorkingCLIManifestWithoutSpec(t *testing.T) {
 	assert.Empty(t, got.SpecFormat)
 }
 
+func TestPublishWorkingCLIWritesMCPMetadataForInternalSpec(t *testing.T) {
+	home := setPressTestEnv(t)
+
+	workingDir := filepath.Join(home, "working", "internal-spec-pp-cli")
+	require.NoError(t, os.MkdirAll(workingDir, 0o755))
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "main.go"),
+		[]byte("package main\nfunc main() {}"),
+		0o644,
+	))
+	require.NoError(t, os.WriteFile(
+		filepath.Join(workingDir, "spec.json"),
+		[]byte(`
+name: internal-spec
+base_url: https://api.example.com
+auth:
+  type: bearer_token
+  env_vars:
+    - INTERNAL_SPEC_TOKEN
+resources:
+  items:
+    description: Items
+    endpoints:
+      list:
+        method: GET
+        path: /items
+        no_auth: true
+`),
+		0o644,
+	))
+
+	state := NewState("internal-spec", workingDir)
+	require.NoError(t, os.MkdirAll(filepath.Dir(state.StatePath()), 0o755))
+	require.NoError(t, state.Save())
+
+	finalDir, err := PublishWorkingCLI(state, filepath.Join(home, "library", "internal-spec-pp-cli"))
+	require.NoError(t, err)
+
+	data, err := os.ReadFile(filepath.Join(finalDir, CLIManifestFilename))
+	require.NoError(t, err)
+
+	var got CLIManifest
+	require.NoError(t, json.Unmarshal(data, &got))
+	assert.Equal(t, "internal", got.SpecFormat)
+	assert.Equal(t, "internal-spec-pp-mcp", got.MCPBinary)
+	assert.Equal(t, 1, got.MCPToolCount)
+	assert.Equal(t, 1, got.MCPPublicToolCount)
+	assert.Equal(t, "full", got.MCPReady)
+	assert.Equal(t, "bearer_token", got.AuthType)
+	assert.Equal(t, []string{"INTERNAL_SPEC_TOKEN"}, got.AuthEnvVars)
+}
+
 func TestWriteManifestForGenerateWithSpecURL(t *testing.T) {
 	dir := t.TempDir()
 
@@ -449,6 +503,121 @@ func TestArchiveRunArtifactsSkipsMissingDiscovery(t *testing.T) {
 	assert.DirExists(t, ArchivedResearchDir(state.APIName, state.RunID))
 }
 
+func TestComputeMCPReady(t *testing.T) {
+	tests := []struct {
+		name        string
+		authType    string
+		publicTools int
+		want        string
+	}{
+		{"none", "none", 0, "full"},
+		{"api_key", "api_key", 0, "full"},
+		{"bearer_token", "bearer_token", 0, "full"},
+		{"oauth2 defaults to full", "oauth2", 0, "full"},
+		{"cookie with public tools", "cookie", 3, "partial"},
+		{"cookie no public tools", "cookie", 0, "cli-only"},
+		{"composed with public tools", "composed", 5, "partial"},
+		{"composed no public tools", "composed", 0, "cli-only"},
+		{"empty auth type", "", 0, "full"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := computeMCPReady(tt.authType, tt.publicTools)
+			assert.Equal(t, tt.want, got)
+		})
+	}
+}
+
+func TestWriteSmitheryYAML(t *testing.T) {
+	t.Run("no manifest file — no smithery written", func(t *testing.T) {
+		dir := t.TempDir()
+		err := writeSmitheryYAML(dir)
+		require.NoError(t, err)
+		_, statErr := os.Stat(filepath.Join(dir, "smithery.yaml"))
+		assert.True(t, os.IsNotExist(statErr))
+	})
+
+	t.Run("cli-only readiness — no smithery written", 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))
+
+		err := writeSmitheryYAML(dir)
+		require.NoError(t, err)
+		_, statErr := os.Stat(filepath.Join(dir, "smithery.yaml"))
+		assert.True(t, os.IsNotExist(statErr))
+	})
+
+	t.Run("api_key auth — env vars required", func(t *testing.T) {
+		dir := t.TempDir()
+		m := CLIManifest{
+			MCPBinary:   "stripe-pp-mcp",
+			MCPReady:    "full",
+			AuthType:    "api_key",
+			AuthEnvVars: []string{"STRIPE_API_KEY"},
+			APIName:     "stripe",
+			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")
+	})
+
+	t.Run("cookie auth — env vars optional", func(t *testing.T) {
+		dir := t.TempDir()
+		m := CLIManifest{
+			MCPBinary:   "pizza-pp-mcp",
+			MCPReady:    "partial",
+			AuthType:    "cookie",
+			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)
+
+		content, err := os.ReadFile(filepath.Join(dir, "smithery.yaml"))
+		require.NoError(t, err)
+		assert.Contains(t, string(content), "required: false")
+	})
+
+	t.Run("description with special characters is safely escaped", func(t *testing.T) {
+		dir := t.TempDir()
+		m := CLIManifest{
+			MCPBinary:   "test-pp-mcp",
+			MCPReady:    "full",
+			APIName:     "test",
+			Description: `Notion: "All-in-one" workspace & collaboration`,
+		}
+		data, _ := json.Marshal(m)
+		require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
+
+		err := writeSmitheryYAML(dir)
+		require.NoError(t, err)
+
+		// 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]interface{}
+		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")
+	})
+}
+
 func TestDetectSpecFormat(t *testing.T) {
 	tests := []struct {
 		name     string
@@ -493,3 +662,29 @@ func TestDetectSpecFormat(t *testing.T) {
 		})
 	}
 }
+
+func TestPopulateMCPMetadata(t *testing.T) {
+	var m CLIManifest
+	populateMCPMetadata(&m, &spec.APISpec{
+		Name: "test",
+		Auth: spec.AuthConfig{
+			Type:    "cookie",
+			EnvVars: []string{"TEST_AUTH"},
+		},
+		Resources: map[string]spec.Resource{
+			"items": {
+				Endpoints: map[string]spec.Endpoint{
+					"list":   {Method: "GET", Path: "/items", NoAuth: true},
+					"create": {Method: "POST", Path: "/items"},
+				},
+			},
+		},
+	})
+
+	assert.Equal(t, "test-pp-mcp", m.MCPBinary)
+	assert.Equal(t, 2, m.MCPToolCount)
+	assert.Equal(t, 1, m.MCPPublicToolCount)
+	assert.Equal(t, "partial", m.MCPReady)
+	assert.Equal(t, "cookie", m.AuthType)
+	assert.Equal(t, []string{"TEST_AUTH"}, m.AuthEnvVars)
+}
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index 8ed602e2..00f38d8b 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -223,6 +223,11 @@ 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)
+	}
+
 	// Remove any stale backup from a prior successful swap before we create a
 	// fresh backup for the current library contents.
 	if _, err := os.Stat(backupDir); err == nil {
diff --git a/internal/pipeline/publish.go b/internal/pipeline/publish.go
index 88962ce6..addb794a 100644
--- a/internal/pipeline/publish.go
+++ b/internal/pipeline/publish.go
@@ -12,8 +12,13 @@ import (
 
 	"github.com/mvanhorn/cli-printing-press/catalog"
 	catalogpkg "github.com/mvanhorn/cli-printing-press/internal/catalog"
+	"github.com/mvanhorn/cli-printing-press/internal/graphql"
 	"github.com/mvanhorn/cli-printing-press/internal/naming"
+	"github.com/mvanhorn/cli-printing-press/internal/openapi"
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
 	"github.com/mvanhorn/cli-printing-press/internal/version"
+
+	"gopkg.in/yaml.v3"
 )
 
 type RunManifest struct {
@@ -110,6 +115,12 @@ 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)
+	}
+
 	if err := state.Save(); err != nil {
 		return "", err
 	}
@@ -192,6 +203,20 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		RunID:                state.RunID,
 	}
 
+	// Carry forward metadata from the generated manifest when publish-time
+	// parsing is unavailable or lossy for the original spec format.
+	if existingData, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename)); err == nil {
+		var existing CLIManifest
+		if json.Unmarshal(existingData, &existing) == nil {
+			m.MCPBinary = existing.MCPBinary
+			m.MCPToolCount = existing.MCPToolCount
+			m.MCPPublicToolCount = existing.MCPPublicToolCount
+			m.MCPReady = existing.MCPReady
+			m.AuthType = existing.AuthType
+			m.AuthEnvVars = existing.AuthEnvVars
+		}
+	}
+
 	// Detect spec format and compute checksum from the spec file in the
 	// working directory. spec.json only exists when specFlag is --spec;
 	// for --docs runs it won't be present and these fields stay empty.
@@ -202,6 +227,25 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		if err == nil {
 			m.SpecChecksum = checksum
 		}
+
+		// Populate MCP metadata from the source spec when possible.
+		// If parsing fails, keep any carried-forward values from the generated
+		// manifest so non-OpenAPI CLIs do not lose MCP metadata at publish time.
+		var (
+			parsed   *spec.APISpec
+			parseErr error
+		)
+		switch m.SpecFormat {
+		case "openapi3":
+			parsed, parseErr = openapi.Parse(data)
+		case "graphql":
+			parsed, parseErr = graphql.ParseSDLBytes(specFile, data)
+		case "internal":
+			parsed, parseErr = spec.ParseBytes(data)
+		}
+		if parseErr == nil {
+			populateMCPMetadata(&m, parsed)
+		}
 	}
 
 	// Look up catalog entry by API name; empty string if not found.
@@ -211,9 +255,92 @@ func writeCLIManifestForPublish(state *PipelineState, dir string) error {
 		m.Description = entry.Description
 	}
 
+	// Load novel features from research.json if available.
+	if research, err := LoadResearch(state.PipelineDir()); err == nil && research.NovelFeaturesBuilt != nil {
+		for _, nf := range *research.NovelFeaturesBuilt {
+			m.NovelFeatures = append(m.NovelFeatures, NovelFeatureManifest{
+				Name:        nf.Name,
+				Command:     nf.Command,
+				Description: nf.Description,
+			})
+		}
+	}
+
 	return WriteCLIManifest(dir, m)
 }
 
+// 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/pipeline/renamecli.go b/internal/pipeline/renamecli.go
index 6e152e22..0851fbf2 100644
--- a/internal/pipeline/renamecli.go
+++ b/internal/pipeline/renamecli.go
@@ -30,6 +30,8 @@ func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error)
 	if err := validateRenameInputs(oldCLIName, newCLIName); err != nil {
 		return 0, err
 	}
+	oldMCPName := naming.MCP(naming.TrimCLISuffix(oldCLIName))
+	newMCPName := naming.MCP(naming.TrimCLISuffix(newCLIName))
 
 	// Path traversal protection: verify the directory and new name resolve
 	// within the expected parent.
@@ -78,6 +80,7 @@ func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error)
 		}
 
 		result := strings.ReplaceAll(string(content), oldCLIName, newCLIName)
+		result = strings.ReplaceAll(result, oldMCPName, newMCPName)
 		if result == string(content) {
 			return nil
 		}
@@ -99,6 +102,9 @@ func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error)
 		if jsonErr := json.Unmarshal(manifestData, &m); jsonErr == nil {
 			m.CLIName = newCLIName
 			m.APIName = originalAPIName
+			if m.MCPBinary != "" {
+				m.MCPBinary = newMCPName
+			}
 			if writeErr := WriteCLIManifest(absDir, m); writeErr != nil {
 				return filesModified, fmt.Errorf("updating manifest: %w", writeErr)
 			}
@@ -115,6 +121,15 @@ func RenameCLI(dir, oldCLIName, newCLIName, originalAPIName string) (int, error)
 		}
 	}
 
+	// 3b. Rename cmd/ MCP subdirectory if it exists.
+	oldMCPDir := filepath.Join(absDir, "cmd", oldMCPName)
+	newMCPDir := filepath.Join(absDir, "cmd", newMCPName)
+	if _, err := os.Stat(oldMCPDir); err == nil {
+		if err := os.Rename(oldMCPDir, newMCPDir); err != nil {
+			return filesModified, fmt.Errorf("renaming MCP cmd directory: %w", err)
+		}
+	}
+
 	// 4. Rename outer directory last (changes the path for the caller).
 	if err := os.Rename(absDir, absNew); err != nil {
 		return filesModified, fmt.Errorf("renaming CLI directory: %w", err)
diff --git a/internal/pipeline/renamecli_test.go b/internal/pipeline/renamecli_test.go
index 17adf3d9..9843d11b 100644
--- a/internal/pipeline/renamecli_test.go
+++ b/internal/pipeline/renamecli_test.go
@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	"github.com/mvanhorn/cli-printing-press/internal/naming"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -14,6 +15,7 @@ import (
 // It includes files that should be renamed and files that should survive unchanged.
 func writeTestCLITree(t *testing.T, dir string, cliName, apiName string) {
 	t.Helper()
+	mcpName := naming.MCP(naming.TrimCLISuffix(cliName))
 
 	// cmd/<cli-name>/main.go
 	cmdDir := filepath.Join(dir, "cmd", cliName)
@@ -27,6 +29,17 @@ import (
 func main() {
 	cli.Execute()
 }
+`), 0o644))
+
+	// cmd/<mcp-name>/main.go
+	mcpDir := filepath.Join(dir, "cmd", mcpName)
+	require.NoError(t, os.MkdirAll(mcpDir, 0o755))
+	require.NoError(t, os.WriteFile(filepath.Join(mcpDir, "main.go"), []byte(`package main
+
+func main() {
+	serverName := "`+mcpName+`"
+	_ = serverName
+}
 `), 0o644))
 
 	// internal/cli/root.go — contains both import paths and runtime literals
@@ -64,16 +77,21 @@ func (c *Client) do() {
 project_name: `+cliName+`
 builds:
   - binary: `+cliName+`
+  - binary: `+mcpName+`
     ldflags:
       - -s -w -X `+cliName+`/internal/cli.version={{ .Version }}
 brews:
   - name: `+cliName+`
-    install: bin.install "`+cliName+`"
+    install: |
+      bin.install "`+cliName+`"
+      bin.install "`+mcpName+`"
 `), 0o644))
 
 	// Makefile
 	require.NoError(t, os.WriteFile(filepath.Join(dir, "Makefile"), []byte(`build:
 	go build -o `+cliName+` ./cmd/`+cliName+`
+build-mcp:
+	go build -o `+mcpName+` ./cmd/`+mcpName+`
 `), 0o644))
 
 	// README.md
@@ -87,6 +105,12 @@ CLI for the `+apiName+` API.
 `+cliName+` doctor
 `+cliName+` users list
 `+"```"+`
+
+## MCP
+
+`+"```"+`
+claude mcp add `+apiName+` `+mcpName+`
+`+"```"+`
 `), 0o644))
 
 	// go.mod (module path uses bare CLI name, as generated CLIs do)
@@ -106,6 +130,7 @@ go 1.24
 		SchemaVersion: 1,
 		APIName:       apiName,
 		CLIName:       cliName,
+		MCPBinary:     mcpName,
 	}
 	data, _ := json.MarshalIndent(m, "", "  ")
 	require.NoError(t, os.WriteFile(filepath.Join(dir, CLIManifestFilename), data, 0o644))
@@ -119,6 +144,8 @@ func TestRenameCLI(t *testing.T) {
 		oldName := "notion-pp-cli"
 		newName := "notion-alt-pp-cli"
 		apiName := "notion"
+		oldMCPName := naming.MCP(naming.TrimCLISuffix(oldName))
+		newMCPName := naming.MCP(naming.TrimCLISuffix(newName))
 
 		cliDir := filepath.Join(root, oldName)
 		require.NoError(t, os.MkdirAll(cliDir, 0o755))
@@ -140,6 +167,10 @@ func TestRenameCLI(t *testing.T) {
 		assert.NoError(t, err, "new cmd directory should exist")
 		_, err = os.Stat(filepath.Join(newDir, "cmd", oldName))
 		assert.ErrorIs(t, err, os.ErrNotExist, "old cmd directory should not exist")
+		_, err = os.Stat(filepath.Join(newDir, "cmd", newMCPName))
+		assert.NoError(t, err, "new MCP cmd directory should exist")
+		_, err = os.Stat(filepath.Join(newDir, "cmd", oldMCPName))
+		assert.ErrorIs(t, err, os.ErrNotExist, "old MCP cmd directory should not exist")
 
 		// root.go should have new name in Use and version template
 		rootGo, err := os.ReadFile(filepath.Join(newDir, "internal", "cli", "root.go"))
@@ -160,28 +191,36 @@ func TestRenameCLI(t *testing.T) {
 		grContent := string(goreleaser)
 		assert.Contains(t, grContent, "project_name: "+newName)
 		assert.Contains(t, grContent, "binary: "+newName)
+		assert.Contains(t, grContent, "binary: "+newMCPName)
 		assert.Contains(t, grContent, `install "`+newName+`"`)
+		assert.Contains(t, grContent, `install "`+newMCPName+`"`)
 		assert.NotContains(t, grContent, oldName)
+		assert.NotContains(t, grContent, oldMCPName)
 
 		// Makefile should have new name
 		makefile, err := os.ReadFile(filepath.Join(newDir, "Makefile"))
 		require.NoError(t, err)
 		assert.Contains(t, string(makefile), newName)
 		assert.NotContains(t, string(makefile), oldName)
+		assert.Contains(t, string(makefile), newMCPName)
+		assert.NotContains(t, string(makefile), oldMCPName)
 
 		// README should have new name
 		readme, err := os.ReadFile(filepath.Join(newDir, "README.md"))
 		require.NoError(t, err)
 		assert.Contains(t, string(readme), "# "+newName)
 		assert.NotContains(t, string(readme), oldName)
+		assert.Contains(t, string(readme), newMCPName)
+		assert.NotContains(t, string(readme), oldMCPName)
 
-		// Manifest should have new cli_name, original api_name
+		// Manifest should have new cli_name, original api_name, and new MCP binary
 		mData, err := os.ReadFile(filepath.Join(newDir, CLIManifestFilename))
 		require.NoError(t, err)
 		var m CLIManifest
 		require.NoError(t, json.Unmarshal(mData, &m))
 		assert.Equal(t, newName, m.CLIName)
 		assert.Equal(t, apiName, m.APIName)
+		assert.Equal(t, newMCPName, m.MCPBinary)
 	})
 
 	t.Run("numeric qualifier renames correctly", func(t *testing.T) {
diff --git a/internal/pipeline/scorecard.go b/internal/pipeline/scorecard.go
index 1dd2dcce..e8645404 100644
--- a/internal/pipeline/scorecard.go
+++ b/internal/pipeline/scorecard.go
@@ -184,6 +184,14 @@ func RunScorecard(outputDir, pipelineDir, specPath string, verifyReport *VerifyR
 	// Gap report for dimensions below 5
 	sc.GapReport = buildGapReport(sc.Steinberger, sc.UnscoredDimensions)
 
+	// MCP tool split from manifest (informational, does not affect score)
+	if manifest, err := loadCLIManifestForScorecard(outputDir); err == nil && manifest.MCPBinary != "" {
+		authCount := manifest.MCPToolCount - manifest.MCPPublicToolCount
+		sc.GapReport = append(sc.GapReport,
+			fmt.Sprintf("MCP: %d tools (%d public, %d auth-required) — readiness: %s",
+				manifest.MCPToolCount, manifest.MCPPublicToolCount, authCount, manifest.MCPReady))
+	}
+
 	// Competitor comparison from research.json
 	sc.CompetitorScores = buildCompetitorScores(sc.Steinberger.Total, pipelineDir)
 
@@ -1729,6 +1737,21 @@ func computeGrade(percentage int) string {
 	}
 }
 
+// loadCLIManifestForScorecard reads .printing-press.json from the CLI directory.
+// Returns an empty manifest (not error) if the file does not exist, so callers
+// can check MCPBinary != "" to decide whether to show MCP info.
+func loadCLIManifestForScorecard(outputDir string) (CLIManifest, error) {
+	data, err := os.ReadFile(filepath.Join(outputDir, CLIManifestFilename))
+	if err != nil {
+		return CLIManifest{}, err
+	}
+	var m CLIManifest
+	if err := json.Unmarshal(data, &m); err != nil {
+		return CLIManifest{}, err
+	}
+	return m, nil
+}
+
 func buildGapReport(s SteinerScore, unscored []string) []string {
 	var gaps []string
 	unscoredSet := make(map[string]struct{}, len(unscored))
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 4351be04..a879dc19 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -74,6 +74,7 @@ type Endpoint struct {
 	ResponsePath    string            `yaml:"response_path,omitempty" json:"response_path,omitempty"`       // path to extract data array from response (e.g., "data", "results.items")
 	Meta            map[string]string `yaml:"meta,omitempty" json:"meta,omitempty"`                         // per-endpoint metadata (e.g., source_tier, source_count from crowd-sniff)
 	HeaderOverrides []RequiredHeader  `yaml:"header_overrides,omitempty" json:"header_overrides,omitempty"` // per-endpoint header overrides (e.g., different api-version)
+	NoAuth          bool              `yaml:"no_auth,omitempty" json:"no_auth,omitempty"`                   // true when the endpoint does not require authentication
 	Alias           string            `yaml:"-" json:"-"`                                                   // computed, not from YAML
 }
 
@@ -188,3 +189,25 @@ func (s *APISpec) Validate() error {
 	}
 	return nil
 }
+
+// CountMCPTools counts total endpoints and public (NoAuth) endpoints across
+// all resources and sub-resources.
+func (s *APISpec) CountMCPTools() (total, public int) {
+	for _, r := range s.Resources {
+		for _, e := range r.Endpoints {
+			total++
+			if e.NoAuth {
+				public++
+			}
+		}
+		for _, sub := range r.SubResources {
+			for _, e := range sub.Endpoints {
+				total++
+				if e.NoAuth {
+					public++
+				}
+			}
+		}
+	}
+	return
+}
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index f4dcf9a7..32e2fa28 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -199,6 +199,99 @@ resources:
 	})
 }
 
+func TestEndpointNoAuth(t *testing.T) {
+	t.Parallel()
+
+	t.Run("parse YAML with no_auth set", func(t *testing.T) {
+		t.Parallel()
+		input := `
+name: test
+base_url: http://x
+resources:
+  stores:
+    description: Stores
+    endpoints:
+      list:
+        method: GET
+        path: /stores
+        no_auth: true
+`
+		var s APISpec
+		require.NoError(t, yaml.Unmarshal([]byte(input), &s))
+		require.NoError(t, s.Validate())
+		ep := s.Resources["stores"].Endpoints["list"]
+		assert.True(t, ep.NoAuth)
+	})
+
+	t.Run("parse YAML without no_auth field", func(t *testing.T) {
+		t.Parallel()
+		input := `
+name: test
+base_url: http://x
+resources:
+  users:
+    description: Users
+    endpoints:
+      list:
+        method: GET
+        path: /users
+`
+		var s APISpec
+		require.NoError(t, yaml.Unmarshal([]byte(input), &s))
+		ep := s.Resources["users"].Endpoints["list"]
+		assert.False(t, ep.NoAuth)
+	})
+
+	t.Run("marshal with no_auth true includes field", func(t *testing.T) {
+		t.Parallel()
+		ep := Endpoint{Method: "GET", Path: "/stores", NoAuth: true}
+		data, err := yaml.Marshal(ep)
+		require.NoError(t, err)
+		assert.Contains(t, string(data), "no_auth: true")
+	})
+
+	t.Run("marshal with no_auth false omits field", func(t *testing.T) {
+		t.Parallel()
+		ep := Endpoint{Method: "GET", Path: "/users"}
+		data, err := yaml.Marshal(ep)
+		require.NoError(t, err)
+		assert.NotContains(t, string(data), "no_auth")
+	})
+}
+
+func TestCountMCPTools(t *testing.T) {
+	t.Parallel()
+	s := APISpec{
+		Name:    "test",
+		BaseURL: "http://x",
+		Resources: map[string]Resource{
+			"stores": {
+				Endpoints: map[string]Endpoint{
+					"list": {Method: "GET", Path: "/stores", NoAuth: true},
+					"get":  {Method: "GET", Path: "/stores/{id}", NoAuth: true},
+				},
+				SubResources: map[string]Resource{
+					"menus": {
+						Endpoints: map[string]Endpoint{
+							"list": {Method: "GET", Path: "/stores/{id}/menus", NoAuth: true},
+						},
+					},
+				},
+			},
+			"orders": {
+				Endpoints: map[string]Endpoint{
+					"list":   {Method: "GET", Path: "/orders"},
+					"create": {Method: "POST", Path: "/orders"},
+				},
+			},
+		},
+	}
+
+	total, public := s.CountMCPTools()
+	assert.Equal(t, 5, total, "should count all endpoints including sub-resources")
+	assert.Equal(t, 3, public, "should count only NoAuth endpoints")
+}
+
 // --- Unit 5: YAML Format Safety Net Tests ---
 
 func TestParseBytesYAMLVariations(t *testing.T) {
diff --git a/skills/printing-press-publish/SKILL.md b/skills/printing-press-publish/SKILL.md
index 6ac475c4..b34faea3 100644
--- a/skills/printing-press-publish/SKILL.md
+++ b/skills/printing-press-publish/SKILL.md
@@ -572,7 +572,16 @@ The registry file has this structure:
       "category": "<category>",
       "api": "<api-display-name>",
       "description": "<from manifest or README>",
-      "path": "library/<category>/<cli-name>"
+      "path": "library/<category>/<cli-name>",
+      "mcp": {
+        "binary": "<name>-pp-mcp",
+        "transport": "stdio",
+        "tool_count": 42,
+        "public_tool_count": 7,
+        "auth_type": "<api_key|none|bearer_token|cookie|composed>",
+        "env_vars": ["<ENV_VAR_NAME>"],
+        "mcp_ready": "<full|partial|cli-only>"
+      }
     }
   ]
 }
@@ -580,6 +589,17 @@ The registry file has this structure:
 
 Read `$PUBLISH_REPO_DIR/registry.json`, parse the `entries` array (not the top-level object), add or update the entry for this CLI. Match on `name` field. Preserve `schema_version` and any other top-level fields.
 
+**MCP metadata:** If the CLI's `.printing-press.json` manifest has MCP fields (`mcp_binary` is non-empty), populate the `mcp` block in the registry entry:
+- `binary`: from manifest `mcp_binary`
+- `transport`: always `"stdio"`
+- `tool_count`: from manifest `mcp_tool_count`
+- `public_tool_count`: from manifest `mcp_public_tool_count`
+- `auth_type`: from manifest `auth_type`
+- `env_vars`: from manifest `auth_env_vars`
+- `mcp_ready`: from manifest `mcp_ready`
+
+If the manifest has no MCP fields (empty `mcp_binary`), omit the `mcp` block entirely.
+
 Write back with `jq` or via the Write tool.
 
 ### Commit and push
diff --git a/testdata/openapi/mixed-auth.yaml b/testdata/openapi/mixed-auth.yaml
new file mode 100644
index 00000000..ee2c4b13
--- /dev/null
+++ b/testdata/openapi/mixed-auth.yaml
@@ -0,0 +1,59 @@
+openapi: "3.0.3"
+info:
+  title: Mixed Auth API
+  version: "1.0.0"
+servers:
+  - url: https://api.example.com/v1
+security:
+  - ApiKeyAuth: []
+components:
+  securitySchemes:
+    ApiKeyAuth:
+      type: apiKey
+      in: header
+      name: X-API-Key
+paths:
+  /stores:
+    get:
+      operationId: listStores
+      summary: List stores
+      description: List all store locations
+      security: []
+      responses:
+        "200":
+          description: OK
+  /menus:
+    get:
+      operationId: getMenu
+      summary: Get menu
+      description: Get menu for a store
+      security:
+        - {}
+      responses:
+        "200":
+          description: OK
+  /orders:
+    get:
+      operationId: listOrders
+      summary: List orders
+      description: List your orders
+      responses:
+        "200":
+          description: OK
+    post:
+      operationId: createOrder
+      summary: Create order
+      description: Place a new order
+      security:
+        - ApiKeyAuth: []
+      responses:
+        "201":
+          description: Created
+  /account:
+    get:
+      operationId: getAccount
+      summary: Get account
+      description: Get account details
+      responses:
+        "200":
+          description: OK

← 349580af fix(cli): Dub retro — FTS batch indexing, retry cap, dogfood  ·  back to Cli Printing Press  ·  fix(cli): sync version files to 1.1.0 and fix release-please 3393ada0 →