Skip to main content
Glama
mjmorales

simple-mcp-runner

by mjmorales
executor.go11.7 kB
// Package executor handles safe command execution with timeouts and resource limits package executor import ( "bytes" "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "sync" "sync/atomic" "time" "github.com/mjmorales/simple-mcp-runner/pkg/config" apperrors "github.com/mjmorales/simple-mcp-runner/pkg/errors" "github.com/mjmorales/simple-mcp-runner/internal/logger" "github.com/mjmorales/simple-mcp-runner/pkg/types" ) // Executor manages command execution with safety features. type Executor struct { config *config.Config logger *logger.Logger activeCommands int32 semaphore chan struct{} } // New creates a new executor instance. func New(cfg *config.Config, log *logger.Logger) *Executor { maxConcurrent := cfg.Execution.MaxConcurrent if maxConcurrent <= 0 { maxConcurrent = 10 } return &Executor{ config: cfg, logger: log, semaphore: make(chan struct{}, maxConcurrent), } } // Execute runs a command with safety checks and resource limits. func (e *Executor) Execute(ctx context.Context, req *types.CommandExecutionRequest) (*types.CommandExecutionResult, error) { e.logger.WithFields(map[string]any{ "command": req.Command, "args": req.Args, "workdir": req.WorkDir, }).Debug("executing command") // Validate request if err := e.validateRequest(req); err != nil { return nil, err } // Check security constraints if err := e.checkSecurity(req); err != nil { return nil, err } // Acquire semaphore select { case e.semaphore <- struct{}{}: defer func() { <-e.semaphore }() case <-ctx.Done(): return nil, apperrors.TimeoutError("context cancelled while waiting for execution slot", "") } // Track active commands atomic.AddInt32(&e.activeCommands, 1) defer atomic.AddInt32(&e.activeCommands, -1) // Parse timeout timeout := e.getTimeout(req.Timeout) // Create context with timeout execCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() // Execute the command result := e.executeCommand(execCtx, req) // Log execution e.logExecution(req, result) return result, nil } // ExecuteConfigCommand executes a pre-configured command. func (e *Executor) ExecuteConfigCommand(ctx context.Context, cmd *config.Command, workDir string) (*types.CommandExecutionResult, error) { req := &types.CommandExecutionRequest{ Command: cmd.Command, Args: cmd.Args, WorkDir: workDir, Timeout: cmd.Timeout, } // Add environment variables if len(cmd.Env) > 0 { env := make([]string, 0, len(cmd.Env)) for k, v := range cmd.Env { env = append(env, fmt.Sprintf("%s=%s", k, v)) } req.Env = env } // Override workdir if specified in command config if cmd.WorkDir != "" { req.WorkDir = cmd.WorkDir } return e.Execute(ctx, req) } // GetActiveCount returns the number of active command executions. func (e *Executor) GetActiveCount() int { return int(atomic.LoadInt32(&e.activeCommands)) } // validateRequest validates the execution request. func (e *Executor) validateRequest(req *types.CommandExecutionRequest) error { if req.Command == "" { return apperrors.ValidationError("command is required", "command") } // Check command length if e.config.Security.MaxCommandLength > 0 { cmdLen := len(req.Command) + len(strings.Join(req.Args, " ")) if cmdLen > e.config.Security.MaxCommandLength { return apperrors.ValidationError( fmt.Sprintf("command too long: %d > %d", cmdLen, e.config.Security.MaxCommandLength), "command", ) } } // Validate workdir if specified if req.WorkDir != "" { if !filepath.IsAbs(req.WorkDir) { return apperrors.ValidationError("workdir must be an absolute path", "workdir") } info, err := os.Stat(req.WorkDir) if err != nil { return apperrors.NotFoundError(fmt.Sprintf("workdir not found: %v", err), req.WorkDir) } if !info.IsDir() { return apperrors.ValidationError("workdir is not a directory", "workdir") } } return nil } // checkSecurity performs security checks on the command. func (e *Executor) checkSecurity(req *types.CommandExecutionRequest) error { // Check if command is allowed if !e.config.IsCommandAllowed(req.Command) { return apperrors.PermissionError( fmt.Sprintf("command not allowed: %s", req.Command), req.Command, ) } // Check if path is allowed if req.WorkDir != "" && !e.config.IsPathAllowed(req.WorkDir) { return apperrors.PermissionError( fmt.Sprintf("path not allowed: %s", req.WorkDir), req.WorkDir, ) } // Check for shell injection attempts if shell expansion is disabled if e.config.Security.DisableShellExpansion { dangerous := []string{";", "&&", "||", "|", "`", "$", "(", ")", "{", "}", "<", ">", "&"} cmdStr := req.Command + " " + strings.Join(req.Args, " ") for _, char := range dangerous { if strings.Contains(cmdStr, char) { return apperrors.PermissionError( fmt.Sprintf("potentially dangerous character detected: %s", char), "command", ) } } } return nil } // getTimeout determines the timeout for command execution. func (e *Executor) getTimeout(requested string) time.Duration { // Parse requested timeout if requested != "" { if dur, err := time.ParseDuration(requested); err == nil { // Check against max timeout maxTimeout := e.parseTimeoutConfig(e.config.Execution.MaxTimeout, 5*time.Minute) if dur > maxTimeout { return maxTimeout } return dur } } // Use default timeout return e.parseTimeoutConfig(e.config.Execution.DefaultTimeout, 30*time.Second) } // parseTimeoutConfig parses a timeout configuration value. func (e *Executor) parseTimeoutConfig(value string, defaultValue time.Duration) time.Duration { if value == "" { return defaultValue } dur, err := time.ParseDuration(value) if err != nil { e.logger.WithFields(map[string]any{ "value": value, "error": err, "using_default": defaultValue, }).Warn("invalid timeout configuration") return defaultValue } return dur } // executeCommand performs the actual command execution. func (e *Executor) executeCommand(ctx context.Context, req *types.CommandExecutionRequest) *types.CommandExecutionResult { startTime := time.Now() result := &types.CommandExecutionResult{ StartTime: startTime, ExitCode: -1, } // Create command // #nosec G204 - This tool's purpose is to execute user-provided commands cmd := exec.CommandContext(ctx, req.Command, req.Args...) // Set working directory if req.WorkDir != "" { cmd.Dir = req.WorkDir } // Set environment if len(req.Env) > 0 { cmd.Env = append(os.Environ(), req.Env...) } // Create buffers for output with size limits stdout := &limitedBuffer{limit: e.config.Execution.MaxOutputSize} stderr := &limitedBuffer{limit: e.config.Execution.MaxOutputSize} cmd.Stdout = stdout cmd.Stderr = stderr // Start the command err := cmd.Start() if err != nil { result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) result.ErrorMessage = fmt.Sprintf("failed to start command: %v", err) return result } // Wait for completion done := make(chan error, 1) go func() { done <- cmd.Wait() }() // Wait for either completion or timeout select { case err := <-done: // Command completed result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) result.Stdout = stdout.String() result.Stderr = stderr.String() if err != nil { exitErr := &exec.ExitError{} if errors.As(err, &exitErr) { result.ExitCode = exitErr.ExitCode() } else { result.ErrorMessage = err.Error() } } else { result.ExitCode = 0 } case <-ctx.Done(): // Timeout or cancellation result.TimedOut = true result.EndTime = time.Now() result.Duration = result.EndTime.Sub(startTime) // Try graceful termination first if cmd.Process != nil { if err := cmd.Process.Signal(os.Interrupt); err != nil { // Process might have already exited, which is fine e.logger.Debug("failed to send interrupt signal", "error", err) } // Wait for kill timeout killTimeout := e.parseTimeoutConfig(e.config.Execution.KillTimeout, 5*time.Second) killTimer := time.NewTimer(killTimeout) select { case <-done: // Process terminated gracefully killTimer.Stop() case <-killTimer.C: // Force kill if err := cmd.Process.Kill(); err != nil { e.logger.Debug("failed to kill process", "error", err) } <-done } } result.Stdout = stdout.String() result.Stderr = stderr.String() result.ErrorMessage = "command timed out" } return result } // logExecution logs command execution details. func (e *Executor) logExecution(req *types.CommandExecutionRequest, result *types.CommandExecutionResult) { fields := map[string]any{ "command": req.Command, "args": req.Args, "workdir": req.WorkDir, "exit_code": result.ExitCode, "duration": result.Duration.Milliseconds(), "timed_out": result.TimedOut, } if result.ErrorMessage != "" { fields["error"] = result.ErrorMessage } // Log at appropriate level if result.ExitCode == 0 && !result.TimedOut { e.logger.WithFields(fields).Info("command executed successfully") } else { e.logger.WithFields(fields).Error("command execution failed") } // Log output at debug level if e.logger.IsDebugEnabled() { if result.Stdout != "" { e.logger.WithFields(map[string]any{ "command": req.Command, "output": truncateString(result.Stdout, 1000), }).Debug("command stdout") } if result.Stderr != "" { e.logger.WithFields(map[string]any{ "command": req.Command, "output": truncateString(result.Stderr, 1000), }).Debug("command stderr") } } } // limitedBuffer is a buffer that limits the amount of data stored. type limitedBuffer struct { buf bytes.Buffer limit int64 size int64 mu sync.Mutex } func (b *limitedBuffer) Write(p []byte) (n int, err error) { b.mu.Lock() defer b.mu.Unlock() if b.limit <= 0 { return b.buf.Write(p) } remaining := b.limit - b.size if remaining <= 0 { return len(p), nil // Discard extra data } if int64(len(p)) > remaining { p = p[:remaining] } n, err = b.buf.Write(p) b.size += int64(n) return n, err } func (b *limitedBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } // truncateString truncates a string to the specified length. func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "... (truncated)" } // CommandBuilder helps build command execution requests. type CommandBuilder struct { req *types.CommandExecutionRequest } // NewCommandBuilder creates a new command builder. func NewCommandBuilder(command string) *CommandBuilder { return &CommandBuilder{ req: &types.CommandExecutionRequest{ Command: command, }, } } // WithArgs sets the command arguments. func (b *CommandBuilder) WithArgs(args ...string) *CommandBuilder { b.req.Args = args return b } // WithWorkDir sets the working directory. func (b *CommandBuilder) WithWorkDir(dir string) *CommandBuilder { b.req.WorkDir = dir return b } // WithTimeout sets the timeout. func (b *CommandBuilder) WithTimeout(timeout string) *CommandBuilder { b.req.Timeout = timeout return b } // WithEnv adds environment variables. func (b *CommandBuilder) WithEnv(env map[string]string) *CommandBuilder { envList := make([]string, 0, len(env)) for k, v := range env { envList = append(envList, fmt.Sprintf("%s=%s", k, v)) } b.req.Env = envList return b } // Build returns the command execution request. func (b *CommandBuilder) Build() *types.CommandExecutionRequest { return b.req }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mjmorales/simple-mcp-runner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server