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 e34ce114e..fbc0d39c4 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,13 @@ 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: + mkdir -p bin + CGO_ENABLED=0 go build $(LDFLAGS) -o bin/cudly-mcp ./cmd/cudly-mcp + # Run unit tests test: test-unit @@ -84,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/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..8f50688af --- /dev/null +++ b/cmd/cudly-mcp/version_test.go @@ -0,0 +1,52 @@ +package main + +// Linker injection is exercised only by building and running a subprocess. + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + gosdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" +) + +const ( + buildTimeout = 5 * time.Minute + runTimeout = 15 * time.Second + injectedTestVersion = "v0.0.0-version-test" +) + +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 = "." + out, err := cmd.CombinedOutput() + require.NoErrorf(t, err, "build cmd/cudly-mcp with version ldflags:\n%s", out) + } + + 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 injected version") +} diff --git a/mcp/README.md b/mcp/README.md index ea6fc3178..fedf5cf58 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`, 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] +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: