[object Object]

← back to Cli Printing Press

fix(cli): mcp-sync bumps mcp-go pin when migrating to cobratree surface (#404)

7670fe98bc32df56518e5789a9b6f5bd694ce9fa · 2026-04-29 20:04:55 -0700 · Trevin Chow

* fix(cli): mcp-sync bumps mcp-go pin when migrating to cobratree surface

Older library CLIs that predate the runtime cobratree walker pin a
mark3labs/mcp-go version (typically v0.26.0) that lacks the APIs the
current templates use — WithReadOnlyHintAnnotation,
req.GetArguments, and friends added in v0.47.0. mcp-sync's migration
step regenerates the MCP surface from those templates; without a
matching dep bump the regenerated CLI fails to compile and the sync
silently leaves a broken state.

Add ensureMCPGoMinVersion that reads go.mod, finds the mark3labs/mcp-go
require line, and bumps it to the floor (v0.47.0) when older. Run
during the !alreadyMigrated migration block so already-cobratree CLIs
don't churn their go.mod. The floor matches the version pinned in
go.mod.tmpl — bump both together when raising the requirement.

Polish on weather-goat hit this case and had to bump the dep manually.
This change folds that step into mcp-sync so future per-CLI migrations
are mechanical.

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

* refactor(cli): use modfile.Parse and add drift gate for mcp-go pin

Apply /simplify findings on the mcp-go-version bump:

- Switch from regex to golang.org/x/mod/modfile (already in deps).
  The previous regex `github.com/mark3labs/mcp-go\s+vX.Y.Z` matched
  ANY occurrence on a line, including a `replace github.com/mark3labs/
  mcp-go => github.com/myfork/mcp-go vX.Y.Z` directive's target. A
  user with a local fork pin would have silently had their override
  bumped. modfile.Parse + AddRequire surgically updates only the
  require entry. New test
  TestEnsureMCPGoMinVersionLeavesReplaceDirectiveAlone locks this in.

- Extract `github.com/mark3labs/mcp-go` to a single mcpGoModulePath
  constant — it's the lookup key into mf.Require, no longer repeated
  across regex and replacement string.

- Add TestMinMCPGoVersionMatchesGoModTemplate as the drift gate
  between minMCPGoVersionForCobratree (used to decide if an existing
  pin is too old) and the version pinned in
  internal/generator/templates/go.mod.tmpl. If they drift, freshly
  generated CLIs and freshly migrated CLIs end up on different
  versions. The constant comment hopes; this test enforces.

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

---------

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

Files touched

Diff

commit 7670fe98bc32df56518e5789a9b6f5bd694ce9fa
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Wed Apr 29 20:04:55 2026 -0700

    fix(cli): mcp-sync bumps mcp-go pin when migrating to cobratree surface (#404)
    
    * fix(cli): mcp-sync bumps mcp-go pin when migrating to cobratree surface
    
    Older library CLIs that predate the runtime cobratree walker pin a
    mark3labs/mcp-go version (typically v0.26.0) that lacks the APIs the
    current templates use — WithReadOnlyHintAnnotation,
    req.GetArguments, and friends added in v0.47.0. mcp-sync's migration
    step regenerates the MCP surface from those templates; without a
    matching dep bump the regenerated CLI fails to compile and the sync
    silently leaves a broken state.
    
    Add ensureMCPGoMinVersion that reads go.mod, finds the mark3labs/mcp-go
    require line, and bumps it to the floor (v0.47.0) when older. Run
    during the !alreadyMigrated migration block so already-cobratree CLIs
    don't churn their go.mod. The floor matches the version pinned in
    go.mod.tmpl — bump both together when raising the requirement.
    
    Polish on weather-goat hit this case and had to bump the dep manually.
    This change folds that step into mcp-sync so future per-CLI migrations
    are mechanical.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    * refactor(cli): use modfile.Parse and add drift gate for mcp-go pin
    
    Apply /simplify findings on the mcp-go-version bump:
    
    - Switch from regex to golang.org/x/mod/modfile (already in deps).
      The previous regex `github.com/mark3labs/mcp-go\s+vX.Y.Z` matched
      ANY occurrence on a line, including a `replace github.com/mark3labs/
      mcp-go => github.com/myfork/mcp-go vX.Y.Z` directive's target. A
      user with a local fork pin would have silently had their override
      bumped. modfile.Parse + AddRequire surgically updates only the
      require entry. New test
      TestEnsureMCPGoMinVersionLeavesReplaceDirectiveAlone locks this in.
    
    - Extract `github.com/mark3labs/mcp-go` to a single mcpGoModulePath
      constant — it's the lookup key into mf.Require, no longer repeated
      across regex and replacement string.
    
    - Add TestMinMCPGoVersionMatchesGoModTemplate as the drift gate
      between minMCPGoVersionForCobratree (used to decide if an existing
      pin is too old) and the version pinned in
      internal/generator/templates/go.mod.tmpl. If they drift, freshly
      generated CLIs and freshly migrated CLIs end up on different
      versions. The constant comment hopes; this test enforces.
    
    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
 internal/pipeline/mcpsync/sync.go      |  74 +++++++++++++++++++
 internal/pipeline/mcpsync/sync_test.go | 125 +++++++++++++++++++++++++++++++++
 2 files changed, 199 insertions(+)

diff --git a/internal/pipeline/mcpsync/sync.go b/internal/pipeline/mcpsync/sync.go
index 9e765e4f..056a1c8f 100644
--- a/internal/pipeline/mcpsync/sync.go
+++ b/internal/pipeline/mcpsync/sync.go
@@ -16,6 +16,8 @@ import (
 	"github.com/mvanhorn/cli-printing-press/v2/internal/openapi"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/pipeline"
 	"github.com/mvanhorn/cli-printing-press/v2/internal/spec"
+	"golang.org/x/mod/modfile"
+	"golang.org/x/mod/semver"
 )
 
 var (
@@ -134,6 +136,18 @@ func Sync(cliDir string, opts Options) (Result, error) {
 		if err := ensureEndpointAnnotations(cliDir, parsed, features); err != nil {
 			return Result{}, err
 		}
+		// Cobratree templates (mcp_tools.go.tmpl, main_mcp.go.tmpl, etc.)
+		// use APIs added in mcp-go v0.47.0 (WithReadOnlyHintAnnotation,
+		// req.GetArguments). Older generated CLIs pin a lower version
+		// and won't compile after the surface regen below. Bump the
+		// pin in go.mod before generation so the resulting CLI builds.
+		bumpedFrom, err := ensureMCPGoMinVersion(cliDir)
+		if err != nil {
+			return Result{}, err
+		}
+		if bumpedFrom != "" {
+			fmt.Fprintf(os.Stderr, "mcp-sync: bumped mark3labs/mcp-go in go.mod from %s to %s for cobratree compatibility\n", bumpedFrom, minMCPGoVersionForCobratree)
+		}
 		// Older generator templates split MCP handlers into a separate
 		// internal/mcp/handlers.go file. The current template emits all
 		// handlers (handleContext, handleSQL, handleSync, makeAPIHandler,
@@ -659,3 +673,63 @@ func writeFileAtomic(path string, data []byte) error {
 	}
 	return nil
 }
+
+// mcpGoModulePath identifies the mcp-go module across regex/lookup
+// sites. Single source so the import path stays in lockstep with the
+// constant it pairs with.
+const mcpGoModulePath = "github.com/mark3labs/mcp-go"
+
+// minMCPGoVersionForCobratree is the floor mark3labs/mcp-go version
+// the cobratree-era templates compile against. Pinned to whatever the
+// generator's go.mod template currently emits — bump both together.
+// TestMinMCPGoVersionMatchesGoModTemplate keeps them in lockstep.
+const minMCPGoVersionForCobratree = "v0.47.0"
+
+// ensureMCPGoMinVersion bumps the mark3labs/mcp-go require directive
+// in go.mod to minMCPGoVersionForCobratree when the existing pin is
+// older. Returns the prior version when a bump happened, the empty
+// string when no change was needed (already current, or dep absent).
+//
+// Older library CLIs predating cobratree pin v0.26.0 or similar; the
+// migration block above regenerates MCP source against the cobratree
+// templates which call APIs (WithReadOnlyHintAnnotation, GetArguments)
+// added in v0.47.0. Without the bump the regenerated CLI fails to
+// compile and the sync silently leaves a broken state.
+//
+// Uses modfile.Parse so a `replace` directive pointing the module at
+// a fork or local path is left alone — only the require entry moves.
+func ensureMCPGoMinVersion(cliDir string) (priorVersion string, err error) {
+	gomodPath := filepath.Join(cliDir, "go.mod")
+	data, err := os.ReadFile(gomodPath)
+	if err != nil {
+		return "", fmt.Errorf("reading go.mod: %w", err)
+	}
+	mf, err := modfile.Parse("go.mod", data, nil)
+	if err != nil {
+		return "", fmt.Errorf("parsing go.mod: %w", err)
+	}
+	var current string
+	for _, r := range mf.Require {
+		if r.Mod.Path == mcpGoModulePath {
+			current = r.Mod.Version
+			break
+		}
+	}
+	if current == "" {
+		return "", nil
+	}
+	if !semver.IsValid(current) || semver.Compare(current, minMCPGoVersionForCobratree) >= 0 {
+		return "", nil
+	}
+	if err := mf.AddRequire(mcpGoModulePath, minMCPGoVersionForCobratree); err != nil {
+		return "", fmt.Errorf("updating mcp-go require: %w", err)
+	}
+	rewritten, err := mf.Format()
+	if err != nil {
+		return "", fmt.Errorf("formatting go.mod: %w", err)
+	}
+	if err := writeFileAtomic(gomodPath, rewritten); err != nil {
+		return "", fmt.Errorf("rewriting go.mod: %w", err)
+	}
+	return current, nil
+}
diff --git a/internal/pipeline/mcpsync/sync_test.go b/internal/pipeline/mcpsync/sync_test.go
index 7a98822d..fe68a85b 100644
--- a/internal/pipeline/mcpsync/sync_test.go
+++ b/internal/pipeline/mcpsync/sync_test.go
@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"os"
 	"path/filepath"
+	"regexp"
 	"testing"
 
 	"github.com/mvanhorn/cli-printing-press/v2/internal/generator"
@@ -405,6 +406,130 @@ func TestReconcileSpecNameWithDirNoYAMLFallsThroughToValidator(t *testing.T) {
 	assert.Contains(t, err.Error(), "--force")
 }
 
+// TestEnsureMCPGoMinVersionBumpsOldPin — older library CLIs predating
+// the cobratree templates pin a v0.x version that lacks
+// WithReadOnlyHintAnnotation/GetArguments. mcp-sync bumps to the floor
+// before regenerating MCP source so the generated CLI compiles.
+func TestEnsureMCPGoMinVersionBumpsOldPin(t *testing.T) {
+	t.Parallel()
+	cliDir := t.TempDir()
+	gomod := `module example.com/foo
+
+go 1.23.0
+
+require github.com/mark3labs/mcp-go v0.26.0
+`
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "go.mod"), []byte(gomod), 0o644))
+
+	prior, err := ensureMCPGoMinVersion(cliDir)
+	require.NoError(t, err)
+	assert.Equal(t, "v0.26.0", prior, "must report the prior pin so callers can log it")
+
+	updated, err := os.ReadFile(filepath.Join(cliDir, "go.mod"))
+	require.NoError(t, err)
+	assert.Contains(t, string(updated), "github.com/mark3labs/mcp-go v0.47.0")
+	assert.NotContains(t, string(updated), "github.com/mark3labs/mcp-go v0.26.0")
+	// Surrounding directives must be preserved.
+	assert.Contains(t, string(updated), "module example.com/foo")
+	assert.Contains(t, string(updated), "go 1.23.0")
+}
+
+// TestEnsureMCPGoMinVersionNoOpAtOrAboveFloor — at or above the floor,
+// no-op even if exactly equal. Avoids spurious go.mod rewrites on
+// already-current CLIs.
+func TestEnsureMCPGoMinVersionNoOpAtOrAboveFloor(t *testing.T) {
+	t.Parallel()
+	for _, version := range []string{"v0.47.0", "v0.48.0", "v1.0.0"} {
+		t.Run(version, func(t *testing.T) {
+			t.Parallel()
+			cliDir := t.TempDir()
+			gomod := "module example.com/foo\nrequire github.com/mark3labs/mcp-go " + version + "\n"
+			require.NoError(t, os.WriteFile(filepath.Join(cliDir, "go.mod"), []byte(gomod), 0o644))
+
+			prior, err := ensureMCPGoMinVersion(cliDir)
+			require.NoError(t, err)
+			assert.Equal(t, "", prior, "must report no-rename when already at or above floor")
+
+			after, err := os.ReadFile(filepath.Join(cliDir, "go.mod"))
+			require.NoError(t, err)
+			assert.Equal(t, gomod, string(after), "go.mod must be byte-identical when no bump needed")
+		})
+	}
+}
+
+// TestEnsureMCPGoMinVersionNoOpWhenDepAbsent — a CLI without mcp-go
+// in go.mod (e.g., a CLI generated before MCP support was added)
+// must not have the dep injected. Other migration steps (or a future
+// `go mod tidy`) handle that.
+func TestEnsureMCPGoMinVersionNoOpWhenDepAbsent(t *testing.T) {
+	t.Parallel()
+	cliDir := t.TempDir()
+	gomod := "module example.com/foo\n\ngo 1.23.0\n"
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "go.mod"), []byte(gomod), 0o644))
+
+	prior, err := ensureMCPGoMinVersion(cliDir)
+	require.NoError(t, err)
+	assert.Equal(t, "", prior)
+
+	after, err := os.ReadFile(filepath.Join(cliDir, "go.mod"))
+	require.NoError(t, err)
+	assert.Equal(t, gomod, string(after))
+}
+
+// TestEnsureMCPGoMinVersionLeavesReplaceDirectiveAlone — a `replace`
+// directive that pins mcp-go to a fork or local checkout must survive
+// the bump. Only the require entry moves; the replace target is
+// independent and may legitimately point at any version.
+func TestEnsureMCPGoMinVersionLeavesReplaceDirectiveAlone(t *testing.T) {
+	t.Parallel()
+	cliDir := t.TempDir()
+	gomod := `module example.com/foo
+
+go 1.23.0
+
+require github.com/mark3labs/mcp-go v0.26.0
+
+replace github.com/mark3labs/mcp-go => github.com/myfork/mcp-go v0.30.0
+`
+	require.NoError(t, os.WriteFile(filepath.Join(cliDir, "go.mod"), []byte(gomod), 0o644))
+
+	prior, err := ensureMCPGoMinVersion(cliDir)
+	require.NoError(t, err)
+	assert.Equal(t, "v0.26.0", prior)
+
+	updated, err := os.ReadFile(filepath.Join(cliDir, "go.mod"))
+	require.NoError(t, err)
+	assert.Contains(t, string(updated), "github.com/mark3labs/mcp-go v0.47.0",
+		"require entry must be bumped to the floor")
+	assert.Contains(t, string(updated), "github.com/myfork/mcp-go v0.30.0",
+		"replace directive's target must be untouched")
+}
+
+// TestMinMCPGoVersionMatchesGoModTemplate is the drift-detection
+// gate: minMCPGoVersionForCobratree (used by ensureMCPGoMinVersion to
+// decide whether an existing pin is too old) must match the version
+// the generator's go.mod.tmpl actually emits. If they drift, freshly
+// generated CLIs and freshly migrated CLIs end up on different mcp-go
+// versions — silent inconsistency.
+func TestMinMCPGoVersionMatchesGoModTemplate(t *testing.T) {
+	t.Parallel()
+	// The template lives at internal/generator/templates/go.mod.tmpl;
+	// this test runs from internal/pipeline/mcpsync/.
+	tmplPath := filepath.Join("..", "..", "..", "internal", "generator", "templates", "go.mod.tmpl")
+	data, err := os.ReadFile(tmplPath)
+	require.NoError(t, err, "go.mod.tmpl must be readable from the test cwd")
+
+	// Match the bare require directive: `require github.com/mark3labs/mcp-go vX.Y.Z`.
+	// The template emits the directive literally (no Go template
+	// interpolation in the version), so a regex over the raw source
+	// is sufficient.
+	re := regexp.MustCompile(`(?m)^require[ \t]+github\.com/mark3labs/mcp-go[ \t]+(v[0-9][^\s]+)`)
+	m := re.FindSubmatch(data)
+	require.NotNil(t, m, "go.mod.tmpl must contain a `require github.com/mark3labs/mcp-go vX.Y.Z` line")
+	assert.Equal(t, minMCPGoVersionForCobratree, string(m[1]),
+		"minMCPGoVersionForCobratree drifted from the version pinned in go.mod.tmpl — bump both together")
+}
+
 // TestRemoveStaleMCPHandlersFile locks behavior for the food52 case:
 // older generator templates emitted MCP handlers in a separate
 // internal/mcp/handlers.go; the current template emits everything in

← 85259159 feat(cli): generator emits mcp:read-only on read-only novel  ·  back to Cli Printing Press  ·  feat(cli): per-resource base_url override in spec schema (#4 6586c0a2 →