diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 37702cb..1669a0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,21 @@ jobs: steps: - name: ⬇️ Checkout Code uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: 🔧 Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔑 Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: 📦 Install Mise run: | diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000..7f9f86a --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,70 @@ +name: 🐳 Snapshot + +on: + push: + branches: + - main + pull_request: + types: [ synchronize, opened, reopened, labeled ] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + +jobs: + snapshot: + if: >- + (github.event_name == 'push' && github.ref == 'refs/heads/main') || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'snapshot')) + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout Code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: 🔧 Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: 🔧 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔑 Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 📦 Install Mise + run: | + curl https://mise.run | sh + mise install + + - name: 🏷️ Compute Docker tags + id: tags + run: | + short_sha="${GITHUB_SHA::7}" + if [ "${{ github.event_name }}" = "push" ]; then + echo "primary=main" >> "$GITHUB_OUTPUT" + echo "sha=main-${short_sha}" >> "$GITHUB_OUTPUT" + echo "extra=latest" >> "$GITHUB_OUTPUT" + else + slug="pr-${{ github.event.pull_request.number }}" + echo "primary=${slug}" >> "$GITHUB_OUTPUT" + echo "sha=${slug}-${short_sha}" >> "$GITHUB_OUTPUT" + echo "extra=" >> "$GITHUB_OUTPUT" + fi + + - name: 🐳 Build & push Docker image + run: mise run docker-snapshot + env: + DOCKER_TAG_PRIMARY: ${{ steps.tags.outputs.primary }} + DOCKER_TAG_SHA: ${{ steps.tags.outputs.sha }} + DOCKER_TAG_EXTRA: ${{ steps.tags.outputs.extra }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.branch.yml b/.goreleaser.branch.yml new file mode 100644 index 0000000..834e55d --- /dev/null +++ b/.goreleaser.branch.yml @@ -0,0 +1,42 @@ +project_name: skpr + +version: 2 + +builds: + - id: skpr-cli + main: ./cmd/skpr + binary: skpr + ldflags: + - -extldflags '-static' -X github.com/skpr/cli/cmd/skpr/version.GitVersion={{.Version}} -X github.com/skpr/cli/cmd/skpr/version.BuildDate={{time "2006-01-02"}} + env: + - CGO_ENABLED=0 + goos: [ linux ] + goarch: [ amd64, arm64 ] + goamd64: [ v1 ] + + - id: skpr-rsh + main: ./cmd/skpr-rsh + binary: skpr-rsh + ldflags: + - -extldflags '-static' + env: + - CGO_ENABLED=0 + goos: [ linux ] + goarch: [ amd64, arm64 ] + goamd64: [ v1 ] + +dockers_v2: + - id: skpr + ids: + - skpr-cli + - skpr-rsh + images: + - "ghcr.io/skpr/cli" + platforms: + - linux/amd64 + - linux/arm64 + tags: + - "{{ .Env.DOCKER_TAG_PRIMARY }}" + - "{{ .Env.DOCKER_TAG_SHA }}" + - "{{ .Env.DOCKER_TAG_EXTRA }}" + sbom: false diff --git a/.goreleaser.yml b/.goreleaser.yml index 2b04ee4..82e04c6 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -41,6 +41,21 @@ release: owner: skpr name: cli +dockers_v2: + - id: skpr + ids: + - skpr-cli + - skpr-rsh + images: + - "ghcr.io/skpr/cli" + platforms: + - linux/amd64 + - linux/arm64 + tags: + - "{{ .Tag }}" + - "latest" + sbom: false + changelog: use: github-native diff --git a/.mise.toml b/.mise.toml index 52a2225..6b5a20b 100644 --- a/.mise.toml +++ b/.mise.toml @@ -46,3 +46,8 @@ depends = ["vendor"] [tasks.snapshot-release] description = "Create a snapshot release for local testing" run = "goreleaser --snapshot --clean --verbose" + +[tasks.docker-snapshot] +description = "Build and push a Docker image via goreleaser (no GitHub release)" +run = "goreleaser release --config=.goreleaser.branch.yml --clean --skip=validate" +depends = ["vendor"] diff --git a/Dockerfile b/Dockerfile index e7a8ab7..f63525e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ -FROM alpine:3.21 +FROM alpine:3.23 RUN apk --no-cache add bash ca-certificates git openssh-client curl rsync docker-cli jq yq -COPY skpr skpr-rsh /usr/local/bin/ + +ARG TARGETPLATFORM +COPY $TARGETPLATFORM/skpr $TARGETPLATFORM/skpr-rsh /usr/local/bin/ CMD ["skpr"] diff --git a/cmd/skpr/main.go b/cmd/skpr/main.go index 3b4bd6f..a01fae2 100644 --- a/cmd/skpr/main.go +++ b/cmd/skpr/main.go @@ -28,6 +28,7 @@ import ( "github.com/skpr/cli/cmd/skpr/login" "github.com/skpr/cli/cmd/skpr/logout" "github.com/skpr/cli/cmd/skpr/logs" + mcpcmd "github.com/skpr/cli/cmd/skpr/mcp" "github.com/skpr/cli/cmd/skpr/mysql" pkg "github.com/skpr/cli/cmd/skpr/package" "github.com/skpr/cli/cmd/skpr/project" @@ -107,6 +108,7 @@ func main() { cmd.AddCommand(login.NewCommand()) cmd.AddCommand(logout.NewCommand()) cmd.AddCommand(logs.NewCommand()) + cmd.AddCommand(mcpcmd.NewCommand(featureFlags.DockerClient)) cmd.AddCommand(mysql.NewCommand(featureFlags.DockerClient)) cmd.AddCommand(pkg.NewCommand(featureFlags.DockerClient)) cmd.AddCommand(project.NewCommand()) diff --git a/cmd/skpr/mcp/command.go b/cmd/skpr/mcp/command.go new file mode 100644 index 0000000..c9e4a52 --- /dev/null +++ b/cmd/skpr/mcp/command.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "github.com/spf13/cobra" + + "github.com/skpr/cli/containers/docker" + skprcommand "github.com/skpr/cli/internal/command" + v1mcp "github.com/skpr/cli/internal/command/mcp" +) + +var ( + cmdLong = `Run a Model Context Protocol (MCP) server. + +The server exposes a subset of skpr's functionality as MCP tools so that it +can be wired into any MCP-compatible client (Claude Desktop, opencode, etc.). + +Available tools + list_environments List all environments for the current project. + get_environment Get detailed information about a specific environment. + mysql_image_list List database images available for an environment. + mysql_image_pull Pull a database image to the local Docker daemon. + version Return the CLI client and server versions. + +Stdio mode (default) + Reads JSON-RPC from stdin and writes responses to stdout. Use this when + an MCP client spawns skpr as a child process. + + Example client configuration (opencode / claude_desktop_config.json): + + "skpr": { + "type": "stdio", + "command": "skpr", + "args": ["mcp"] + } + +HTTP mode (--http) + Starts a Streamable HTTP server on the given address. Use this when running + skpr as a sidecar container or when multiple clients need to share one server. + + Example — listen on all interfaces, port 8080: + skpr mcp --http :8080 + + Example — localhost only (recommended for local development): + skpr mcp --http 127.0.0.1:8080 + + Example client configuration for HTTP: + "skpr": { + "type": "http", + "url": "http://localhost:8080/" + } + + Docker sidecar example: + docker run --rm \ + -v ~/.config/skpr:/root/.config/skpr:ro \ + -p 8080:8080 \ + skpr mcp --http :8080 + + Kubernetes sidecar container spec: + - name: skpr-mcp + image: ghcr.io/skpr/cli:latest + args: ["mcp", "--http", ":8080"] + ports: + - containerPort: 8080 + volumeMounts: + - name: skpr-config + mountPath: /root/.config/skpr + readOnly: true + + Health probe (liveness / readiness): + GET /healthz → 200 OK body: ok + +SECURITY NOTE + HTTP mode exposes your skpr credentials to anyone who can reach the + listening port. There is no built-in authentication in this release. + Bind to localhost or a private network interface, and use your + infrastructure's network controls to restrict access.` + + // GitVersion is overridden at build time via ldflags (shared with the + // version command). + GitVersion string + // BuildDate is overridden at build time via ldflags. + BuildDate string +) + +// NewCommand creates the cobra.Command for 'skpr mcp'. +func NewCommand(clientId docker.DockerClientId) *cobra.Command { + command := &v1mcp.Command{ + ClientId: clientId, + GitVersion: GitVersion, + BuildDate: BuildDate, + } + + cmd := &cobra.Command{ + Use: "mcp", + Args: cobra.NoArgs, + DisableFlagsInUseLine: true, + Short: "Run a Model Context Protocol server", + Long: cmdLong, + GroupID: skprcommand.GroupOther, + RunE: func(cmd *cobra.Command, _ []string) error { + return command.Run(cmd.Context()) + }, + } + + cmd.Flags().StringVar(&command.HTTPAddr, "http", "", + `Listen address for HTTP transport (e.g. ":8080" or "127.0.0.1:8080"). Empty uses stdio.`) + cmd.Flags().StringVar(&command.HTTPPath, "path", "/", + "URL path to mount the MCP handler on (HTTP mode only).") + cmd.Flags().BoolVar(&command.HTTPStateless, "stateless", false, + "Disable session tracking (HTTP mode only). Useful behind a stateless load balancer.") + cmd.Flags().BoolVar(&command.HTTPAllowCrossOriginHosts, "allow-cross-origin-hosts", false, + "Disable DNS-rebinding protection (HTTP mode only). Only use if you understand the security implications.") + + return cmd +} diff --git a/go.mod b/go.mod index 9a795e3..bd60d89 100644 --- a/go.mod +++ b/go.mod @@ -28,6 +28,7 @@ require ( github.com/moby/go-archive v0.2.0 github.com/moby/moby/api v1.54.2 github.com/moby/patternmatcher v0.6.1 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0 github.com/pkg/errors v0.9.1 github.com/skpr/api v1.6.0 @@ -83,6 +84,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/hokaccha/go-prettyjson v0.0.0-20211117102719-0474bc63780f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jwalton/go-supportscolor v1.2.0 // indirect @@ -111,9 +113,12 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect diff --git a/go.sum b/go.sum index 8db516d..8b14e9c 100644 --- a/go.sum +++ b/go.sum @@ -118,10 +118,14 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= @@ -181,6 +185,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -215,6 +221,10 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skpr/api v1.6.0 h1:LzbNm97wXvnWAcm+iyV1r36YKGMFWNn65y+EHhBxgqo= @@ -236,6 +246,8 @@ github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8 h1:RB0v+/pc8oMzPsN github.com/tcnksm/go-input v0.0.0-20180404061846-548a7d7a8ee8/go.mod h1:IlWNj9v/13q7xFbaK4mbyzMNwrZLaWSHx/aibKIZuIg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -284,6 +296,8 @@ golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= diff --git a/internal/command/mcp/command.go b/internal/command/mcp/command.go new file mode 100644 index 0000000..d0031bf --- /dev/null +++ b/internal/command/mcp/command.go @@ -0,0 +1,95 @@ +package mcp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/skpr/cli/containers/docker" +) + +// Command runs the MCP server over stdio or HTTP. +type Command struct { + ClientId docker.DockerClientId + GitVersion string + BuildDate string + + // HTTP transport options. HTTPAddr being non-empty selects HTTP mode. + // In HTTP mode stdout is free for logging; stdio mode keeps stdout + // exclusively for JSON-RPC frames. + HTTPAddr string // e.g. ":8080" or "127.0.0.1:8080" + HTTPPath string // URL path to mount the MCP handler on (default "/") + HTTPStateless bool // maps to StreamableHTTPOptions.Stateless + HTTPAllowCrossOriginHosts bool // maps to StreamableHTTPOptions.DisableLocalhostProtection +} + +// Run starts the MCP server and blocks until the client disconnects or the +// context is cancelled. +// +// When HTTPAddr is empty the server runs over stdio (stdin/stdout). All +// JSON-RPC framing is on stdout; incidental output goes to stderr. +// +// When HTTPAddr is non-empty the server listens for Streamable HTTP +// connections on the given address. stdout is available for logging. +func (cmd *Command) Run(ctx context.Context) error { + srv := Build(cmd.ClientId, cmd.GitVersion, cmd.BuildDate) + if cmd.HTTPAddr == "" { + return srv.Run(ctx, &mcp.StdioTransport{}) + } + return cmd.runHTTP(ctx, srv) +} + +// runHTTP starts an HTTP server that wraps the MCP server using the +// Streamable HTTP transport defined by the MCP spec. +func (cmd *Command) runHTTP(ctx context.Context, srv *mcp.Server) error { + path := cmd.HTTPPath + if path == "" { + path = "/" + } + + handler := mcp.NewStreamableHTTPHandler( + func(*http.Request) *mcp.Server { return srv }, + &mcp.StreamableHTTPOptions{ + Stateless: cmd.HTTPStateless, + DisableLocalhostProtection: cmd.HTTPAllowCrossOriginHosts, + Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), + }, + ) + + mux := http.NewServeMux() + mux.Handle(path, handler) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, "ok") + }) + + httpSrv := &http.Server{ + Addr: cmd.HTTPAddr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + errCh := make(chan error, 1) + go func() { + slog.Info("MCP server listening", "addr", cmd.HTTPAddr, "path", path) + errCh <- httpSrv.ListenAndServe() + }() + + select { + case <-ctx.Done(): + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return httpSrv.Shutdown(shutdownCtx) + case err := <-errCh: + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err + } +} diff --git a/internal/command/mcp/command_test.go b/internal/command/mcp/command_test.go new file mode 100644 index 0000000..1dc8720 --- /dev/null +++ b/internal/command/mcp/command_test.go @@ -0,0 +1,130 @@ +package mcp_test + +import ( + "context" + "net" + "net/http" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + skprmcp "github.com/skpr/cli/internal/command/mcp" +) + +// freeAddr finds an available TCP address on localhost by briefly opening a +// listener and then closing it. The address is returned in "host:port" form. +func freeAddr(t *testing.T) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + return addr +} + +// waitHealthz polls /healthz until it returns 200 OK or the deadline is +// reached. It returns an error if the server never becomes healthy. +func waitHealthz(t *testing.T, addr string) error { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + url := "http://" + addr + "/healthz" + for time.Now().Before(deadline) { + resp, err := http.Get(url) //nolint:noctx + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(25 * time.Millisecond) + } + return context.DeadlineExceeded +} + +func TestHTTPTransportLifecycle(t *testing.T) { + addr := freeAddr(t) + + command := &skprmcp.Command{ + ClientId: "", + GitVersion: "v0.0.0-test", + BuildDate: "1970-01-01", + HTTPAddr: addr, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- command.Run(ctx) + }() + + // Wait for the HTTP server to be ready. + require.NoError(t, waitHealthz(t, addr), "server did not become healthy in time") + + // Connect via the Streamable HTTP transport. + mcpClient := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) + session, err := mcpClient.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: "http://" + addr + "/", + DisableStandaloneSSE: true, + }, nil) + require.NoError(t, err) + defer session.Close() + + // Collect tool names advertised by the server. + toolNames := make(map[string]struct{}) + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + toolNames[tool.Name] = struct{}{} + } + + expected := []string{ + "list_environments", + "get_environment", + "mysql_image_list", + "mysql_image_pull", + "version", + } + for _, name := range expected { + assert.Contains(t, toolNames, name, "expected tool %q to be registered", name) + } + assert.Len(t, toolNames, len(expected)) + + // Close the session then cancel the context; the server should shut down cleanly. + require.NoError(t, session.Close()) + cancel() + + select { + case err := <-done: + assert.NoError(t, err, "server.Run should return nil on clean shutdown") + case <-time.After(5 * time.Second): + t.Fatal("server did not shut down within 5 seconds") + } +} + +func TestHealthzEndpoint(t *testing.T) { + addr := freeAddr(t) + + command := &skprmcp.Command{ + ClientId: "", + GitVersion: "v0.0.0-test", + BuildDate: "1970-01-01", + HTTPAddr: addr, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go command.Run(ctx) //nolint:errcheck + + require.NoError(t, waitHealthz(t, addr)) + + resp, err := http.Get("http://" + addr + "/healthz") //nolint:noctx + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) +} diff --git a/internal/command/mcp/server.go b/internal/command/mcp/server.go new file mode 100644 index 0000000..ea54deb --- /dev/null +++ b/internal/command/mcp/server.go @@ -0,0 +1,43 @@ +package mcp + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/skpr/cli/containers/docker" + "github.com/skpr/cli/internal/command/mcp/tools" +) + +// Build creates a new MCP server with all registered tools. +func Build(clientId docker.DockerClientId, gitVersion, buildDate string) *mcp.Server { + srv := mcp.NewServer(&mcp.Implementation{ + Name: "skpr", + Version: gitVersion, + }, nil) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "list_environments", + Description: "List all environments for the current project and their status.", + }, tools.ListEnvironments) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "get_environment", + Description: "Get detailed information about a specific environment.", + }, tools.GetEnvironment) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "mysql_image_list", + Description: "List database images available for an environment.", + }, tools.MysqlImageList) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "mysql_image_pull", + Description: "Pull a database image for an environment to the local Docker daemon.", + }, tools.NewMysqlImagePull(clientId)) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "version", + Description: "Return the CLI client version and (if reachable) the server version.", + }, tools.NewVersion(gitVersion, buildDate)) + + return srv +} diff --git a/internal/command/mcp/tools/environments.go b/internal/command/mcp/tools/environments.go new file mode 100644 index 0000000..f6f232a --- /dev/null +++ b/internal/command/mcp/tools/environments.go @@ -0,0 +1,87 @@ +package tools + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/skpr/api/pb" + + "github.com/skpr/cli/internal/client" +) + +// ListEnvironmentsInput is the input schema for the list_environments tool. +type ListEnvironmentsInput struct{} + +// EnvironmentSummary is a single row in the list_environments output. +type EnvironmentSummary struct { + Name string `json:"name"` + Version string `json:"version"` + Size string `json:"size"` + Routes string `json:"routes"` + Phase string `json:"phase"` + Production bool `json:"production"` +} + +// ListEnvironmentsOutput is the output schema for the list_environments tool. +type ListEnvironmentsOutput struct { + Environments []EnvironmentSummary `json:"environments"` +} + +// ListEnvironments lists all environments for the current project. +func ListEnvironments(ctx context.Context, req *mcp.CallToolRequest, _ ListEnvironmentsInput) (*mcp.CallToolResult, ListEnvironmentsOutput, error) { + ctx, skprClient, err := client.New(ctx) + if err != nil { + return nil, ListEnvironmentsOutput{}, fmt.Errorf("failed to create client: %w", err) + } + + resp, err := skprClient.Environment().List(ctx, &pb.EnvironmentListRequest{}) + if err != nil { + return nil, ListEnvironmentsOutput{}, fmt.Errorf("could not list environments: %w", err) + } + + out := ListEnvironmentsOutput{ + Environments: make([]EnvironmentSummary, 0, len(resp.Environments)), + } + + for _, env := range resp.Environments { + routes := append(env.Ingress.Routes, env.Ingress.Domain) + out.Environments = append(out.Environments, EnvironmentSummary{ + Name: env.Name, + Version: env.Version, + Size: env.Size, + Routes: strings.Join(routes, ", "), + Phase: env.Phase, + Production: env.Production, + }) + } + + return nil, out, nil +} + +// GetEnvironmentInput is the input schema for the get_environment tool. +type GetEnvironmentInput struct { + // Environment is required — no omitempty so the schema marks it required. + Environment string `json:"environment" jsonschema:"Name of the environment to retrieve"` +} + +// GetEnvironmentOutput is the output schema for the get_environment tool. +type GetEnvironmentOutput struct { + Environment *pb.Environment `json:"environment"` +} + +// GetEnvironment retrieves detailed information about a single environment. +func GetEnvironment(ctx context.Context, req *mcp.CallToolRequest, in GetEnvironmentInput) (*mcp.CallToolResult, GetEnvironmentOutput, error) { + ctx, skprClient, err := client.New(ctx) + if err != nil { + return nil, GetEnvironmentOutput{}, fmt.Errorf("failed to create client: %w", err) + } + + resp, err := skprClient.Environment().Get(ctx, &pb.EnvironmentGetRequest{Name: in.Environment}) + if err != nil { + return nil, GetEnvironmentOutput{}, fmt.Errorf("could not get environment %q: %w", in.Environment, err) + } + + return nil, GetEnvironmentOutput{Environment: resp.Environment}, nil +} diff --git a/internal/command/mcp/tools/mysql_image.go b/internal/command/mcp/tools/mysql_image.go new file mode 100644 index 0000000..2915945 --- /dev/null +++ b/internal/command/mcp/tools/mysql_image.go @@ -0,0 +1,215 @@ +package tools + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/pkg/errors" + "github.com/skpr/api/pb" + + "github.com/skpr/cli/containers/buildpack/utils/aws/ecr" + "github.com/skpr/cli/containers/docker" + "github.com/skpr/cli/containers/docker/types" + "github.com/skpr/cli/internal/client" + timeutils "github.com/skpr/cli/internal/time" +) + +// MysqlImageListInput is the input schema for the mysql_image_list tool. +type MysqlImageListInput struct { + // Environment is required — no omitempty so the schema marks it required. + Environment string `json:"environment" jsonschema:"Name of the environment"` +} + +// MysqlImageRow is a single row in the mysql_image_list output. +type MysqlImageRow struct { + ID string `json:"id"` + Phase string `json:"phase"` + StartTime string `json:"start_time,omitempty"` + CompletionTime string `json:"completion_time,omitempty"` + Duration string `json:"duration,omitempty"` + Tags []string `json:"tags"` +} + +// MysqlImageListOutput is the output schema for the mysql_image_list tool. +type MysqlImageListOutput struct { + Images []MysqlImageRow `json:"images"` +} + +// MysqlImageList lists database images for the given environment. +func MysqlImageList(ctx context.Context, req *mcp.CallToolRequest, in MysqlImageListInput) (*mcp.CallToolResult, MysqlImageListOutput, error) { + ctx, skprClient, err := client.New(ctx) + if err != nil { + return nil, MysqlImageListOutput{}, fmt.Errorf("failed to create client: %w", err) + } + + resp, err := skprClient.Mysql().ImageList(ctx, &pb.ImageListRequest{ + Environment: in.Environment, + }) + if err != nil { + return nil, MysqlImageListOutput{}, fmt.Errorf("image list failed: %w", err) + } + + out := MysqlImageListOutput{ + Images: make([]MysqlImageRow, 0, len(resp.List)), + } + + for _, item := range resp.List { + row := MysqlImageRow{ + ID: item.ID, + Phase: item.Phase.String(), + Tags: item.Tags, + } + + if item.StartTime != "" { + start, err := timeutils.ParseString(item.StartTime) + if err != nil { + return nil, MysqlImageListOutput{}, fmt.Errorf("failed to parse start time: %w", err) + } + row.StartTime = start.Format(time.RFC822Z) + } + + if item.CompletionTime != "" { + completion, err := timeutils.ParseString(item.CompletionTime) + if err != nil { + return nil, MysqlImageListOutput{}, fmt.Errorf("failed to parse completion time: %w", err) + } + row.CompletionTime = completion.Format(time.RFC822Z) + row.Duration = item.Duration + } + + out.Images = append(out.Images, row) + } + + return nil, out, nil +} + +// MysqlImagePullInput is the input schema for the mysql_image_pull tool. +type MysqlImagePullInput struct { + // Environment is required — no omitempty so the schema marks it required. + Environment string `json:"environment" jsonschema:"Name of the environment"` + Databases []string `json:"databases,omitempty" jsonschema:"Database names to pull (defaults to [default])"` + ID string `json:"id,omitempty" jsonschema:"Specific image ID to pull (overrides Databases)"` +} + +// MysqlImagePullEntry is a single pulled image result. +type MysqlImagePullEntry struct { + Image string `json:"image"` + Status string `json:"status"` // "pulled" or "up-to-date" +} + +// MysqlImagePullOutput is the output schema for the mysql_image_pull tool. +type MysqlImagePullOutput struct { + Pulled []MysqlImagePullEntry `json:"pulled"` +} + +// NewMysqlImagePull returns a tool handler for mysql_image_pull using the given Docker client ID. +func NewMysqlImagePull(clientId docker.DockerClientId) func(context.Context, *mcp.CallToolRequest, MysqlImagePullInput) (*mcp.CallToolResult, MysqlImagePullOutput, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, in MysqlImagePullInput) (*mcp.CallToolResult, MysqlImagePullOutput, error) { + ctx, skprClient, err := client.New(ctx) + if err != nil { + return nil, MysqlImagePullOutput{}, fmt.Errorf("failed to create client: %w", err) + } + + getRepositoryResp, err := skprClient.Mysql().ImageGetRepository(ctx, &pb.ImageGetRepositoryRequest{ + Environment: in.Environment, + }) + if err != nil { + return nil, MysqlImagePullOutput{}, fmt.Errorf("failed to get repository: %w", err) + } + + auth := types.Auth{ + Username: skprClient.Credentials.Username, + Password: skprClient.Credentials.Password, + Session: skprClient.Credentials.Session, + } + + if ecr.IsRegistry(getRepositoryResp.Repository) { + auth, err = ecr.UpgradeAuth(ctx, getRepositoryResp.Repository, auth) + if err != nil { + return nil, MysqlImagePullOutput{}, errors.Wrap(err, "failed to upgrade AWS ECR authentication") + } + } + + dockerClient, err := docker.NewClientFromUserConfig(auth, clientId) + if err != nil { + return nil, MysqlImagePullOutput{}, errors.Wrap(err, "failed to create Docker client") + } + + // Build tag list — same logic as the CLI command. + tags := []string{} + if in.ID != "" { + tags = append(tags, in.ID) + } else { + databases := in.Databases + if len(databases) == 0 { + databases = []string{"default"} + } + for _, database := range databases { + tags = append(tags, fmt.Sprintf("%s-latest", database)) + } + } + + // MCP stdout must only carry JSON-RPC frames; route Docker progress to + // stderr so it doesn't corrupt the transport. + progressWriter := io.Writer(os.Stderr) + + out := MysqlImagePullOutput{ + Pulled: make([]MysqlImagePullEntry, 0, len(tags)), + } + + for _, tag := range tags { + imageName := fmt.Sprintf("%s:%s", getRepositoryResp.Repository, tag) + + cleanupId, err := dockerClient.ImageId(context.TODO(), imageName) + if err != nil { + return nil, MysqlImagePullOutput{}, err + } + + err = dockerClient.PullImage(context.TODO(), getRepositoryResp.Repository, tag, progressWriter) + if err != nil { + return nil, MysqlImagePullOutput{}, err + } + + currentId, err := dockerClient.ImageId(context.TODO(), imageName) + if err != nil { + return nil, MysqlImagePullOutput{}, err + } + + status := "pulled" + if cleanupId == currentId { + status = "up-to-date" + } + + out.Pulled = append(out.Pulled, MysqlImagePullEntry{ + Image: imageName, + Status: status, + }) + + // Clean up the old image if it differs from the new one. + if cleanupId != "" && cleanupId != currentId { + if err := dockerClient.RemoveImage(context.TODO(), cleanupId); err != nil { + fmt.Fprintf(os.Stderr, "warning: failed to remove old image %s: %v\n", cleanupId, err) + } + } + } + + // Summarise for the text part of the tool result, keeping stdout JSON-RPC-clean. + summary := make([]string, 0, len(out.Pulled)) + for _, entry := range out.Pulled { + summary = append(summary, fmt.Sprintf("%s (%s)", entry.Image, entry.Status)) + } + + result := &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: strings.Join(summary, "\n")}, + }, + } + + return result, out, nil + } +} diff --git a/internal/command/mcp/tools/tools_test.go b/internal/command/mcp/tools/tools_test.go new file mode 100644 index 0000000..4661f94 --- /dev/null +++ b/internal/command/mcp/tools/tools_test.go @@ -0,0 +1,100 @@ +package tools_test + +import ( + "context" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + skprmcp "github.com/skpr/cli/internal/command/mcp" +) + +// buildTestServer builds the MCP server with a dummy docker client ID and +// empty build info so it can be used in tests without real credentials. +func buildTestServer() *mcp.Server { + return skprmcp.Build("", "v0.0.0-test", "1970-01-01") +} + +func TestToolRegistration(t *testing.T) { + srv := buildTestServer() + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := srv.Connect(ctx, st, nil) + require.NoError(t, err) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) + session, err := client.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + // Collect tool names advertised by the server. + toolNames := make(map[string]struct{}) + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + toolNames[tool.Name] = struct{}{} + } + + expected := []string{ + "list_environments", + "get_environment", + "mysql_image_list", + "mysql_image_pull", + "version", + } + + for _, name := range expected { + assert.Contains(t, toolNames, name, "expected tool %q to be registered", name) + } + + assert.Len(t, toolNames, len(expected), "unexpected number of registered tools") +} + +func TestMysqlImagePullInputSchema(t *testing.T) { + srv := buildTestServer() + + ctx := context.Background() + ct, st := mcp.NewInMemoryTransports() + + _, err := srv.Connect(ctx, st, nil) + require.NoError(t, err) + + mcpClient := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0"}, nil) + session, err := mcpClient.Connect(ctx, ct, nil) + require.NoError(t, err) + defer session.Close() + + // Find the mysql_image_pull tool and verify its input schema marks + // "environment" as required. + for tool, err := range session.Tools(ctx, nil) { + require.NoError(t, err) + if tool.Name != "mysql_image_pull" { + continue + } + + require.NotNil(t, tool.InputSchema, "mysql_image_pull must have an input schema") + + // InputSchema arrives as map[string]any over the wire from the client. + schema, ok := tool.InputSchema.(map[string]any) + require.True(t, ok, "InputSchema should be a map[string]any") + + rawRequired, ok := schema["required"] + require.True(t, ok, "mysql_image_pull input schema must have a 'required' key") + + required, ok := rawRequired.([]any) + require.True(t, ok, "'required' must be a []any") + + requiredStrings := make([]string, len(required)) + for i, r := range required { + requiredStrings[i], _ = r.(string) + } + + assert.Contains(t, requiredStrings, "environment", "environment must be required in mysql_image_pull input schema") + return + } + + t.Fatal("mysql_image_pull tool not found") +} diff --git a/internal/command/mcp/tools/version.go b/internal/command/mcp/tools/version.go new file mode 100644 index 0000000..5ffe652 --- /dev/null +++ b/internal/command/mcp/tools/version.go @@ -0,0 +1,65 @@ +package tools + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/skpr/api/pb" + + "github.com/skpr/cli/internal/client" +) + +// VersionInput is the input schema for the version tool. +type VersionInput struct{} + +// VersionOutput is the output schema for the version tool. +type VersionOutput struct { + ClientVersion string `json:"client_version"` + ClientBuildDate string `json:"client_build_date"` + ServerVersion string `json:"server_version,omitempty"` + ServerBuildDate string `json:"server_build_date,omitempty"` +} + +// NewVersion returns a tool handler for the version tool, embedding build-time +// values supplied by the cobra command layer. +func NewVersion(gitVersion, buildDate string) func(context.Context, *mcp.CallToolRequest, VersionInput) (*mcp.CallToolResult, VersionOutput, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, _ VersionInput) (*mcp.CallToolResult, VersionOutput, error) { + out := VersionOutput{ + ClientVersion: gitVersion, + ClientBuildDate: buildDate, + } + + // Best-effort: attempt to retrieve the server version. + ctx, skprClient, err := client.New(ctx) + if err == nil { + resp, err := skprClient.Version().Get(ctx, &pb.VersionGetRequest{}) + if err == nil && resp != nil { + out.ServerVersion = resp.Version + out.ServerBuildDate = resp.BuildDate + } + } + + result := &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: fmt.Sprintf("Client: %s (%s) | Server: %s (%s)", + or(out.ClientVersion, "unknown"), + or(out.ClientBuildDate, "unknown"), + or(out.ServerVersion, "unknown"), + or(out.ServerBuildDate, "unknown"), + ), + }, + }, + } + + return result, out, nil + } +} + +func or(s, fallback string) string { + if s == "" { + return fallback + } + return s +}