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