Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ config.local.json
# Build output
/bin/
/dist/
/server
/agent

# Temporary files
/tmp/
Expand Down
239 changes: 238 additions & 1 deletion cmd/server/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,250 @@
package main

import (
"context"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"

"github.com/codeready-toolchain/cli-mcp-server/pkg/server"
"github.com/codeready-toolchain/cli-mcp-server/pkg/session"
"github.com/codeready-toolchain/cli-mcp-server/pkg/tools"
"github.com/codeready-toolchain/cli-mcp-server/pkg/version"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)

const shutdownTimeout = 310 * time.Second

func main() {
fmt.Fprintf(os.Stderr, "cli-mcp-server %s (built %s)\n", version.Commit, version.BuildTime)
// TODO: Cobra root command, MCP server setup (SANDBOX-1814)

var (
address string
transport string
stateless bool
namespace string
sandboxImage string
kubeconfig string
hmacKeyFile string
idleTimeout time.Duration
warmPoolSize int
)

rootCmd := &cobra.Command{
Use: "cli-mcp-server",
Short: "Sandboxed exec environment MCP server for LLM investigation",
RunE: func(_ *cobra.Command, _ []string) error {
return runServer(runConfig{
address: address,
transport: transport,
stateless: stateless,
namespace: namespace,
sandboxImage: sandboxImage,
kubeconfig: kubeconfig,
hmacKeyFile: hmacKeyFile,
idleTimeout: idleTimeout,
warmPoolSize: warmPoolSize,
})
},
}

rootCmd.Flags().StringVarP(&address, "address", "a", "localhost:8080", "Server address (host:port)")
rootCmd.Flags().StringVarP(&transport, "transport", "t", "stdio", "Transport (stdio, http)")
rootCmd.Flags().BoolVar(&stateless, "stateless", false, "Enable stateless mode (required for HTTP)")
rootCmd.Flags().StringVar(&namespace, "namespace", "tarsy", "Namespace for sandbox pods")
rootCmd.Flags().StringVar(&sandboxImage, "sandbox-image", "", "Container image for sandbox pods (required)")
rootCmd.Flags().StringVar(&kubeconfig, "kubeconfig", "", "Path to kubeconfig for sandbox pods")
rootCmd.Flags().StringVar(&hmacKeyFile, "hmac-key-file", "", "Path to HMAC shared secret file (required)")
rootCmd.Flags().DurationVar(&idleTimeout, "idle-timeout", 30*time.Minute, "Idle timeout for sandbox pods")
rootCmd.Flags().IntVar(&warmPoolSize, "warm-pool-size", 0, "Pre-warmed sandbox pods (0 = disabled)")

if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

type runConfig struct {
address string
transport string
stateless bool
namespace string
sandboxImage string
kubeconfig string
hmacKeyFile string
idleTimeout time.Duration
warmPoolSize int
}

func runServer(cfg runConfig) error {
if err := server.ValidateTransportFlags(cfg.transport, cfg.stateless, cfg.address); err != nil {
return err
}
if cfg.sandboxImage == "" {
return fmt.Errorf("--sandbox-image is required")
}
if cfg.idleTimeout <= 0 {
return fmt.Errorf("--idle-timeout must be greater than zero")
}
if cfg.warmPoolSize < 0 {
return fmt.Errorf("--warm-pool-size must not be negative")
}
hmacKey, err := loadHMACKey(cfg.hmacKeyFile)
if err != nil {
return err
}

logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
AddSource: true,
})).With("server", "cli-mcp-server")

clientset, err := buildClientset(cfg.kubeconfig)
if err != nil {
return fmt.Errorf("failed to create kubernetes client: %w", err)
}

sandboxCfg := session.DefaultConfig()
sandboxCfg.Image = cfg.sandboxImage
sandboxCfg.HMACKey = hmacKey
sandboxCfg.Namespace = cfg.namespace
sandboxCfg.IdleTimeout = cfg.idleTimeout
sandboxCfg.WarmPoolSize = cfg.warmPoolSize
Comment thread
coderabbitai[bot] marked this conversation as resolved.

mgr, err := session.NewSessionManager(clientset, sandboxCfg, logger)
if err != nil {
return fmt.Errorf("failed to create session manager: %w", err)
}

mcpServer := server.NewMCPServer("cli-mcp-server", cfg.stateless, logger)
tools.NewBashTool(mgr).RegisterWith(mcpServer)

// Shared context cancelled on SIGTERM/SIGINT — stops background workers for both transports.
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()

startCleanupLoop(ctx, mgr, logger)
if cfg.warmPoolSize > 0 {
mgr.StartPool(ctx)
}

switch cfg.transport {
case "http":
return serveHTTP(ctx, cancel, cfg.address, mcpServer, mgr, clientset, cfg.namespace, logger)
case "stdio":
return serveStdio(ctx, mcpServer)
default:
return fmt.Errorf("unsupported transport: %s", cfg.transport)
}
}

func serveHTTP(ctx context.Context, cancel context.CancelFunc, address string, mcpServer *mcp.Server, mgr *session.SessionManager, clientset kubernetes.Interface, namespace string, logger *slog.Logger) error {
checker := &k8sHealthChecker{clientset: clientset, namespace: namespace}
mux := server.NewMux(mcpServer, mgr, checker, logger)

srv := &http.Server{
Addr: address,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second, // mitigate slowloris; leave ReadTimeout unset for MCP streams
}
serverErrChan := make(chan error, 1)
go func() {
log.Printf("listening on %s (HTTP, stateless)", address)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
serverErrChan <- err
}
}()

select {
case <-ctx.Done():
log.Println("received shutdown signal, draining...")
case err := <-serverErrChan:
cancel()
return fmt.Errorf("HTTP serve failed: %w", err)
}

cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown error: %v", err)
}
log.Println("shutdown complete")
return nil
}

func serveStdio(ctx context.Context, mcpServer *mcp.Server) error {
log.Println("serving on stdio")
if err := mcpServer.Run(ctx, &mcp.StdioTransport{}); err != nil && ctx.Err() == nil {
return fmt.Errorf("stdio transport error: %w", err)
}
log.Println("shutdown complete")
return nil
}

func startCleanupLoop(ctx context.Context, mgr *session.SessionManager, logger *slog.Logger) {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cleaned, err := mgr.CleanupStale(ctx)
if err != nil {
logger.Error("stale cleanup failed", "error", err)
} else if cleaned > 0 {
logger.Info("cleaned stale sessions", "count", cleaned)
}
}
}
}()
}

func loadHMACKey(path string) (string, error) {
if path == "" {
return "", fmt.Errorf("--hmac-key-file is required")
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read HMAC key file: %w", err)
}
if len(data) == 0 {
return "", fmt.Errorf("HMAC key file is empty (zero bytes): %s", path)
}
return string(data), nil
}

func buildClientset(kubeconfigPath string) (kubernetes.Interface, error) {
var config *rest.Config
var err error
if kubeconfigPath != "" {
config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
} else {
config, err = rest.InClusterConfig()
}
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(config)
}

type k8sHealthChecker struct {
clientset kubernetes.Interface
namespace string
}

func (c *k8sHealthChecker) CheckHealth(ctx context.Context) error {
_, err := c.clientset.CoreV1().Namespaces().Get(ctx, c.namespace, metav1.GetOptions{})
return err
}
18 changes: 14 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ module github.com/codeready-toolchain/cli-mcp-server
go 1.25.12

require (
github.com/codeready-toolchain/mcp-common v0.0.0-20260311065550-14ceedd27660
github.com/google/uuid v1.6.0
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/prometheus/client_golang v1.22.0
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
k8s.io/api v0.33.4
k8s.io/apimachinery v0.33.4
k8s.io/client-go v0.33.4
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
Expand All @@ -23,22 +28,27 @@ require (
github.com/google/gnostic-models v0.6.9 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
Expand Down
Loading
Loading