← 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
M internal/pipeline/climanifest.goM internal/pipeline/mcpsync/sync.goM internal/pipeline/mcpsync/sync_test.go
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 →