[object Object]

← back to Cli Printing Press

fix(cli): mcp-sync refreshes .printing-press.json from spec.yaml (#375)

40d1ffadcdaa582a61953a4239a204eda1886334 · 2026-04-28 21:37:36 -0700 · Trevin Chow

mcp-sync regenerated manifest.json from .printing-press.json (provenance)
without ever refreshing the provenance file from spec.yaml. So when a
spec.yaml field changed (auth.key_url, auth.optional, auth.env_vars,
display_name, MCP tool counts), the new value never reached the manifest
until someone hand-injected it into .printing-press.json.

This bit recipe-goat twice in one session:
  1. Added auth.key_url to spec.yaml — signup URL didn't surface in the
     MCPB Configure modal until I jq-edited .printing-press.json.
  2. Added auth.optional to spec.yaml — Required label didn't drop
     until I did the same hand-injection again.

Fix: new pipeline.RefreshCLIManifestFromSpec(dir, parsed) reads the
existing .printing-press.json, overlays spec-derived fields via
populateMCPMetadata, and writes back. mcp-sync calls it between
WriteToolsManifest and WriteMCPBManifest so the manifest write picks
up fresh provenance.

Field-level boundary:
  Refreshed (spec-derived): MCPBinary, MCPToolCount, MCPPublicToolCount,
    MCPReady, AuthType, AuthEnvVars, AuthKeyURL, AuthOptional, DisplayName
  Preserved (generate-time): spec_url, spec_path, spec_checksum,
    generated_at, printing_press_version, schema_version, novel_features,
    catalog_entry, category, cli_name, api_name, api_version, description

Pre-existing CLIs without .printing-press.json (rare) get a no-op via
errors.Is(os.ErrNotExist).

Tests:
  - New TestSyncRefreshesProvenanceFromSpec: generate CLI, edit spec.yaml
    to add auth.key_url + auth.optional, run Sync, assert
    .printing-press.json AND manifest.json reflect the new spec end-to-end.
  - go test ./... 2229/2229. Golden 9/9. golangci-lint clean.

Files touched

Diff

commit 40d1ffadcdaa582a61953a4239a204eda1886334
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Tue Apr 28 21:37:36 2026 -0700

    fix(cli): mcp-sync refreshes .printing-press.json from spec.yaml (#375)
    
    mcp-sync regenerated manifest.json from .printing-press.json (provenance)
    without ever refreshing the provenance file from spec.yaml. So when a
    spec.yaml field changed (auth.key_url, auth.optional, auth.env_vars,
    display_name, MCP tool counts), the new value never reached the manifest
    until someone hand-injected it into .printing-press.json.
    
    This bit recipe-goat twice in one session:
      1. Added auth.key_url to spec.yaml — signup URL didn't surface in the
         MCPB Configure modal until I jq-edited .printing-press.json.
      2. Added auth.optional to spec.yaml — Required label didn't drop
         until I did the same hand-injection again.
    
    Fix: new pipeline.RefreshCLIManifestFromSpec(dir, parsed) reads the
    existing .printing-press.json, overlays spec-derived fields via
    populateMCPMetadata, and writes back. mcp-sync calls it between
    WriteToolsManifest and WriteMCPBManifest so the manifest write picks
    up fresh provenance.
    
    Field-level boundary:
      Refreshed (spec-derived): MCPBinary, MCPToolCount, MCPPublicToolCount,
        MCPReady, AuthType, AuthEnvVars, AuthKeyURL, AuthOptional, DisplayName
      Preserved (generate-time): spec_url, spec_path, spec_checksum,
        generated_at, printing_press_version, schema_version, novel_features,
        catalog_entry, category, cli_name, api_name, api_version, description
    
    Pre-existing CLIs without .printing-press.json (rare) get a no-op via
    errors.Is(os.ErrNotExist).
    
    Tests:
      - New TestSyncRefreshesProvenanceFromSpec: generate CLI, edit spec.yaml
        to add auth.key_url + auth.optional, run Sync, assert
        .printing-press.json AND manifest.json reflect the new spec end-to-end.
      - go test ./... 2229/2229. Golden 9/9. golangci-lint clean.
---
 internal/pipeline/climanifest.go       | 36 +++++++++++++++
 internal/pipeline/mcpsync/sync.go      | 10 +++++
 internal/pipeline/mcpsync/sync_test.go | 81 ++++++++++++++++++++++++++++++++++
 3 files changed, 127 insertions(+)

diff --git a/internal/pipeline/climanifest.go b/internal/pipeline/climanifest.go
index ce6aaa88..b91da34f 100644
--- a/internal/pipeline/climanifest.go
+++ b/internal/pipeline/climanifest.go
@@ -4,6 +4,7 @@ import (
 	"crypto/sha256"
 	"encoding/hex"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -95,6 +96,41 @@ func ReadCLIBinaryName(dir string) string {
 	return m.CLIName
 }
 
+// RefreshCLIManifestFromSpec rereads dir/.printing-press.json, overlays the
+// spec-derived fields from parsed (via populateMCPMetadata), and writes
+// the result back. Used by mcp-sync to keep provenance in sync with
+// spec.yaml — without this, spec.yaml updates to auth.key_url,
+// auth.optional, auth.env_vars, and similar fields never reach
+// downstream emitters (manifest.json, doctor, scorecard) because those
+// read .printing-press.json, not the spec directly.
+//
+// Generate-time fields (spec_url, spec_path, spec_checksum,
+// generated_at, printing_press_version, schema_version, novel_features,
+// catalog_entry, category, cli_name, api_name, api_version, description)
+// are preserved as-is. Only the spec-driven MCP/auth/display fields
+// are refreshed.
+//
+// Returns nil silently when .printing-press.json is missing — callers
+// generating from scratch don't need a provenance-refresh step.
+func RefreshCLIManifestFromSpec(dir string, parsed *spec.APISpec) error {
+	if parsed == nil {
+		return nil
+	}
+	data, err := os.ReadFile(filepath.Join(dir, CLIManifestFilename))
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			return nil
+		}
+		return fmt.Errorf("reading CLI manifest for refresh: %w", err)
+	}
+	var m CLIManifest
+	if err := json.Unmarshal(data, &m); err != nil {
+		return fmt.Errorf("parsing CLI manifest for refresh: %w", err)
+	}
+	populateMCPMetadata(&m, parsed)
+	return WriteCLIManifest(dir, m)
+}
+
 // WriteCLIManifest marshals m as indented JSON and writes it to
 // dir/.printing-press.json.
 func WriteCLIManifest(dir string, m CLIManifest) error {
diff --git a/internal/pipeline/mcpsync/sync.go b/internal/pipeline/mcpsync/sync.go
index a9b60a75..a3e9c15b 100644
--- a/internal/pipeline/mcpsync/sync.go
+++ b/internal/pipeline/mcpsync/sync.go
@@ -119,6 +119,16 @@ func Sync(cliDir string, opts Options) (Result, error) {
 	if err := pipeline.WriteToolsManifest(cliDir, parsed); err != nil {
 		return Result{}, fmt.Errorf("regenerating tools-manifest.json: %w", err)
 	}
+	// Refresh .printing-press.json's spec-derived fields before regenerating
+	// manifest.json. WriteMCPBManifest reads provenance from disk, so
+	// without this step spec.yaml updates to auth.key_url, auth.optional,
+	// auth.env_vars, and similar never reach the MCPB Configure modal.
+	// This staleness bit recipe-goat twice in one session — first when
+	// auth.key_url was added (signup URL didn't surface), then again
+	// when auth.optional was added (Required label didn't drop).
+	if err := pipeline.RefreshCLIManifestFromSpec(cliDir, parsed); err != nil {
+		return Result{}, fmt.Errorf("refreshing CLI manifest from spec: %w", err)
+	}
 	// Regenerate the MCPB manifest too. The schema can drift between
 	// generator releases (most recently: cli_binary was removed because
 	// Claude Desktop strict-validates v0.3 keys). mcp-sync without this
diff --git a/internal/pipeline/mcpsync/sync_test.go b/internal/pipeline/mcpsync/sync_test.go
index 146024d0..0c329700 100644
--- a/internal/pipeline/mcpsync/sync_test.go
+++ b/internal/pipeline/mcpsync/sync_test.go
@@ -1,11 +1,13 @@
 package mcpsync
 
 import (
+	"encoding/json"
 	"os"
 	"path/filepath"
 	"testing"
 
 	"github.com/mvanhorn/cli-printing-press/v2/internal/generator"
+	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -196,6 +198,85 @@ func RegisterNovelFeatureTools() { shellOutToCLI("items list") }
 	assert.NotContains(t, src, "// CleanText is the only helper this older template emitted.", "stale stub content should be replaced")
 }
 
+// TestSyncRefreshesProvenanceFromSpec locks the contract that mcp-sync
+// updates .printing-press.json's spec-derived fields from the current
+// spec.yaml. Pre-fix, mcp-sync wrote manifest.json from stale provenance,
+// so spec.yaml updates to auth.key_url, auth.optional, auth.env_vars,
+// etc. never reached the MCPB Configure modal until someone hand-injected
+// them into .printing-press.json. Bit recipe-goat twice in one session.
+func TestSyncRefreshesProvenanceFromSpec(t *testing.T) {
+	t.Parallel()
+
+	// Step 1: generate the CLI with an initial spec (no key_url, not optional).
+	apiSpec := &spec.APISpec{
+		Name:    "provrefresh",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth: spec.AuthConfig{
+			Type:    "api_key",
+			Header:  "Authorization",
+			Format:  "Bearer {token}",
+			EnvVars: []string{"PROVREFRESH_TOKEN"},
+		},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/provrefresh-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"},
+				},
+			},
+		},
+	}
+	cliDir := filepath.Join(t.TempDir(), "provrefresh")
+	gen := generator.New(apiSpec, cliDir)
+	require.NoError(t, gen.Generate())
+
+	// Step 2: simulate a generate-time provenance write. WriteManifestForGenerate
+	// is what cli/generate.go calls; the test exercises the same path.
+	require.NoError(t, pipeline.WriteManifestForGenerate(pipeline.GenerateManifestParams{
+		APIName:   apiSpec.Name,
+		OutputDir: cliDir,
+		Spec:      apiSpec,
+	}))
+
+	// Step 3: edit spec.yaml with the new auth.key_url + auth.optional that
+	// the original spec lacked. This is the field-update scenario that
+	// breaks without provenance refresh.
+	apiSpec.Auth.KeyURL = "https://example.com/get-a-token"
+	apiSpec.Auth.Optional = true
+	specData, err := yaml.Marshal(apiSpec)
+	require.NoError(t, err)
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "spec.yaml"), specData, 0o644))
+
+	// Step 4: run mcp-sync. It should pick up the new spec values and
+	// refresh .printing-press.json + manifest.json.
+	result, err := Sync(cliDir, Options{})
+	require.NoError(t, err)
+	assert.True(t, result.Changed)
+
+	// Step 5: verify .printing-press.json now reflects the new spec.
+	provData, err := os.ReadFile(filepath.Join(cliDir, pipeline.CLIManifestFilename))
+	require.NoError(t, err)
+	var prov pipeline.CLIManifest
+	require.NoError(t, json.Unmarshal(provData, &prov))
+	assert.Equal(t, "https://example.com/get-a-token", prov.AuthKeyURL, "auth_key_url must refresh from spec.yaml on mcp-sync")
+	assert.True(t, prov.AuthOptional, "auth_optional must refresh from spec.yaml on mcp-sync")
+
+	// Step 6: verify the MCPB manifest also reflects the new spec — this
+	// is the user-visible failure mode (Configure modal wording / Required
+	// flag). Confirms the full chain spec.yaml → .printing-press.json →
+	// manifest.json works end-to-end.
+	manifestData, err := os.ReadFile(filepath.Join(cliDir, pipeline.MCPBManifestFilename))
+	require.NoError(t, err)
+	manifestStr := string(manifestData)
+	assert.Contains(t, manifestStr, "https://example.com/get-a-token", "manifest description should surface key_url after refresh")
+	assert.Contains(t, manifestStr, `"description": "Optional. Sets`, "manifest description should reflect auth_optional with the Optional. prefix")
+}
+
 // TestValidateSpecNameMatchesDirAccepts ensures matching name and dir
 // pass the preflight without error. The dir basename is the source of
 // truth for the package identity (it appears in go.mod, in directory

← a158a629 fix(cli): wire auth.optional through CLIManifest to MCPB use  ·  back to Cli Printing Press  ·  fix(cli): mcp-sync skips deliver/suggestFlag prolog blocks w 2b9f2fbb →