From be5569f74c823ccf13dfe69102483ec739af4c01 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 25 Aug 2026 04:59:18 +0200 Subject: [PATCH 1/4] fix(mcp): inject build version into cudly-mcp and document Codex CLI setup The Makefile's shared $(LDFLAGS) has always injected -X main.Version, but cmd/cudly-mcp declared a lowercase `version` var ldflags cannot address by name, so a release build would silently report "dev" in the MCP initialize response. Rename to the exported `Version` used by the other binaries, add a build-mcp target reusing $(LDFLAGS), and add a subprocess test that builds the real binary with an injected version and asserts the MCP client sees it over stdio (confirmed to fail against the old lowercase var and pass after). Also documents registering cudly-mcp with OpenAI Codex CLI's ~/.codex/config.toml [mcp_servers.cudly] table, alongside the existing Claude Code mcp.json example. --- Makefile | 9 ++- cmd/cudly-mcp/main.go | 12 ++-- cmd/cudly-mcp/version_test.go | 114 ++++++++++++++++++++++++++++++++++ mcp/README.md | 16 +++++ 4 files changed, 146 insertions(+), 5 deletions(-) create mode 100644 cmd/cudly-mcp/version_test.go diff --git a/Makefile b/Makefile index e34ce114e..1511a9f96 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean test deploy help all build-server build-lambda test-unit test-integration \ +.PHONY: build clean test deploy help all build-server build-lambda build-mcp test-unit test-integration \ test-coverage full-test security-scan terraform-validate docker-build \ fmt vet lint complexity complexity-report security-scan-go security-scan-docker \ security-scan-terraform terraform-fmt terraform-fmt-check iac-arm docker-test pre-commit \ @@ -30,6 +30,7 @@ help: ## Display available targets @echo " build - Build the CLI" @echo " build-server - Build the unified server" @echo " build-lambda - Build for AWS Lambda" + @echo " build-mcp - Build the MCP server (cmd/cudly-mcp)" @echo " test - Run all unit tests" @echo " test-unit - Run unit tests only" @echo " test-integration - Run integration tests with testcontainers" @@ -59,6 +60,12 @@ build-server: build-lambda: CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o bootstrap ./cmd/lambda +# Build the MCP server (see mcp/README.md). Uses the same $(LDFLAGS)/$(VERSION) +# as build-server so a tagged release reports its version in the MCP +# initialize response instead of "dev" (see cmd/cudly-mcp/main.go). +build-mcp: + CGO_ENABLED=0 go build $(LDFLAGS) -o bin/cudly-mcp ./cmd/cudly-mcp + # Run unit tests test: test-unit diff --git a/cmd/cudly-mcp/main.go b/cmd/cudly-mcp/main.go index fde741f11..3d5a4d2c8 100644 --- a/cmd/cudly-mcp/main.go +++ b/cmd/cudly-mcp/main.go @@ -20,13 +20,17 @@ import ( _ "github.com/LeanerCloud/CUDly/providers/gcp" ) -// version is overridable at build time via: +// Version is overridable at build time via: // -// go build -ldflags "-X main.version=1.2.3" ./cmd/cudly-mcp -var version = "dev" +// go build -ldflags "-X main.Version=1.2.3" ./cmd/cudly-mcp +// +// `make build-mcp` sets it from the same $(LDFLAGS)/$(VERSION) the other +// binaries (build-server, ...) use, so a release build's tag flows through to +// the MCP initialize response's Implementation.Version. +var Version = "dev" func main() { - server, err := cudlymcp.NewServer(version) + server, err := cudlymcp.NewServer(Version) if err != nil { log.Fatalf("cudly-mcp: failed to build server: %v", err) } diff --git a/cmd/cudly-mcp/version_test.go b/cmd/cudly-mcp/version_test.go new file mode 100644 index 000000000..7d922ee19 --- /dev/null +++ b/cmd/cudly-mcp/version_test.go @@ -0,0 +1,114 @@ +package main + +// version_test.go is the regression guard for the version-injection mismatch +// fixed alongside it: the Makefile's shared $(LDFLAGS) has always injected +// -X main.Version=$(VERSION) (see build-server), but this package used to +// declare a lowercase `version` var that ldflags cannot address by name, so +// every release build silently reported "dev" in the MCP initialize +// response's Implementation.Version. An in-process test against +// cudlymcp.NewServer cannot catch this: it calls NewServer directly with a +// Go string, bypassing the ldflags/linker step entirely. Only building the +// real binary with -ldflags and inspecting what it reports over stdio proves +// the wiring works end to end, the same way `make build-mcp` and a real +// release tag do. + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" + + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +const ( + buildTimeout = 2 * time.Minute + runTimeout = 15 * time.Second + + // injectedTestVersion stands in for a release tag; any value distinct + // from the "dev" zero value proves ldflags injection reached the binary. + injectedTestVersion = "v0.0.0-version-test" +) + +var ( + buildOnce sync.Once + buildDir string + builtBinary string + buildErr error +) + +// TestMain removes the compiled binary's directory once every test in this +// package has run; the binary is built once (buildOnce) and shared across +// tests, so its lifetime is the test binary's, not any single test's. +func TestMain(m *testing.M) { + code := m.Run() + if buildDir != "" { + _ = os.RemoveAll(buildDir) + } + os.Exit(code) +} + +// builtVersionedBinary compiles cmd/cudly-mcp once with -X main.Version set, +// mirroring `make build-mcp`'s ldflags, and returns the path to the +// executable. +func builtVersionedBinary(t *testing.T) string { + t.Helper() + buildOnce.Do(func() { + if _, err := exec.LookPath("go"); err != nil { + buildErr = err + return + } + dir, err := os.MkdirTemp("", "cudly-mcp-version-test-") + if err != nil { + buildErr = err + return + } + buildDir = dir + binPath := filepath.Join(dir, "cudly-mcp") + + ctx, cancel := context.WithTimeout(context.Background(), buildTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", + "-ldflags", "-X main.Version="+injectedTestVersion, + "-o", binPath, ".") + cmd.Dir = "." // this package's directory, cmd/cudly-mcp + if out, err := cmd.CombinedOutput(); err != nil { + buildErr = err + t.Logf("go build output:\n%s", out) + return + } + builtBinary = binPath + }) + if buildErr != nil { + t.Fatalf("build cmd/cudly-mcp with version ldflags: %v", buildErr) + } + return builtBinary +} + +// TestBuiltBinaryReportsInjectedVersion runs the actual compiled cudly-mcp +// binary as a subprocess over stdio (the same transport every real MCP +// client uses) and asserts the MCP initialize handshake's +// InitializeResult.ServerInfo.Version echoes the ldflags-injected version, +// not the "dev" fallback in cmd/cudly-mcp/main.go. +func TestBuiltBinaryReportsInjectedVersion(t *testing.T) { + binPath := builtVersionedBinary(t) + + ctx, cancel := context.WithTimeout(context.Background(), runTimeout) + defer cancel() + + transport := &gosdk.CommandTransport{Command: exec.CommandContext(ctx, binPath)} + client := gosdk.NewClient(&gosdk.Implementation{Name: "version-test-client"}, nil) + session, err := client.Connect(ctx, transport, nil) + require.NoError(t, err, "connect to the built binary over stdio") + defer session.Close() + + result := session.InitializeResult() + require.NotNil(t, result, "InitializeResult") + require.NotNil(t, result.ServerInfo, "InitializeResult.ServerInfo") + require.Equal(t, injectedTestVersion, result.ServerInfo.Version, + "built binary must report the ldflags-injected version, not the main.Version=\"dev\" fallback") +} diff --git a/mcp/README.md b/mcp/README.md index ea6fc3178..743605336 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -84,6 +84,22 @@ Add an entry to your client's MCP server config. For Claude Code, this is `~/.cl `env` is optional -- omit it entirely to rely on whatever ambient credentials are already active in the shell that launches the client, or set only the provider(s) you actually use. +## Use with OpenAI Codex CLI + +Codex CLI reads MCP server definitions from `~/.codex/config.toml` (or a project-scoped `.codex/config.toml`), under a `[mcp_servers.]` table -- same `command`/`args`/`env` shape as Claude Code's `mcpServers` JSON, different file format: + +```toml +[mcp_servers.cudly] +command = "/absolute/path/to/cudly-mcp" +args = [] + +[mcp_servers.cudly.env] +AWS_PROFILE = "my-aws-profile" +AZURE_SUBSCRIPTION_ID = "00000000-0000-0000-0000-000000000000" +``` + +`CUDLY_MCP_ENABLE_REAL_PURCHASES` is deliberately omitted from this example -- leave it unset until you have exercised the dry-run path against this client and are ready to let it spend money; see [Safety model](#safety-model). + ## Worked example A typical session searches for a recommendation, previews the purchase, then executes it: From 4287aaa10572713c3f20588d116e7a3b9139faa9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 25 Aug 2026 05:23:19 +0200 Subject: [PATCH 2/4] fix(cmd/cudly-mcp): give the version-injection build test CI-safe headroom TestBuiltBinaryReportsInjectedVersion's 2-minute build timeout only accounted for a warm local build cache. In the "Unit Tests" CI job the preceding `go test -race` step only warms the race-enabled build cache, so this test recompiles the full AWS/Azure/GCP SDK dependency tree from scratch for the non-race binary and was hitting "signal: killed" at exactly the 2-minute mark. Raise the timeout to 5 minutes. --- cmd/cudly-mcp/version_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/cudly-mcp/version_test.go b/cmd/cudly-mcp/version_test.go index 7d922ee19..7262497f1 100644 --- a/cmd/cudly-mcp/version_test.go +++ b/cmd/cudly-mcp/version_test.go @@ -26,7 +26,13 @@ import ( ) const ( - buildTimeout = 2 * time.Minute + // buildTimeout must cover a genuinely cold build: the "Unit Tests" CI job + // runs this test with a fresh module/build cache for the non-race build + // flavor (the preceding `go test -race` step only warms the race-enabled + // cache), so this recompiles the full AWS/Azure/GCP SDK dependency tree + // from scratch on shared CI hardware. That measured over 2 minutes in CI + // (see PR #1891); 5 minutes leaves headroom without masking a real hang. + buildTimeout = 5 * time.Minute runTimeout = 15 * time.Second // injectedTestVersion stands in for a release tag; any value distinct From 53c32ee218e624af263edd49d74f29b48351d3df Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 25 Aug 2026 05:26:13 +0200 Subject: [PATCH 3/4] docs(mcp): note Codex CLI's project-trust gate on .codex/config.toml Codex CLI only loads a project-scoped .codex/config.toml when the project directory is marked trusted; an untrusted project's config layer is silently ignored. Callers following the previous wording could register cudly there and see nothing load, with no indication why. Addresses a CodeRabbit finding on PR #1891. --- mcp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/README.md b/mcp/README.md index 743605336..fedf5cf58 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -86,7 +86,7 @@ Add an entry to your client's MCP server config. For Claude Code, this is `~/.cl ## Use with OpenAI Codex CLI -Codex CLI reads MCP server definitions from `~/.codex/config.toml` (or a project-scoped `.codex/config.toml`), under a `[mcp_servers.]` table -- same `command`/`args`/`env` shape as Claude Code's `mcpServers` JSON, different file format: +Codex CLI reads MCP server definitions from `~/.codex/config.toml`, under a `[mcp_servers.]` table -- same `command`/`args`/`env` shape as Claude Code's `mcpServers` JSON, different file format. A project-scoped `.codex/config.toml` works the same way, but only if you have marked that project directory as trusted in Codex CLI -- an untrusted project's `.codex/` layer is silently ignored, so if `cudly` doesn't show up, register it in `~/.codex/config.toml` instead (or trust the project). ```toml [mcp_servers.cudly] From da420367a9bd0dadac3657115befff2c826ae9d9 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sun, 30 Aug 2026 22:39:35 +0200 Subject: [PATCH 4/4] fix(mcp): harden host-native build verification --- .github/workflows/ci.yml | 43 ++++++++++++++++ Makefile | 3 +- cmd/cudly-mcp/version_test.go | 92 +++++------------------------------ 3 files changed, 57 insertions(+), 81 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0284c3e84..51b08a039 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -317,6 +317,48 @@ jobs: echo "::warning::Coverage is below 80% (current: ${coverage}%)" fi + mcp-build: + name: Build MCP (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + binary_format: ELF + - os: macos-latest + binary_format: Mach-O + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Assert MCP artifact is absent + run: test ! -e bin/cudly-mcp + + - name: Build MCP + run: make build-mcp VERSION=v0.0.0-version-test + + - name: Assert host-native binary format + run: file bin/cudly-mcp | grep -F '${{ matrix.binary_format }}' + + - name: Verify Make-built MCP version + env: + CUDLY_MCP_TEST_BINARY: ${{ github.workspace }}/bin/cudly-mcp + run: go test ./cmd/cudly-mcp -run '^TestBuiltBinaryReportsInjectedVersion$' -count=1 + + - name: Assert clean target includes MCP artifact + run: make -n clean | grep -Fx 'rm -f cudly bootstrap bin/cudly-server bin/cudly-mcp' + # Integration tests with real PostgreSQL integration-tests: name: Integration Tests @@ -1023,6 +1065,7 @@ jobs: - lint - workflow-lint - unit-tests + - mcp-build - integration-tests - docker-build - terraform-validate diff --git a/Makefile b/Makefile index 1511a9f96..fbc0d39c4 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,7 @@ build-lambda: # as build-server so a tagged release reports its version in the MCP # initialize response instead of "dev" (see cmd/cudly-mcp/main.go). build-mcp: + mkdir -p bin CGO_ENABLED=0 go build $(LDFLAGS) -o bin/cudly-mcp ./cmd/cudly-mcp # Run unit tests @@ -91,7 +92,7 @@ full-test: test-unit test-integration test-coverage # Clean build artifacts clean: - rm -f cudly bootstrap bin/cudly-server + rm -f cudly bootstrap bin/cudly-server bin/cudly-mcp rm -f coverage.out coverage.html rm -f gosec-report.json trivy-report.json tfsec-report.json go clean diff --git a/cmd/cudly-mcp/version_test.go b/cmd/cudly-mcp/version_test.go index 7262497f1..8f50688af 100644 --- a/cmd/cudly-mcp/version_test.go +++ b/cmd/cudly-mcp/version_test.go @@ -1,23 +1,12 @@ package main -// version_test.go is the regression guard for the version-injection mismatch -// fixed alongside it: the Makefile's shared $(LDFLAGS) has always injected -// -X main.Version=$(VERSION) (see build-server), but this package used to -// declare a lowercase `version` var that ldflags cannot address by name, so -// every release build silently reported "dev" in the MCP initialize -// response's Implementation.Version. An in-process test against -// cudlymcp.NewServer cannot catch this: it calls NewServer directly with a -// Go string, bypassing the ldflags/linker step entirely. Only building the -// real binary with -ldflags and inspecting what it reports over stdio proves -// the wiring works end to end, the same way `make build-mcp` and a real -// release tag do. +// Linker injection is exercised only by building and running a subprocess. import ( "context" "os" "os/exec" "path/filepath" - "sync" "testing" "time" @@ -26,82 +15,25 @@ import ( ) const ( - // buildTimeout must cover a genuinely cold build: the "Unit Tests" CI job - // runs this test with a fresh module/build cache for the non-race build - // flavor (the preceding `go test -race` step only warms the race-enabled - // cache), so this recompiles the full AWS/Azure/GCP SDK dependency tree - // from scratch on shared CI hardware. That measured over 2 minutes in CI - // (see PR #1891); 5 minutes leaves headroom without masking a real hang. - buildTimeout = 5 * time.Minute - runTimeout = 15 * time.Second - - // injectedTestVersion stands in for a release tag; any value distinct - // from the "dev" zero value proves ldflags injection reached the binary. + buildTimeout = 5 * time.Minute + runTimeout = 15 * time.Second injectedTestVersion = "v0.0.0-version-test" ) -var ( - buildOnce sync.Once - buildDir string - builtBinary string - buildErr error -) - -// TestMain removes the compiled binary's directory once every test in this -// package has run; the binary is built once (buildOnce) and shared across -// tests, so its lifetime is the test binary's, not any single test's. -func TestMain(m *testing.M) { - code := m.Run() - if buildDir != "" { - _ = os.RemoveAll(buildDir) - } - os.Exit(code) -} - -// builtVersionedBinary compiles cmd/cudly-mcp once with -X main.Version set, -// mirroring `make build-mcp`'s ldflags, and returns the path to the -// executable. -func builtVersionedBinary(t *testing.T) string { - t.Helper() - buildOnce.Do(func() { - if _, err := exec.LookPath("go"); err != nil { - buildErr = err - return - } - dir, err := os.MkdirTemp("", "cudly-mcp-version-test-") - if err != nil { - buildErr = err - return - } - buildDir = dir - binPath := filepath.Join(dir, "cudly-mcp") - +func TestBuiltBinaryReportsInjectedVersion(t *testing.T) { + binPath := os.Getenv("CUDLY_MCP_TEST_BINARY") + if binPath == "" { + binPath = filepath.Join(t.TempDir(), "cudly-mcp") ctx, cancel := context.WithTimeout(context.Background(), buildTimeout) defer cancel() + cmd := exec.CommandContext(ctx, "go", "build", "-ldflags", "-X main.Version="+injectedTestVersion, "-o", binPath, ".") - cmd.Dir = "." // this package's directory, cmd/cudly-mcp - if out, err := cmd.CombinedOutput(); err != nil { - buildErr = err - t.Logf("go build output:\n%s", out) - return - } - builtBinary = binPath - }) - if buildErr != nil { - t.Fatalf("build cmd/cudly-mcp with version ldflags: %v", buildErr) + cmd.Dir = "." + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "build cmd/cudly-mcp with version ldflags:\n%s", out) } - return builtBinary -} - -// TestBuiltBinaryReportsInjectedVersion runs the actual compiled cudly-mcp -// binary as a subprocess over stdio (the same transport every real MCP -// client uses) and asserts the MCP initialize handshake's -// InitializeResult.ServerInfo.Version echoes the ldflags-injected version, -// not the "dev" fallback in cmd/cudly-mcp/main.go. -func TestBuiltBinaryReportsInjectedVersion(t *testing.T) { - binPath := builtVersionedBinary(t) ctx, cancel := context.WithTimeout(context.Background(), runTimeout) defer cancel() @@ -116,5 +48,5 @@ func TestBuiltBinaryReportsInjectedVersion(t *testing.T) { require.NotNil(t, result, "InitializeResult") require.NotNil(t, result.ServerInfo, "InitializeResult.ServerInfo") require.Equal(t, injectedTestVersion, result.ServerInfo.Version, - "built binary must report the ldflags-injected version, not the main.Version=\"dev\" fallback") + "built binary must report the injected version") }