[object Object]

← back to Cli Printing Press

fix(cli): manifest emission no longer gated by stale mcp_ready label (#359)

2e892e368f3c3249a674b18da27402d47debbafa · 2026-04-28 00:26:52 -0700 · Trevin Chow

Generated CLIs now ship manifest.json regardless of mcp_ready, fixing
the Pagliacci-pizza class of bug where composed/cookie-auth CLIs with
unauthenticated tools (registration, login, public discovery) were
silently denied an MCPB install path.

Three-line root cause:

1. Spec authors rarely flag endpoints `no_auth: true` granularly.
2. computeMCPReady saw publicTools=0 for composed/cookie auth and
   returned "cli-only".
3. WriteMCPBManifestFromStruct skipped emission whenever MCPReady was
   "cli-only", suppressing the entire Claude Desktop install path even
   for CLIs whose MCPs registered 67+ tools (Pagliacci had login,
   registration, store/menu lookup tools that work without creds).

Two-pronged fix — machine plus skill — so future CLIs can't hit this:

Machine — internal/pipeline/mcpb_manifest.go:

- WriteMCPBManifestFromStruct's gate is now `m.MCPBinary == ""`. The
  cli-only check is gone. Withholding the manifest from cli-only CLIs
  only hurt users who could have installed the bundle and used the
  unauthenticated surface; user_config.required already conveys
  "needs creds" or "optional" via authRequiresCredential().

Machine — internal/pipeline/climanifest.go:

- computeMCPReady drops the publicTools-count gate entirely. cookie
  and composed always return "partial". cli-only is no longer
  produced by this function. Call signature dropped the now-unused
  publicTools parameter; one caller in toolsmanifest.go updated.
- Comment block on the function explains why: the `publicTools > 0`
  test relied on a tag spec authors rarely audit, producing systematic
  false-negative readiness labels for composed/cookie APIs.

Skill — skills/printing-press/SKILL.md (Pre-Generation Auth Enrichment):

New "Tagging endpoints `no_auth: true`" subsection prompts the agent
to audit each endpoint when auth is composed/cookie/session_handshake.
Spells out the typical unauthenticated categories — auth-flow
primitives (register, login, password-reset), public discovery
(store/menu browse, catalog), health/metadata — and gives a worked
pizza-ordering example. Notes that the safety net (machine fix above)
makes this defense-in-depth rather than load-bearing.

Tests:

- TestComputeMCPReady simplified: composed/cookie always partial,
  no more publicTools axis. Comment captures Pagliacci-pizza as the
  canonical regression case.
- TestWriteMCPBManifest's "cli-only readiness → skipped" case
  inverted to "cli-only readiness still emits manifest".

No golden update needed: golden-api.yaml uses api_key auth and is
unaffected by the composed/cookie path changes.

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

Files touched

Diff

commit 2e892e368f3c3249a674b18da27402d47debbafa
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue Apr 28 00:26:52 2026 -0700

    fix(cli): manifest emission no longer gated by stale mcp_ready label (#359)
    
    Generated CLIs now ship manifest.json regardless of mcp_ready, fixing
    the Pagliacci-pizza class of bug where composed/cookie-auth CLIs with
    unauthenticated tools (registration, login, public discovery) were
    silently denied an MCPB install path.
    
    Three-line root cause:
    
    1. Spec authors rarely flag endpoints `no_auth: true` granularly.
    2. computeMCPReady saw publicTools=0 for composed/cookie auth and
       returned "cli-only".
    3. WriteMCPBManifestFromStruct skipped emission whenever MCPReady was
       "cli-only", suppressing the entire Claude Desktop install path even
       for CLIs whose MCPs registered 67+ tools (Pagliacci had login,
       registration, store/menu lookup tools that work without creds).
    
    Two-pronged fix — machine plus skill — so future CLIs can't hit this:
    
    Machine — internal/pipeline/mcpb_manifest.go:
    
    - WriteMCPBManifestFromStruct's gate is now `m.MCPBinary == ""`. The
      cli-only check is gone. Withholding the manifest from cli-only CLIs
      only hurt users who could have installed the bundle and used the
      unauthenticated surface; user_config.required already conveys
      "needs creds" or "optional" via authRequiresCredential().
    
    Machine — internal/pipeline/climanifest.go:
    
    - computeMCPReady drops the publicTools-count gate entirely. cookie
      and composed always return "partial". cli-only is no longer
      produced by this function. Call signature dropped the now-unused
      publicTools parameter; one caller in toolsmanifest.go updated.
    - Comment block on the function explains why: the `publicTools > 0`
      test relied on a tag spec authors rarely audit, producing systematic
      false-negative readiness labels for composed/cookie APIs.
    
    Skill — skills/printing-press/SKILL.md (Pre-Generation Auth Enrichment):
    
    New "Tagging endpoints `no_auth: true`" subsection prompts the agent
    to audit each endpoint when auth is composed/cookie/session_handshake.
    Spells out the typical unauthenticated categories — auth-flow
    primitives (register, login, password-reset), public discovery
    (store/menu browse, catalog), health/metadata — and gives a worked
    pizza-ordering example. Notes that the safety net (machine fix above)
    makes this defense-in-depth rather than load-bearing.
    
    Tests:
    
    - TestComputeMCPReady simplified: composed/cookie always partial,
      no more publicTools axis. Comment captures Pagliacci-pizza as the
      canonical regression case.
    - TestWriteMCPBManifest's "cli-only readiness → skipped" case
      inverted to "cli-only readiness still emits manifest".
    
    No golden update needed: golden-api.yaml uses api_key auth and is
    unaffected by the composed/cookie path changes.
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/pipeline/climanifest.go      | 27 +++++++++++-----
 internal/pipeline/climanifest_test.go | 50 ++++++++++++++++++-----------
 internal/pipeline/mcpb_manifest.go    | 16 +++++++---
 internal/pipeline/toolsmanifest.go    |  2 +-
 skills/printing-press/SKILL.md        | 60 +++++++++++++++++++++++++++++++++++
 5 files changed, 123 insertions(+), 32 deletions(-)

diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index 15c1385f..893cef9f 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -158,17 +158,28 @@ 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 {
+// computeMCPReady determines the MCP readiness label for scorecard /
+// SKILL prose. It does NOT gate manifest emission — that decision lives
+// in WriteMCPBManifestFromStruct and is purely "do we have an MCP binary
+// to ship?". The label exists to set user expectations: full = every
+// tool works without per-tool auth setup; partial = some tools work
+// without credentials, others need auth provided through the companion
+// CLI's flow (composed, cookie). cli-only is reserved for the
+// degenerate case of no MCP tools at all and is rarely set in practice.
+//
+// Why no `publicTools > 0` gate for composed/cookie any more: the count
+// relies on spec authors tagging endpoints `no_auth: true`, which most
+// generated specs don't audit carefully. A composed-auth CLI with zero
+// no_auth tags was previously labeled cli-only even when many endpoints
+// (registration, login, public discovery) actually work without auth.
+// Defaulting to "partial" matches the typical reality and avoids
+// suppressing manifest emission downstream.
+func computeMCPReady(authType string) string {
 	switch authType {
 	case "none", "api_key", "bearer_token":
 		return "full"
 	case "cookie", "composed":
-		if publicTools > 0 {
-			return "partial"
-		}
-		return "cli-only"
+		return "partial"
 	default:
 		return "full"
 	}
@@ -182,7 +193,7 @@ func populateMCPMetadata(m *CLIManifest, parsed *spec.APISpec) {
 	m.MCPBinary = naming.MCP(parsed.Name)
 	m.MCPToolCount = total
 	m.MCPPublicToolCount = public
-	m.MCPReady = computeMCPReady(parsed.Auth.Type, public)
+	m.MCPReady = computeMCPReady(parsed.Auth.Type)
 	m.AuthType = parsed.Auth.Type
 	m.AuthEnvVars = parsed.Auth.EnvVars
 	m.AuthKeyURL = parsed.Auth.KeyURL
diff --git a/internal/pipeline/climanifest_test.go b/internal/pipeline/climanifest_test.go
index ab6a4e63..9e8da30a 100644
--- a/internal/pipeline/climanifest_test.go
+++ b/internal/pipeline/climanifest_test.go
@@ -542,25 +542,30 @@ func TestArchiveRunArtifactsSkipsMissingDiscovery(t *testing.T) {
 }
 
 func TestComputeMCPReady(t *testing.T) {
+	// composed/cookie auth always reports "partial" — the older `if
+	// publicTools > 0` gate produced false-negative "cli-only" labels for
+	// CLIs whose spec authors hadn't yet tagged endpoints with `no_auth:
+	// true`. Pagliacci-pizza is the canonical case: composed auth, 67
+	// registered tools (account_register, account_login, store/menu
+	// lookups all unauthenticated), but mcp_public_tool_count was 0 so
+	// the readiness label was wrong and downstream manifest emission
+	// was suppressed.
 	tests := []struct {
-		name        string
-		authType    string
-		publicTools int
-		want        string
+		name     string
+		authType string
+		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"},
+		{"none", "none", "full"},
+		{"api_key", "api_key", "full"},
+		{"bearer_token", "bearer_token", "full"},
+		{"oauth2 defaults to full", "oauth2", "full"},
+		{"cookie always partial", "cookie", "partial"},
+		{"composed always partial", "composed", "partial"},
+		{"empty auth type", "", "full"},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got := computeMCPReady(tt.authType, tt.publicTools)
+			got := computeMCPReady(tt.authType)
 			assert.Equal(t, tt.want, got)
 		})
 	}
@@ -575,14 +580,23 @@ func TestWriteMCPBManifest(t *testing.T) {
 		assert.True(t, os.IsNotExist(statErr))
 	})
 
-	t.Run("cli-only readiness → skipped (host can't use bundle alone)", func(t *testing.T) {
+	t.Run("cli-only readiness still emits manifest", func(t *testing.T) {
+		// Earlier behavior: cli-only readiness skipped manifest emission
+		// entirely, on the theory that "the bundle won't work standalone."
+		// In practice that suppressed bundles for composed/cookie-auth CLIs
+		// with unauthenticated tools (registration, login, public lookups).
+		// The manifest now ships regardless; user_config's required flag
+		// communicates auth-required-or-optional. See Pagliacci-pizza.
 		dir := t.TempDir()
-		m := CLIManifest{MCPBinary: "test-pp-mcp", MCPReady: "cli-only"}
-		writeManifest(t, dir, m)
+		writeManifest(t, dir, CLIManifest{
+			APIName:   "test",
+			MCPBinary: "test-pp-mcp",
+			MCPReady:  "cli-only",
+		})
 
 		require.NoError(t, WriteMCPBManifest(dir))
 		_, statErr := os.Stat(filepath.Join(dir, MCPBManifestFilename))
-		assert.True(t, os.IsNotExist(statErr))
+		require.NoError(t, statErr, "cli-only readiness should NOT skip manifest emission")
 	})
 
 	t.Run("missing MCP binary → skipped", func(t *testing.T) {
diff --git a/internal/pipeline/mcpb_manifest.go b/internal/pipeline/mcpb_manifest.go
index acbebc58..a2c9ce79 100644
--- a/internal/pipeline/mcpb_manifest.go
+++ b/internal/pipeline/mcpb_manifest.go
@@ -127,10 +127,16 @@ type MCPBCompat struct {
 }
 
 // WriteMCPBManifest emits manifest.json for a published CLI directory by
-// reading .printing-press.json. Skips silently when the CLI dir has no
-// manifest, no MCP binary, or cli-only readiness — none of those should
-// produce a draggable .mcpb. Callers that already have the CLIManifest
-// in memory should use WriteMCPBManifestFromStruct to avoid the re-read.
+// reading .printing-press.json. Skips silently only when the CLI dir has
+// no .printing-press.json or no MCP binary — every other CLI ships a
+// manifest, including composed/cookie-auth ones whose MCPReady label
+// says "partial" or "cli-only". The user_config block already conveys
+// auth-required-or-optional (per authRequiresCredential), so withholding
+// the manifest from cli-only readiness CLIs only hurt users who could
+// otherwise install the bundle and use any unauthenticated tools.
+//
+// Callers that already have the CLIManifest in memory should use
+// WriteMCPBManifestFromStruct to avoid the re-read.
 func WriteMCPBManifest(dir string) error {
 	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
 	if err != nil {
@@ -147,7 +153,7 @@ func WriteMCPBManifest(dir string) error {
 // Use it when the CLIManifest was just built and writing it back to disk
 // only to re-read it would be wasted work.
 func WriteMCPBManifestFromStruct(dir string, m CLIManifest) error {
-	if m.MCPBinary == "" || m.MCPReady == "cli-only" {
+	if m.MCPBinary == "" {
 		return nil
 	}
 	// SetEscapeHTML(false) so `>=1.0.0` stays readable instead of `>=1.0.0`.
diff --git a/internal/pipeline/toolsmanifest.go b/internal/pipeline/toolsmanifest.go
index 9f63685c..9b6cb037 100644
--- a/internal/pipeline/toolsmanifest.go
+++ b/internal/pipeline/toolsmanifest.go
@@ -85,7 +85,7 @@ func WriteToolsManifest(dir string, parsed *spec.APISpec) error {
 	}
 
 	total, public := parsed.CountMCPTools()
-	mcpReady := computeMCPReady(parsed.Auth.Type, public)
+	mcpReady := computeMCPReady(parsed.Auth.Type)
 
 	// For cookie/composed auth, only include NoAuth endpoints.
 	cookieOrComposed := parsed.Auth.Type == "cookie" || parsed.Auth.Type == "composed"
diff --git a/skills/printing-press/SKILL.md b/skills/printing-press/SKILL.md
index ad545989..71bc221f 100644
--- a/skills/printing-press/SKILL.md
+++ b/skills/printing-press/SKILL.md
@@ -1409,6 +1409,66 @@ template produces correct auth from the start.
 APIs, public data feeds), don't invent auth. The signal must come from research — not
 from guessing. No research mention of auth = no enrichment.
 
+#### Tagging endpoints `no_auth: true` (composed/cookie auth APIs)
+
+For APIs whose `auth.type` is `cookie`, `composed`, or `session_handshake` — i.e.,
+auth that requires interactive setup (browser cookie capture, multi-step token
+exchange) — audit each endpoint individually for whether it actually needs
+authentication. The default of `no_auth: false` means "auth required"; flip it to
+`no_auth: true` for endpoints that work without credentials.
+
+Typical unauthenticated endpoints worth tagging:
+
+- **Auth-flow primitives:** login, registration, password-reset, email-confirm,
+  refresh-token, OAuth callback. The user isn't authenticated when calling these —
+  they ARE the auth flow.
+- **Public discovery:** store/location finder, menu browse, public catalog,
+  category listing, public search, public product detail.
+- **Health/metadata:** health checks, version probes, capability flags, sitemap.
+
+Why this matters: the `no_auth` count drives downstream decisions. Specifically,
+a composed-auth API with zero `no_auth: true` tags previously got labeled
+`mcp_ready: cli-only` and was suppressed from MCPB manifest emission, which
+broke the Claude Desktop install path entirely. The current generator
+(post-2.5) ships a manifest regardless, but the readiness label, scorecard
+breadth dimension, and SKILL.md prose all read better when the count
+reflects reality.
+
+If unsure whether an endpoint requires auth, the safe default is `no_auth: false`
+(auth required) — over-tagging can mislead users to expect tools that won't work.
+
+**Example for a composed-auth pizza-ordering API:**
+
+```yaml
+resources:
+  account:
+    endpoints:
+      register:
+        method: POST
+        path: /account/register
+        no_auth: true   # registering means you're not yet authenticated
+      login:
+        method: POST
+        path: /account/login
+        no_auth: true   # the auth flow itself
+      profile:
+        method: GET
+        path: /account/profile
+        # no_auth defaults to false — needs auth to view your own profile
+  stores:
+    endpoints:
+      find:
+        method: GET
+        path: /stores/near
+        no_auth: true   # public store finder
+  cart:
+    endpoints:
+      checkout:
+        method: POST
+        path: /cart/checkout
+        # no_auth defaults to false — placing an order needs auth
+```
+
 ### Lock and Generate
 
 Before running any generate command, acquire the build lock:

← 9e260025 ci(ci): split fast and full test lanes (#357)  ·  back to Cli Printing Press  ·  fix(ci): speed up generated compile tests and caches (#360) ddacddba →