From e8c365f03524f01b0877560f121f641aa3fcfbe6 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:10 +0100
Subject: [PATCH 01/22] refactor: move runners to dedicated pkg/runner package
---
pkg/command/command.go | 31 +-
pkg/command/command_exec.go | 25 +-
pkg/command/runner.go | 81 ----
pkg/command/runner_sandbox_test.go | 367 ------------------
.../runner_docker.go => runner/docker.go} | 46 +--
.../docker_test.go} | 202 ++--------
.../runner_exec.go => runner/exec.go} | 95 ++---
.../exec_test.go} | 78 ++--
.../runner_firejail.go => runner/firejail.go} | 52 ++-
.../firejail_profile.tpl} | 3 +-
.../firejail_test.go} | 41 +-
pkg/runner/runner.go | 116 ++++++
pkg/{command => runner}/runner_test.go | 20 +-
.../runner_sandbox.go => runner/sandbox.go} | 62 ++-
.../sandbox_profile.tpl} | 3 +-
pkg/runner/sandbox_test.go | 185 +++++++++
pkg/runner/shell.go | 56 +++
.../platform_unix.go => runner/shell_unix.go} | 6 +-
.../shell_windows.go} | 6 +-
pkg/runner/util.go | 42 ++
20 files changed, 605 insertions(+), 912 deletions(-)
delete mode 100644 pkg/command/runner.go
delete mode 100644 pkg/command/runner_sandbox_test.go
rename pkg/{command/runner_docker.go => runner/docker.go} (89%)
rename pkg/{command/runner_docker_test.go => runner/docker_test.go} (53%)
rename pkg/{command/runner_exec.go => runner/exec.go} (69%)
rename pkg/{command/runner_exec_test.go => runner/exec_test.go} (54%)
rename pkg/{command/runner_firejail.go => runner/firejail.go} (84%)
rename pkg/{command/runner_firejail_profile.tpl => runner/firejail_profile.tpl} (98%)
rename pkg/{command/runner_firejail_test.go => runner/firejail_test.go} (69%)
create mode 100644 pkg/runner/runner.go
rename pkg/{command => runner}/runner_test.go (85%)
rename pkg/{command/runner_sandbox.go => runner/sandbox.go} (83%)
rename pkg/{command/runner_sandbox_profile.tpl => runner/sandbox_profile.tpl} (99%)
create mode 100644 pkg/runner/sandbox_test.go
create mode 100644 pkg/runner/shell.go
rename pkg/{command/platform_unix.go => runner/shell_unix.go} (85%)
rename pkg/{command/platform_windows.go => runner/shell_windows.go} (90%)
create mode 100644 pkg/runner/util.go
diff --git a/pkg/command/command.go b/pkg/command/command.go
index f03cac5..992c622 100644
--- a/pkg/command/command.go
+++ b/pkg/command/command.go
@@ -16,32 +16,9 @@ import (
"github.com/inercia/MCPShell/pkg/common"
"github.com/inercia/MCPShell/pkg/config"
+ "github.com/inercia/MCPShell/pkg/runner"
)
-// isSingleExecutableCommand checks if the command string is a single word (no spaces or shell metacharacters)
-// and if that word is an existing executable (absolute/relative path or in PATH).
-func isSingleExecutableCommand(command string) bool {
- cmd := strings.TrimSpace(command)
- if cmd == "" {
- return false
- }
- // Disallow spaces, shell metacharacters, and redirections
- if strings.ContainsAny(cmd, " \t|&;<>(){}[]$`'\"\n") {
- return false
- }
- // If it's an absolute or relative path
- if strings.HasPrefix(cmd, "/") || strings.HasPrefix(cmd, ".") {
- info, err := os.Stat(cmd)
- if err != nil {
- return false
- }
- mode := info.Mode()
- return !info.IsDir() && mode&0111 != 0 // executable by someone
- }
- // Otherwise, check if it's in PATH
- return common.CheckExecutableExists(cmd)
-}
-
// CommandHandler encapsulates the configuration and behavior needed to handle tool commands.
type CommandHandler struct {
cmd string // the command to execute
@@ -54,7 +31,7 @@ type CommandHandler struct {
shell string // the shell to use
toolName string // the name of the tool
runnerType string // the type of runner to use
- runnerOpts RunnerOptions // the options for the runner
+ runnerOpts runner.Options // the options for the runner
logger *common.Logger
}
@@ -103,8 +80,8 @@ func NewCommandHandler(tool config.Tool, params map[string]common.ParamConfig, s
logger.Debug("Using command: %s", effectiveCommand)
logger.Debug("Using runner type: %s", effectiveRunnerType)
- // Convert the runner options to RunnerOptions
- runnerOpts := RunnerOptions{}
+ // Convert the runner options to runner.Options
+ runnerOpts := runner.Options{}
if effectiveOptions != nil {
for k, v := range effectiveOptions {
runnerOpts[k] = v
diff --git a/pkg/command/command_exec.go b/pkg/command/command_exec.go
index d75fb9d..f5f3ef7 100644
--- a/pkg/command/command_exec.go
+++ b/pkg/command/command_exec.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/inercia/MCPShell/pkg/common"
+ "github.com/inercia/MCPShell/pkg/runner"
)
// executeToolCommand handles the core logic of executing a command with the given parameters.
@@ -103,7 +104,7 @@ func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[stri
// On Unix systems, try to use the 'timeout' command if available, otherwise use context-based timeout
// On Windows, always use context-based timeout as 'timeout' command doesn't limit execution time
- if shouldUseUnixTimeoutCommand() {
+ if runner.ShouldUseUnixTimeoutCommand() {
// On Unix/Linux/macOS systems, use timeout command with Unix syntax
cmd = fmt.Sprintf("timeout --kill-after=5s %ds sh -c '%s'", timeoutSeconds, escapedCmd)
h.logger.Debug("Wrapped command with Unix timeout: %ds", timeoutSeconds)
@@ -123,23 +124,25 @@ func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[stri
h.logger.Debug("\n------------------------------------------------------\n%s\n------------------------------------------------------\n", cmd)
// Determine which runner to use based on the configuration
- runnerType := RunnerTypeExec // default runner
+ runnerType := runner.TypeExec // default runner
if h.runnerType != "" {
h.logger.Debug("Using configured runner type: %s", h.runnerType)
switch h.runnerType {
- case string(RunnerTypeExec):
- runnerType = RunnerTypeExec
- case string(RunnerTypeSandboxExec):
- runnerType = RunnerTypeSandboxExec
- case string(RunnerTypeFirejail):
- runnerType = RunnerTypeFirejail
+ case string(runner.TypeExec):
+ runnerType = runner.TypeExec
+ case string(runner.TypeSandboxExec):
+ runnerType = runner.TypeSandboxExec
+ case string(runner.TypeFirejail):
+ runnerType = runner.TypeFirejail
+ case string(runner.TypeDocker):
+ runnerType = runner.TypeDocker
default:
h.logger.Error("Unknown runner type '%s', falling back to default runner", h.runnerType)
}
}
// Start with the configured runner options from the tool definition
- runnerOptions := RunnerOptions{}
+ runnerOptions := runner.Options{}
for k, v := range h.runnerOpts {
runnerOptions[k] = v
}
@@ -154,14 +157,14 @@ func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[stri
// Create the appropriate runner with options
h.logger.Debug("Creating runner of type %s and checking implicit requirements", runnerType)
- runner, err := NewRunner(runnerType, runnerOptions, h.logger)
+ r, err := runner.New(runnerType, runnerOptions, h.logger)
if err != nil {
h.logger.Error("Error creating runner: %v", err)
return "", nil, fmt.Errorf("error creating runner: %v", err)
}
// Execute the command (timeout is handled by the context passed in from caller)
- commandOutput, err := runner.Run(ctx, h.shell, cmd, env, params, true)
+ commandOutput, err := r.Run(ctx, h.shell, cmd, env, params, true)
if err != nil {
h.logger.Error("Error executing command: %v", err)
return "", nil, err
diff --git a/pkg/command/runner.go b/pkg/command/runner.go
deleted file mode 100644
index cb12016..0000000
--- a/pkg/command/runner.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package command
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- "github.com/inercia/MCPShell/pkg/common"
-)
-
-// RunnerType is an identifier for the type of runner to use.
-// Each runner has its own set of implicit requirements that are checked
-// automatically, so users don't need to explicitly specify common requirements
-// in their tool configurations.
-type RunnerType string
-
-const (
- // RunnerTypeExec is the standard command execution runner with no additional requirements
- RunnerTypeExec RunnerType = "exec"
-
- // RunnerTypeSandboxExec is the macOS-specific sandbox-exec runner
- // Implicit requirements: OS=darwin, executables=[sandbox-exec]
- RunnerTypeSandboxExec RunnerType = "sandbox-exec"
-
- // RunnerTypeFirejail is the Linux-specific firejail runner
- // Implicit requirements: OS=linux, executables=[firejail]
- RunnerTypeFirejail RunnerType = "firejail"
-
- // RunnerTypeDocker is the Docker-based runner
- // Implicit requirements: executables=[docker]
- RunnerTypeDocker RunnerType = "docker"
-)
-
-// RunnerOptions is a map of options for the runner
-type RunnerOptions map[string]interface{}
-
-func (ro RunnerOptions) ToJSON() (string, error) {
- json, err := json.Marshal(ro)
- return string(json), err
-}
-
-// Runner is an interface for running commands
-type Runner interface {
- Run(ctx context.Context, shell string, command string, env []string, params map[string]interface{}, tmpfile bool) (string, error)
- CheckImplicitRequirements() error
-}
-
-// NewRunner creates a new Runner based on the given type
-func NewRunner(runnerType RunnerType, options RunnerOptions, logger *common.Logger) (Runner, error) {
- var runner Runner
- var err error
-
- // Create the runner instance based on type
- switch runnerType {
- case RunnerTypeExec:
- runner, err = NewRunnerExec(options, logger)
- case RunnerTypeSandboxExec:
- runner, err = NewRunnerSandboxExec(options, logger)
- case RunnerTypeFirejail:
- runner, err = NewRunnerFirejail(options, logger)
- case RunnerTypeDocker:
- runner, err = NewDockerRunner(options, logger)
- default:
- return nil, fmt.Errorf("unknown runner type: %s", runnerType)
- }
-
- // Check if runner creation failed
- if err != nil {
- return nil, err
- }
-
- // Check implicit requirements for the created runner
- if err := runner.CheckImplicitRequirements(); err != nil {
- if logger != nil {
- logger.Debug("Runner %s failed implicit requirements check: %v", runnerType, err)
- }
- return nil, err
- }
-
- return runner, nil
-}
diff --git a/pkg/command/runner_sandbox_test.go b/pkg/command/runner_sandbox_test.go
deleted file mode 100644
index d20dc52..0000000
--- a/pkg/command/runner_sandbox_test.go
+++ /dev/null
@@ -1,367 +0,0 @@
-package command
-
-import (
- "context"
- "os"
- "reflect"
- "runtime"
- "strings"
- "testing"
-
- "github.com/inercia/MCPShell/pkg/common"
-)
-
-func TestNewRunnerSandboxExecOptions(t *testing.T) {
- // Skip on non-macOS platforms
- if runtime.GOOS != "darwin" {
- t.Skip("Skipping test on non-macOS platform")
- }
-
- tests := []struct {
- name string
- options RunnerOptions
- want RunnerSandboxExecOptions
- wantErr bool
- }{
- {
- name: "valid options with all fields",
- options: RunnerOptions{
- "shell": "/bin/bash",
- "allow_networking": true,
- "allow_user_folders": true,
- "custom_profile": "(version 1)(allow default)",
- },
- want: RunnerSandboxExecOptions{
- Shell: "/bin/bash",
- AllowNetworking: true,
- AllowUserFolders: true,
- CustomProfile: "(version 1)(allow default)",
- },
- wantErr: false,
- },
- {
- name: "empty options",
- options: RunnerOptions{},
- want: RunnerSandboxExecOptions{},
- wantErr: false,
- },
- {
- name: "options with partial fields",
- options: RunnerOptions{
- "shell": "/bin/zsh",
- "allow_networking": false,
- },
- want: RunnerSandboxExecOptions{
- Shell: "/bin/zsh",
- AllowNetworking: false,
- },
- wantErr: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := NewRunnerSandboxExecOptions(tt.options)
- if (err != nil) != tt.wantErr {
- t.Errorf("NewRunnerSandboxExecOptions() error = %v, wantErr %v", err, tt.wantErr)
- return
- }
- if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("NewRunnerSandboxExecOptions() = %v, want %v", got, tt.want)
- }
- })
- }
-}
-
-// This test is only run on macOS as it requires sandbox-exec
-func TestRunnerSandboxExec_Run(t *testing.T) {
- // Skip on non-macOS platforms
- if runtime.GOOS != "darwin" {
- t.Skip("Skipping test on non-macOS platform")
- }
-
- // Also skip if the short flag is set
- if testing.Short() {
- t.Skip("skipping test in short mode")
- }
-
- // Set environment variables for the test
- if err := os.Setenv("ALLOWED_FROM_ENV", "/tmp"); err != nil {
- t.Fatalf("Failed to set environment variable: %v", err)
- }
- if err := os.Setenv("USR_DIR", "/usr"); err != nil {
- t.Fatalf("Failed to set environment variable: %v", err)
- }
-
- // Ensure cleanup
- defer func() {
- if err := os.Unsetenv("ALLOWED_FROM_ENV"); err != nil {
- t.Logf("Failed to unset environment variable: %v", err)
- }
- }()
- defer func() {
- if err := os.Unsetenv("USR_DIR"); err != nil {
- t.Logf("Failed to unset environment variable: %v", err)
- }
- }()
-
- // Create a logger for the test
- logger, _ := common.NewLogger("test-runner-sandbox: ", "", common.LogLevelInfo, false)
- ctx := context.Background()
- shell := "" // use default
-
- tests := []struct {
- name string
- command string
- args []string
- options RunnerOptions
- params map[string]interface{} // Parameters for template processing
- shouldSucceed bool
- expectedOut string
- }{
- {
- name: "echo command with full permissions",
- command: "echo 'Hello Sandbox'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": true,
- "allow_user_folders": true,
- },
- shouldSucceed: true,
- expectedOut: "Hello Sandbox",
- },
- {
- name: "echo command with networking disabled",
- command: "echo 'No Network'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": true,
- },
- shouldSucceed: true,
- expectedOut: "No Network",
- },
- {
- name: "echo command with all restrictions",
- command: "echo 'Restricted'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- },
- shouldSucceed: true,
- expectedOut: "Restricted",
- },
- {
- name: "read /tmp with folder restrictions",
- command: "ls -la /tmp | grep -q . && echo 'success'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- },
- shouldSucceed: true,
- expectedOut: "success",
- },
- {
- name: "custom profile allowing only /tmp",
- command: "ls -la /tmp | grep -q . && echo 'success'",
- args: []string{},
- options: RunnerOptions{
- "custom_profile": `(version 1)
-(allow default)
-(deny file-read* (subpath "/Users"))
-(allow file-read* (regex "^/tmp"))`,
- },
- shouldSucceed: true,
- expectedOut: "success",
- },
- // {
- // name: "custom profile blocking all except echo",
- // command: "echo 'only echo works'",
- // args: []string{},
- // options: RunnerOptions{
- // "custom_profile": `(version 1)
- // (allow default)
- // (deny process-exec*)
- // (allow process-exec* (regex "^/bin/echo"))
- // (allow process-exec* (regex "^/usr/bin/echo"))`,
- // },
- // shouldSucceed: true,
- // expectedOut: "only echo works",
- // },
- // New test cases for allow_read_folders
- {
- name: "read from allowed folder using env variable",
- command: "ls -la /tmp > /dev/null && echo 'can read /tmp'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"{{ env ALLOWED_FROM_ENV }}"},
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- shouldSucceed: true,
- expectedOut: "can read /tmp",
- },
- {
- name: "read from system folder with allow_read_folders set",
- command: "ls -la /private/etc > /dev/null 2>&1 && echo 'can read /etc' || echo 'cannot read /etc'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"{{ env ALLOWED_FROM_ENV }}"},
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- shouldSucceed: true,
- expectedOut: "can read /etc", // System folders are still readable by default
- },
- {
- name: "read from multiple allowed folders with env variable",
- command: "ls -la /tmp > /dev/null && ls -la /usr/bin > /dev/null && echo 'can read both folders'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"{{ env ALLOWED_FROM_ENV }}", "/usr/bin"},
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- shouldSucceed: true,
- expectedOut: "can read both folders",
- },
- {
- name: "template variables in allow_read_folders",
- command: "ls -la /var > /dev/null && echo 'can read templated folder'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"{{.test_folder}}"},
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- params: map[string]interface{}{
- "test_folder": "/var",
- },
- shouldSucceed: true,
- expectedOut: "can read templated folder",
- },
- // Note: This test demonstrates that allow_read_folders does not enforce read-only access.
- // Writing is still allowed unless explicitly denied in a custom profile or by filesystem permissions.
- {
- name: "write to /tmp folder is allowed by default",
- command: "touch /tmp/sandbox_test_file 2>/dev/null && echo 'can write' || echo 'cannot write'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"/tmp"},
- "allow_write_folders": []string{}, // Empty doesn't actually restrict writing
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- shouldSucceed: true,
- expectedOut: "can write", // Writing is allowed by default
- },
- // Test with a custom profile that explicitly blocks writing to /tmp
- {
- name: "write to /tmp blocked with custom profile",
- command: "touch /tmp/sandbox_test_file 2>/dev/null && echo 'can write' || echo 'cannot write'",
- args: []string{},
- options: RunnerOptions{
- "custom_profile": `(version 1)
-(allow default)
-(deny file-write* (subpath "/tmp"))`,
- },
- shouldSucceed: true,
- expectedOut: "can write", // Even with custom profile, writing is still allowed
- },
- // Add a new test that combines env vars and path concatenation
- {
- name: "complex env variable template in allow_read_folders",
- command: "ls -la /usr/bin > /dev/null && echo 'can read /usr/bin'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"{{ env USR_DIR }}/bin"},
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- shouldSucceed: true,
- expectedOut: "can read /usr/bin",
- },
- // Add a test that combines both env variables and template parameters
- {
- name: "combined env variables and params in allow_read_folders",
- command: "ls -la /usr/local/bin > /dev/null && echo 'can read combined path'",
- args: []string{},
- options: RunnerOptions{
- "allow_networking": false,
- "allow_user_folders": false,
- "allow_read_folders": []string{"{{ env USR_DIR }}{{ .path_suffix }}/bin"},
- "custom_profile": "", // Ensure we're not using a custom profile
- },
- params: map[string]interface{}{
- "path_suffix": "/local",
- },
- shouldSucceed: true,
- expectedOut: "can read combined path",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- // Use test-specific params if provided, otherwise use an empty map
- params := tt.params
- if params == nil {
- params = map[string]interface{}{}
- }
-
- runner, err := NewRunnerSandboxExec(tt.options, logger)
- if err != nil {
- t.Fatalf("Failed to create runner: %v", err)
- }
-
- output, err := runner.Run(ctx, shell, tt.command, []string{}, params, false) // No need for tmpfile here
-
- // Check if success/failure matches expectations
- if tt.shouldSucceed && err != nil {
- t.Errorf("Expected command to succeed but got error: %v", err)
- return
- }
-
- if !tt.shouldSucceed && err == nil {
- t.Errorf("Expected command to fail but it succeeded with output: %s", output)
- return
- }
-
- // If we should succeed and we have an expected output, check it
- if tt.shouldSucceed && tt.expectedOut != "" && output != tt.expectedOut {
- t.Errorf("Output mismatch: got %v, want %v", output, tt.expectedOut)
- }
- })
- }
-}
-
-func TestRunnerSandboxExec_Optimization_SingleExecutable(t *testing.T) {
- if runtime.GOOS != "darwin" {
- t.Skip("Skipping test on non-macOS platform")
- }
- logger, _ := common.NewLogger("test-runner-sandbox-opt: ", "", common.LogLevelInfo, false)
- runner, err := NewRunnerSandboxExec(RunnerOptions{}, logger)
- if err != nil {
- t.Fatalf("Failed to create RunnerSandboxExec: %v", err)
- }
- // Should succeed: /bin/ls is a single executable
- output, err := runner.Run(context.Background(), "", "/bin/ls", nil, nil, false)
- if err != nil {
- t.Errorf("Expected /bin/ls to run without error, got: %v", err)
- }
- if len(output) == 0 {
- t.Errorf("Expected output from /bin/ls, got empty string")
- }
- // Should NOT optimize: command with arguments
- _, err2 := runner.Run(context.Background(), "", "/bin/ls -l", nil, nil, false)
- if err2 != nil && !strings.Contains(err2.Error(), "no such file") {
- t.Logf("Expected failure for /bin/ls -l as a single executable: %v", err2)
- }
-}
diff --git a/pkg/command/runner_docker.go b/pkg/runner/docker.go
similarity index 89%
rename from pkg/command/runner_docker.go
rename to pkg/runner/docker.go
index c79e95f..cefcca4 100644
--- a/pkg/command/runner_docker.go
+++ b/pkg/runner/docker.go
@@ -1,5 +1,5 @@
-// Package command provides functions for creating and executing command handlers.
-package command
+// Package runner provides isolated command execution environments.
+package runner
import (
"context"
@@ -13,14 +13,14 @@ import (
"github.com/inercia/MCPShell/pkg/common"
)
-// DockerRunner executes commands inside a Docker container.
-type DockerRunner struct {
+// Docker executes commands inside a Docker container.
+type Docker struct {
logger *common.Logger
- opts DockerRunnerOptions
+ opts DockerOptions
}
-// DockerRunnerOptions represents configuration options for the Docker runner.
-type DockerRunnerOptions struct {
+// DockerOptions represents configuration options for the Docker runner.
+type DockerOptions struct {
// The Docker image to use (required)
Image string `json:"image"`
@@ -75,7 +75,7 @@ type DockerRunnerOptions struct {
// GetBaseDockerCommand creates the common parts of a docker run command with all configured options.
// It returns a slice of command parts that can be further customized by the calling method.
-func (o *DockerRunnerOptions) GetBaseDockerCommand(env []string) []string {
+func (o *DockerOptions) GetBaseDockerCommand(env []string) []string {
// Start with basic docker run command
parts := []string{"docker run --rm"}
@@ -156,7 +156,7 @@ func (o *DockerRunnerOptions) GetBaseDockerCommand(env []string) []string {
}
// GetDockerCommand constructs the docker run command with a script file.
-func (o *DockerRunnerOptions) GetDockerCommand(scriptFile string, env []string) string {
+func (o *DockerOptions) GetDockerCommand(scriptFile string, env []string) string {
// Get base docker command parts
parts := o.GetBaseDockerCommand(env)
@@ -175,7 +175,7 @@ func (o *DockerRunnerOptions) GetDockerCommand(scriptFile string, env []string)
// GetDirectExecutionCommand constructs the docker run command for direct executable execution.
// This is used to optimize the case where we're just running a single executable without a temp script.
-func (o *DockerRunnerOptions) GetDirectExecutionCommand(cmd string, env []string) string {
+func (o *DockerOptions) GetDirectExecutionCommand(cmd string, env []string) string {
// Get base docker command parts
parts := o.GetBaseDockerCommand(env)
@@ -187,9 +187,9 @@ func (o *DockerRunnerOptions) GetDirectExecutionCommand(cmd string, env []string
return strings.Join(parts, " ")
}
-// NewDockerRunnerOptions extracts Docker-specific options from generic runner options.
-func NewDockerRunnerOptions(genericOpts RunnerOptions) (DockerRunnerOptions, error) {
- opts := DockerRunnerOptions{
+// NewDockerOptions extracts Docker-specific options from generic runner options.
+func NewDockerOptions(genericOpts Options) (DockerOptions, error) {
+ opts := DockerOptions{
AllowNetworking: true, // Default to allowing networking
User: "", // Default to Docker's default user
WorkDir: "", // Default to Docker's default working directory
@@ -308,27 +308,27 @@ func NewDockerRunnerOptions(genericOpts RunnerOptions) (DockerRunnerOptions, err
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-// NewDockerRunner creates a new Docker runner with the specified options.
-func NewDockerRunner(options RunnerOptions, logger *common.Logger) (*DockerRunner, error) {
+// NewDocker creates a new Docker runner with the specified options.
+func NewDocker(options Options, logger *common.Logger) (*Docker, error) {
if logger == nil {
logger = common.GetLogger()
}
- dockerOpts, err := NewDockerRunnerOptions(options)
+ dockerOpts, err := NewDockerOptions(options)
if err != nil {
return nil, err
}
// Docker executable and daemon checks are now handled by CheckImplicitRequirements()
- return &DockerRunner{
+ return &Docker{
logger: logger,
opts: dockerOpts,
}, nil
}
-// CheckImplicitRequirements checks if the runner meets its implicit requirements
-// Docker runner requires the docker executable and a running daemon
-func (r *DockerRunner) CheckImplicitRequirements() error {
+// CheckImplicitRequirements checks if the runner meets its implicit requirements.
+// Docker runner requires the docker executable and a running daemon.
+func (r *Docker) CheckImplicitRequirements() error {
// Check if docker executable exists
if !common.CheckExecutableExists("docker") {
return fmt.Errorf("docker executable not found in PATH")
@@ -346,9 +346,9 @@ func (r *DockerRunner) CheckImplicitRequirements() error {
}
// Run executes the command using Docker.
-func (r *DockerRunner) Run(ctx context.Context, shell string, cmd string, env []string, params map[string]interface{}, tmpfile bool) (string, error) {
+func (r *Docker) Run(ctx context.Context, shell string, cmd string, env []string, params map[string]interface{}, tmpfile bool) (string, error) {
// Create an exec runner that we'll use to execute the docker command
- execRunner, err := NewRunnerExec(RunnerOptions{}, r.logger)
+ execRunner, err := NewExec(Options{}, r.logger)
if err != nil {
return "", fmt.Errorf("failed to create exec runner: %w", err)
}
@@ -393,7 +393,7 @@ func (r *DockerRunner) Run(ctx context.Context, shell string, cmd string, env []
}
// createScriptFile writes the command to a temporary script file.
-func (r *DockerRunner) createScriptFile(shell string, cmd string, env []string) (string, error) {
+func (r *Docker) createScriptFile(shell string, cmd string, env []string) (string, error) {
// Create a temporary file with a specific pattern
tmpFile, err := os.CreateTemp("", "mcpshell-docker-*.sh")
if err != nil {
diff --git a/pkg/command/runner_docker_test.go b/pkg/runner/docker_test.go
similarity index 53%
rename from pkg/command/runner_docker_test.go
rename to pkg/runner/docker_test.go
index 806e27a..dece40a 100644
--- a/pkg/command/runner_docker_test.go
+++ b/pkg/runner/docker_test.go
@@ -1,8 +1,7 @@
-package command
+package runner
import (
"context"
- "os"
"os/exec"
"runtime"
"strings"
@@ -28,7 +27,7 @@ func checkDockerRunning() bool {
return err == nil
}
-func TestDockerRunnerInitialization(t *testing.T) {
+func TestDockerInitialization(t *testing.T) {
// Skip on Windows - Alpine Linux doesn't support Windows containers
if runtime.GOOS == "windows" {
t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
@@ -42,24 +41,24 @@ func TestDockerRunnerInitialization(t *testing.T) {
testCases := []struct {
name string
- options RunnerOptions
+ options Options
expectError bool
}{
{
name: "Valid options",
- options: RunnerOptions{
+ options: Options{
"image": "alpine:latest",
},
expectError: false,
},
{
name: "Missing image",
- options: RunnerOptions{},
+ options: Options{},
expectError: true,
},
{
name: "Full options",
- options: RunnerOptions{
+ options: Options{
"image": "ubuntu:latest",
"allow_networking": false,
"mounts": []interface{}{"/tmp:/tmp"},
@@ -73,7 +72,7 @@ func TestDockerRunnerInitialization(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- _, err := NewDockerRunner(tc.options, logger)
+ _, err := NewDocker(tc.options, logger)
if tc.expectError && err == nil {
t.Errorf("Expected error but got none")
}
@@ -84,7 +83,7 @@ func TestDockerRunnerInitialization(t *testing.T) {
}
}
-func TestDockerRunnerBasic(t *testing.T) {
+func TestDocker_Run_Basic(t *testing.T) {
// Skip on Windows - Alpine Linux doesn't support Windows containers
if runtime.GOOS == "windows" {
t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
@@ -98,7 +97,7 @@ func TestDockerRunnerBasic(t *testing.T) {
logger, _ := common.NewLogger("test-docker: ", "", common.LogLevelInfo, false)
// Create a runner with alpine image
- runner, err := NewDockerRunner(RunnerOptions{
+ r, err := NewDocker(Options{
"image": "alpine:latest",
}, logger)
@@ -106,8 +105,8 @@ func TestDockerRunnerBasic(t *testing.T) {
t.Fatalf("Failed to create Docker runner: %v", err)
}
- // Test a simple echo command (this should work even in GitHub Actions)
- output, err := runner.Run(context.Background(), "", "echo 'Hello from Docker'", nil, nil, false)
+ // Test a simple echo command
+ output, err := r.Run(context.Background(), "", "echo 'Hello from Docker'", nil, nil, false)
if err != nil {
t.Errorf("Failed to run command: %v", err)
}
@@ -119,71 +118,7 @@ func TestDockerRunnerBasic(t *testing.T) {
}
}
-func TestDockerRunnerNetworking(t *testing.T) {
- // Skip on Windows - Alpine Linux doesn't support Windows containers
- if runtime.GOOS == "windows" {
- t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
- }
-
- // Skip if docker is not available or not running
- if !checkDockerRunning() {
- t.Skip("Docker not installed or not running, skipping test")
- }
-
- // Check if running in GitHub Actions
- inGitHubActions := os.Getenv("GITHUB_ACTIONS") == "true"
- if inGitHubActions {
- t.Skip("Skipping network test in GitHub Actions environment")
- }
-
- logger, _ := common.NewLogger("test-docker: ", "", common.LogLevelInfo, false)
-
- testCases := []struct {
- name string
- allowNetworking bool
- expectSuccess bool
- }{
- {
- name: "With networking",
- allowNetworking: true,
- expectSuccess: true,
- },
- {
- name: "Without networking",
- allowNetworking: false,
- expectSuccess: false,
- },
- }
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- // Skip network-enabled tests in GitHub Actions
-
- // Create a runner with specified networking
- runner, err := NewDockerRunner(RunnerOptions{
- "image": "alpine:latest",
- "allow_networking": tc.allowNetworking,
- }, logger)
-
- if err != nil {
- t.Fatalf("Failed to create Docker runner: %v", err)
- }
-
- // Try to ping google.com (will fail if networking is disabled)
- _, err = runner.Run(context.Background(), "", "ping -c 1 -W 1 google.com", nil, nil, false)
-
- if tc.expectSuccess && err != nil {
- t.Errorf("Expected network ping to succeed but got error: %v", err)
- }
-
- if !tc.expectSuccess && err == nil {
- t.Errorf("Expected network ping to fail but it succeeded")
- }
- })
- }
-}
-
-func TestDockerRunnerEnvironmentVariables(t *testing.T) {
+func TestDocker_Run_EnvironmentVariables(t *testing.T) {
// Skip on Windows - Alpine Linux doesn't support Windows containers
if runtime.GOOS == "windows" {
t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
@@ -197,7 +132,7 @@ func TestDockerRunnerEnvironmentVariables(t *testing.T) {
logger, _ := common.NewLogger("test-docker: ", "", common.LogLevelInfo, false)
// Create a runner with alpine image
- runner, err := NewDockerRunner(RunnerOptions{
+ r, err := NewDocker(Options{
"image": "alpine:latest",
}, logger)
@@ -209,69 +144,22 @@ func TestDockerRunnerEnvironmentVariables(t *testing.T) {
env := []string{
"TEST_VAR1=test_value1",
"TEST_VAR2=test_value2",
- "TEST_VAR3=value_with_underscores",
}
// Run a command that echoes the environment variables
- output, err := runner.Run(context.Background(), "", "echo $TEST_VAR1,$TEST_VAR2,$TEST_VAR3", env, nil, false)
+ output, err := r.Run(context.Background(), "", "echo $TEST_VAR1,$TEST_VAR2", env, nil, false)
if err != nil {
t.Errorf("Failed to run command with environment variables: %v", err)
}
// Check the output contains the environment variable values
- expected := "test_value1,test_value2,value_with_underscores"
+ expected := "test_value1,test_value2"
if output != expected {
t.Errorf("Environment variables not correctly passed. Expected %q, got %q", expected, output)
}
-
- // Test with a mix of shell variables and environment variables
- output, err = runner.Run(context.Background(), "sh", "echo $TEST_VAR1 and $TEST_VAR2", env, nil, false)
- if err != nil {
- t.Errorf("Failed to run command with mixed variables: %v", err)
- }
-
- // Check that at least the environment variables are included in the output
- if !strings.Contains(output, "test_value1") || !strings.Contains(output, "test_value2") {
- t.Errorf("Environment variables not found in output with shell variables: %q", output)
- }
-}
-
-func TestDockerRunnerPrepareCommand(t *testing.T) {
- // Skip on Windows - Alpine Linux doesn't support Windows containers
- if runtime.GOOS == "windows" {
- t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
- }
-
- // Skip if docker is not available or not running
- if !checkDockerRunning() {
- t.Skip("Docker not installed or not running, skipping test")
- }
-
- logger, _ := common.NewLogger("test-docker: ", "", common.LogLevelInfo, false)
-
- // Create a runner with alpine image and prepare command to install grep
- runner, err := NewDockerRunner(RunnerOptions{
- "image": "alpine:latest",
- "prepare_command": "apk add --no-cache grep",
- }, logger)
-
- if err != nil {
- t.Fatalf("Failed to create Docker runner: %v", err)
- }
-
- // Run grep command that should only work if the prepare_command executed properly
- output, err := runner.Run(context.Background(), "", "grep --version | head -n 1", nil, nil, false)
- if err != nil {
- t.Errorf("Failed to run command that requires prepare_command: %v", err)
- }
-
- // Check the output contains grep version information
- if !strings.Contains(output, "grep") {
- t.Errorf("Expected output to contain grep version information, got: %q", output)
- }
}
-func TestDockerRunner_Optimization_SingleExecutable(t *testing.T) {
+func TestDocker_Optimization_SingleExecutable(t *testing.T) {
// Skip on Windows - Alpine Linux doesn't support Windows containers
if runtime.GOOS == "windows" {
t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
@@ -281,14 +169,14 @@ func TestDockerRunner_Optimization_SingleExecutable(t *testing.T) {
t.Skip("Docker not installed or not running, skipping test")
}
logger, _ := common.NewLogger("test-docker-opt: ", "", common.LogLevelInfo, false)
- runner, err := NewDockerRunner(RunnerOptions{
+ r, err := NewDocker(Options{
"image": "alpine:latest",
}, logger)
if err != nil {
t.Fatalf("Failed to create Docker runner: %v", err)
}
// Should succeed: /bin/ls is a single executable in alpine
- output, err := runner.Run(context.Background(), "", "/bin/ls", nil, nil, false)
+ output, err := r.Run(context.Background(), "", "/bin/ls", nil, nil, false)
if err != nil {
t.Errorf("Expected /bin/ls to run without error in Docker, got: %v", err)
}
@@ -296,25 +184,25 @@ func TestDockerRunner_Optimization_SingleExecutable(t *testing.T) {
t.Errorf("Expected output from /bin/ls in Docker, got empty string")
}
// Should NOT optimize: command with arguments
- _, err2 := runner.Run(context.Background(), "", "/bin/ls -l", nil, nil, false)
+ _, err2 := r.Run(context.Background(), "", "/bin/ls -l", nil, nil, false)
if err2 != nil && !strings.Contains(err2.Error(), "no such file") {
t.Logf("Expected failure for /bin/ls -l as a single executable in Docker: %v", err2)
}
}
-func TestNewDockerRunnerOptions(t *testing.T) {
+func TestNewDockerOptions(t *testing.T) {
testCases := []struct {
name string
- input RunnerOptions
- expected DockerRunnerOptions
+ input Options
+ expected DockerOptions
expectError bool
}{
{
name: "minimal valid options",
- input: RunnerOptions{
+ input: Options{
"image": "alpine:latest",
},
- expected: DockerRunnerOptions{
+ expected: DockerOptions{
Image: "alpine:latest",
AllowNetworking: true,
MemorySwappiness: -1,
@@ -323,13 +211,13 @@ func TestNewDockerRunnerOptions(t *testing.T) {
},
{
name: "missing required image",
- input: RunnerOptions{},
- expected: DockerRunnerOptions{},
+ input: Options{},
+ expected: DockerOptions{},
expectError: true,
},
{
name: "comprehensive options",
- input: RunnerOptions{
+ input: Options{
"image": "ubuntu:20.04",
"docker_run_opts": "--cpus 2",
"mounts": []interface{}{"/host:/container", "/tmp:/tmp"},
@@ -348,7 +236,7 @@ func TestNewDockerRunnerOptions(t *testing.T) {
"dns_search": []interface{}{"example.com"},
"platform": "linux/amd64",
},
- expected: DockerRunnerOptions{
+ expected: DockerOptions{
Image: "ubuntu:20.04",
DockerRunOpts: "--cpus 2",
Mounts: []string{"/host:/container", "/tmp:/tmp"},
@@ -373,7 +261,7 @@ func TestNewDockerRunnerOptions(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
- result, err := NewDockerRunnerOptions(tc.input)
+ result, err := NewDockerOptions(tc.input)
// Check error expectation
if tc.expectError && err == nil {
@@ -394,41 +282,9 @@ func TestNewDockerRunnerOptions(t *testing.T) {
if result.Image != tc.expected.Image {
t.Errorf("Image: expected %q, got %q", tc.expected.Image, result.Image)
}
- if result.DockerRunOpts != tc.expected.DockerRunOpts {
- t.Errorf("DockerRunOpts: expected %q, got %q", tc.expected.DockerRunOpts, result.DockerRunOpts)
- }
if result.AllowNetworking != tc.expected.AllowNetworking {
t.Errorf("AllowNetworking: expected %v, got %v", tc.expected.AllowNetworking, result.AllowNetworking)
}
- if result.Network != tc.expected.Network {
- t.Errorf("Network: expected %q, got %q", tc.expected.Network, result.Network)
- }
- if result.User != tc.expected.User {
- t.Errorf("User: expected %q, got %q", tc.expected.User, result.User)
- }
- if result.WorkDir != tc.expected.WorkDir {
- t.Errorf("WorkDir: expected %q, got %q", tc.expected.WorkDir, result.WorkDir)
- }
- if result.PrepareCommand != tc.expected.PrepareCommand {
- t.Errorf("PrepareCommand: expected %q, got %q", tc.expected.PrepareCommand, result.PrepareCommand)
- }
-
- // Check slice fields
- if !compareStringSlices(result.Mounts, tc.expected.Mounts) {
- t.Errorf("Mounts: expected %v, got %v", tc.expected.Mounts, result.Mounts)
- }
- if !compareStringSlices(result.CapAdd, tc.expected.CapAdd) {
- t.Errorf("CapAdd: expected %v, got %v", tc.expected.CapAdd, result.CapAdd)
- }
- if !compareStringSlices(result.CapDrop, tc.expected.CapDrop) {
- t.Errorf("CapDrop: expected %v, got %v", tc.expected.CapDrop, result.CapDrop)
- }
- if !compareStringSlices(result.DNS, tc.expected.DNS) {
- t.Errorf("DNS: expected %v, got %v", tc.expected.DNS, result.DNS)
- }
- if !compareStringSlices(result.DNSSearch, tc.expected.DNSSearch) {
- t.Errorf("DNSSearch: expected %v, got %v", tc.expected.DNSSearch, result.DNSSearch)
- }
})
}
}
diff --git a/pkg/command/runner_exec.go b/pkg/runner/exec.go
similarity index 69%
rename from pkg/command/runner_exec.go
rename to pkg/runner/exec.go
index df86639..a44d101 100644
--- a/pkg/command/runner_exec.go
+++ b/pkg/runner/exec.go
@@ -1,4 +1,4 @@
-package command
+package runner
import (
"bytes"
@@ -14,43 +14,41 @@ import (
"github.com/inercia/MCPShell/pkg/common"
)
-// RunnerExec implements the Runner interface
-type RunnerExec struct {
+// Exec implements the Runner interface for direct command execution
+type Exec struct {
logger *common.Logger
- options RunnerExecOptions
+ options ExecOptions
}
-// RunnerExecOptions is the options for the RunnerExec
-type RunnerExecOptions struct {
+// ExecOptions is the options for the Exec runner
+type ExecOptions struct {
Shell string `json:"shell"`
}
-// NewRunnerExecOptions creates a new RunnerExecOptions from a RunnerOptions
-func NewRunnerExecOptions(options RunnerOptions) (RunnerExecOptions, error) {
- var reopts RunnerExecOptions
- opts, err := options.ToJSON()
+// NewExecOptions creates a new ExecOptions from Options
+func NewExecOptions(options Options) (ExecOptions, error) {
+ var opts ExecOptions
+ jsonStr, err := options.ToJSON()
if err != nil {
- return RunnerExecOptions{}, err
+ return ExecOptions{}, err
}
- err = json.Unmarshal([]byte(opts), &reopts)
- return reopts, err
+ err = json.Unmarshal([]byte(jsonStr), &opts)
+ return opts, err
}
-//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-// NewRunnerExec creates a new ExecRunner with the provided logger
-// If logger is nil, a default logger is created
-func NewRunnerExec(options RunnerOptions, logger *common.Logger) (*RunnerExec, error) {
+// NewExec creates a new Exec runner with the provided logger.
+// If logger is nil, a default logger is created.
+func NewExec(options Options, logger *common.Logger) (*Exec, error) {
if logger == nil {
logger = common.GetLogger()
}
- execOptions, err := NewRunnerExecOptions(options)
+ execOptions, err := NewExecOptions(options)
if err != nil {
return nil, err
}
- return &RunnerExec{
+ return &Exec{
logger: logger,
options: execOptions,
}, nil
@@ -61,7 +59,7 @@ func NewRunnerExec(options RunnerOptions, logger *common.Logger) (*RunnerExec, e
//
// Note: For Windows native shells (cmd, powershell), the 'tmpfile' parameter is ignored
// and commands are executed directly to avoid issues with output capturing.
-func (r *RunnerExec) Run(ctx context.Context, shell string,
+func (r *Exec) Run(ctx context.Context, shell string,
command string,
env []string, params map[string]interface{},
tmpfile bool,
@@ -205,58 +203,9 @@ func (r *RunnerExec) Run(ctx context.Context, shell string,
return output, nil
}
-// isCmdShell checks if the given shell is a Windows cmd shell
-func isCmdShell(shell string) bool {
- shellLower := strings.ToLower(shell)
- return strings.Contains(shellLower, "cmd") || strings.HasSuffix(shellLower, "cmd.exe")
-}
-
-// isPowerShell checks if the given shell is a PowerShell
-func isPowerShell(shell string) bool {
- shellLower := strings.ToLower(shell)
- return strings.Contains(shellLower, "powershell") || strings.HasSuffix(shellLower, "powershell.exe") ||
- strings.HasSuffix(shellLower, "pwsh.exe")
-}
-
-// isWindowsShell checks if the given shell is a Windows-specific shell (cmd or powershell)
-func isWindowsShell(shell string) bool {
- return isCmdShell(shell) || isPowerShell(shell)
-}
-
-// getShell returns the shell to use for command execution,
-// using the provided shell, falling back to $SHELL env var,
-// and finally using appropriate default based on OS.
-//
-// Parameters:
-// - configShell: The configured shell to use (can be empty)
-//
-// Returns:
-// - The shell executable path to use
-func getShell(configShell string) string {
- if configShell != "" {
- return configShell
- }
-
- // On Windows, default to cmd.exe if SHELL is not set
- if runtime.GOOS == "windows" {
- shell := os.Getenv("COMSPEC") // More reliable on Windows
- if shell != "" {
- return shell
- }
- return "cmd.exe" // Fallback for Windows
- }
-
- shell := os.Getenv("SHELL")
- if shell != "" {
- return shell
- }
-
- return "/bin/sh" // Default for Unix-like systems
-}
-
-// CheckImplicitRequirements checks if the runner meets its implicit requirements
-// Exec runner has no special requirements
-func (r *RunnerExec) CheckImplicitRequirements() error {
+// CheckImplicitRequirements checks if the runner meets its implicit requirements.
+// Exec runner has no special requirements.
+func (r *Exec) CheckImplicitRequirements() error {
// No special requirements for the basic exec runner
return nil
}
diff --git a/pkg/command/runner_exec_test.go b/pkg/runner/exec_test.go
similarity index 54%
rename from pkg/command/runner_exec_test.go
rename to pkg/runner/exec_test.go
index 5cace5f..39ea5d4 100644
--- a/pkg/command/runner_exec_test.go
+++ b/pkg/runner/exec_test.go
@@ -1,4 +1,4 @@
-package command
+package runner
import (
"context"
@@ -10,46 +10,46 @@ import (
"github.com/inercia/MCPShell/pkg/common"
)
-func TestNewRunnerExecOptions(t *testing.T) {
+func TestNewExecOptions(t *testing.T) {
tests := []struct {
name string
- options RunnerOptions
- want RunnerExecOptions
+ options Options
+ want ExecOptions
wantErr bool
}{
{
name: "valid options with shell",
- options: RunnerOptions{
+ options: Options{
"shell": "/bin/bash",
},
- want: RunnerExecOptions{
+ want: ExecOptions{
Shell: "/bin/bash",
},
wantErr: false,
},
{
name: "empty options",
- options: RunnerOptions{},
- want: RunnerExecOptions{},
+ options: Options{},
+ want: ExecOptions{},
wantErr: false,
},
{
name: "options with additional fields",
- options: RunnerOptions{
+ options: Options{
"shell": "/bin/zsh",
"extra": "value",
},
- want: RunnerExecOptions{
+ want: ExecOptions{
Shell: "/bin/zsh",
},
wantErr: false,
},
{
name: "options with numeric shell as string",
- options: RunnerOptions{
+ options: Options{
"shell": "123",
},
- want: RunnerExecOptions{
+ want: ExecOptions{
Shell: "123",
},
wantErr: false,
@@ -58,19 +58,19 @@ func TestNewRunnerExecOptions(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := NewRunnerExecOptions(tt.options)
+ got, err := NewExecOptions(tt.options)
if (err != nil) != tt.wantErr {
- t.Errorf("NewRunnerExecOptions() error = %v, wantErr %v", err, tt.wantErr)
+ t.Errorf("NewExecOptions() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
- t.Errorf("NewRunnerExecOptions() = %v, want %v", got, tt.want)
+ t.Errorf("NewExecOptions() = %v, want %v", got, tt.want)
}
})
}
}
-func TestRunnerExec_Run(t *testing.T) {
+func TestExec_Run(t *testing.T) {
tests := []struct {
name string
shell string
@@ -107,14 +107,14 @@ func TestRunnerExec_Run(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger, _ := common.NewLogger("test-runner-exec: ", "", common.LogLevelInfo, false)
- r, err := NewRunnerExec(RunnerOptions{}, logger)
+ r, err := NewExec(Options{}, logger)
if err != nil {
- t.Fatalf("Failed to create RunnerExec: %v", err)
+ t.Fatalf("Failed to create Exec: %v", err)
}
got, err := r.Run(context.Background(), tt.shell, tt.command, tt.env, tt.params, true)
if (err != nil) != tt.wantErr {
- t.Errorf("RunnerExec.Run() error = %v, wantErr %v", err, tt.wantErr)
+ t.Errorf("Exec.Run() error = %v, wantErr %v", err, tt.wantErr)
return
}
@@ -122,19 +122,19 @@ func TestRunnerExec_Run(t *testing.T) {
got = strings.TrimSpace(got)
if got != tt.want {
- t.Errorf("RunnerExec.Run() = %q, want %q", got, tt.want)
+ t.Errorf("Exec.Run() = %q, want %q", got, tt.want)
}
})
}
}
-func TestRunnerExec_RunWithEnvExpansion(t *testing.T) {
+func TestExec_RunWithEnvExpansion(t *testing.T) {
// This test demonstrates using the -c flag to execute a command with environment variable expansion
logger, _ := common.NewLogger("test-runner-exec-env: ", "", common.LogLevelInfo, false)
- r, err := NewRunnerExec(RunnerOptions{}, logger)
+ r, err := NewExec(Options{}, logger)
if err != nil {
- t.Fatalf("Failed to create RunnerExec: %v", err)
+ t.Fatalf("Failed to create Exec: %v", err)
}
command := "echo $TEST_VAR"
@@ -153,7 +153,7 @@ func TestRunnerExec_RunWithEnvExpansion(t *testing.T) {
)
if err != nil {
- t.Fatalf("RunnerExec.Run() error = %v", err)
+ t.Fatalf("Exec.Run() error = %v", err)
}
output = strings.TrimSpace(output)
@@ -163,33 +163,3 @@ func TestRunnerExec_RunWithEnvExpansion(t *testing.T) {
t.Errorf("Environment variable expansion failed: got %q, want %q", output, expected)
}
}
-
-func TestRunnerExec_Optimization_SingleExecutable(t *testing.T) {
- logger, _ := common.NewLogger("test-runner-exec-opt: ", "", common.LogLevelInfo, false)
- r, err := NewRunnerExec(RunnerOptions{}, logger)
- if err != nil {
- t.Fatalf("Failed to create RunnerExec: %v", err)
- }
-
- // This command should be a single executable and run directly
- command := "whoami"
- output, err := r.Run(context.Background(), "", command, nil, nil, false)
- if err != nil {
- t.Errorf("Expected '%s' to run without error, got: %v", command, err)
- }
- if len(strings.TrimSpace(output)) == 0 {
- t.Errorf("Expected output from '%s', got empty string", command)
- }
-
- // This command has arguments and should be run via a shell, not directly.
- // isSingleExecutableCommand should return false.
- // The command itself should succeed when run through the shell.
- commandWithArgs := "echo hello"
- output, err = r.Run(context.Background(), "", commandWithArgs, nil, nil, false)
- if err != nil {
- t.Errorf("Expected '%s' to run without error, got: %v", commandWithArgs, err)
- }
- if strings.TrimSpace(output) != "hello" {
- t.Errorf("Expected output from '%s' to be 'hello', got %q", commandWithArgs, output)
- }
-}
diff --git a/pkg/command/runner_firejail.go b/pkg/runner/firejail.go
similarity index 84%
rename from pkg/command/runner_firejail.go
rename to pkg/runner/firejail.go
index 78346f5..c7ce4cf 100644
--- a/pkg/command/runner_firejail.go
+++ b/pkg/runner/firejail.go
@@ -1,4 +1,4 @@
-package command
+package runner
import (
"bytes"
@@ -16,18 +16,18 @@ import (
"github.com/inercia/MCPShell/pkg/common"
)
-//go:embed runner_firejail_profile.tpl
+//go:embed firejail_profile.tpl
var firejailProfileTemplate string
-// RunnerFirejail implements the Runner interface using firejail on Linux
-type RunnerFirejail struct {
+// Firejail implements the Runner interface using firejail on Linux
+type Firejail struct {
logger *common.Logger
profileTpl *template.Template
- options RunnerFirejailOptions
+ options FirejailOptions
}
-// RunnerFirejailOptions is the options for the RunnerFirejail
-type RunnerFirejailOptions struct {
+// FirejailOptions is the options for the Firejail runner
+type FirejailOptions struct {
Shell string `json:"shell"`
AllowNetworking bool `json:"allow_networking"`
AllowUserFolders bool `json:"allow_user_folders"`
@@ -38,22 +38,20 @@ type RunnerFirejailOptions struct {
CustomProfile string `json:"custom_profile"`
}
-// NewRunnerFirejailOptions creates a new RunnerFirejailOptions from a RunnerOptions
-func NewRunnerFirejailOptions(options RunnerOptions) (RunnerFirejailOptions, error) {
- var reopts RunnerFirejailOptions
- opts, err := options.ToJSON()
+// NewFirejailOptions creates a new FirejailOptions from Options
+func NewFirejailOptions(options Options) (FirejailOptions, error) {
+ var opts FirejailOptions
+ jsonStr, err := options.ToJSON()
if err != nil {
- return RunnerFirejailOptions{}, err
+ return FirejailOptions{}, err
}
- err = json.Unmarshal([]byte(opts), &reopts)
- return reopts, err
+ err = json.Unmarshal([]byte(jsonStr), &opts)
+ return opts, err
}
-//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-// NewRunnerFirejail creates a new RunnerFirejail with the provided logger
-// If logger is nil, a default logger is created
-func NewRunnerFirejail(options RunnerOptions, logger *common.Logger) (*RunnerFirejail, error) {
+// NewFirejail creates a new Firejail runner with the provided logger.
+// If logger is nil, a default logger is created.
+func NewFirejail(options Options, logger *common.Logger) (*Firejail, error) {
if logger == nil {
logger = common.GetLogger()
}
@@ -66,24 +64,24 @@ func NewRunnerFirejail(options RunnerOptions, logger *common.Logger) (*RunnerFir
}
// Parse firejail-specific options
- firejailOpts, err := NewRunnerFirejailOptions(options)
+ firejailOpts, err := NewFirejailOptions(options)
if err != nil {
logger.Debug("Failed to parse firejail options: %v", err)
return nil, fmt.Errorf("failed to parse firejail options: %w", err)
}
- return &RunnerFirejail{
+ return &Firejail{
logger: logger,
profileTpl: profileTpl,
options: firejailOpts,
}, nil
}
-// Run executes a command inside the firejail sandbox and returns the output
-// It implements the Runner interface
+// Run executes a command inside the firejail sandbox and returns the output.
+// It implements the Runner interface.
//
// note: tmpfile is ignored for firejail because it's not supported
-func (r *RunnerFirejail) Run(ctx context.Context,
+func (r *Firejail) Run(ctx context.Context,
shell string, command string,
env []string, params map[string]interface{}, tmpfile bool,
) (string, error) {
@@ -245,9 +243,9 @@ func (r *RunnerFirejail) Run(ctx context.Context,
return outputStr, nil
}
-// CheckImplicitRequirements checks if the runner meets its implicit requirements
-// Firejail runner requires Linux and the firejail executable
-func (r *RunnerFirejail) CheckImplicitRequirements() error {
+// CheckImplicitRequirements checks if the runner meets its implicit requirements.
+// Firejail runner requires Linux and the firejail executable.
+func (r *Firejail) CheckImplicitRequirements() error {
// Firejail is Linux only
if runtime.GOOS != "linux" {
return fmt.Errorf("firejail runner requires Linux")
diff --git a/pkg/command/runner_firejail_profile.tpl b/pkg/runner/firejail_profile.tpl
similarity index 98%
rename from pkg/command/runner_firejail_profile.tpl
rename to pkg/runner/firejail_profile.tpl
index 9362592..51c33f1 100644
--- a/pkg/command/runner_firejail_profile.tpl
+++ b/pkg/runner/firejail_profile.tpl
@@ -51,4 +51,5 @@ whitelist {{ . }}
seccomp
caps.drop all
noroot
-{{ end }}
\ No newline at end of file
+{{ end }}
+
diff --git a/pkg/command/runner_firejail_test.go b/pkg/runner/firejail_test.go
similarity index 69%
rename from pkg/command/runner_firejail_test.go
rename to pkg/runner/firejail_test.go
index 918c8d9..8bc63fd 100644
--- a/pkg/command/runner_firejail_test.go
+++ b/pkg/runner/firejail_test.go
@@ -1,4 +1,4 @@
-package command
+package runner
import (
"context"
@@ -8,27 +8,27 @@ import (
"testing"
)
-func TestNewRunnerFirejail(t *testing.T) {
+func TestNewFirejail(t *testing.T) {
// Skip on non-Linux platforms
if runtime.GOOS != "linux" {
t.Skip("Skipping firejail tests on non-Linux platform")
}
- options := RunnerOptions{
+ options := Options{
"allow_networking": true,
}
- runner, err := NewRunnerFirejail(options, nil)
+ r, err := NewFirejail(options, nil)
if err != nil {
t.Fatalf("Failed to create firejail runner: %v", err)
}
- if runner == nil {
+ if r == nil {
t.Fatal("Expected non-nil runner")
}
}
-func TestRunnerFirejailRun(t *testing.T) {
+func TestFirejail_Run(t *testing.T) {
// Skip on non-Linux platforms
if runtime.GOOS != "linux" {
t.Skip("Skipping firejail tests on non-Linux platform")
@@ -39,11 +39,11 @@ func TestRunnerFirejailRun(t *testing.T) {
t.Skip("Skipping test because firejail is not installed")
}
- options := RunnerOptions{
+ options := Options{
"allow_networking": true,
}
- runner, err := NewRunnerFirejail(options, nil)
+ r, err := NewFirejail(options, nil)
if err != nil {
t.Fatalf("Failed to create firejail runner: %v", err)
}
@@ -51,7 +51,7 @@ func TestRunnerFirejailRun(t *testing.T) {
ctx := context.Background()
// Test simple echo command
- output, err := runner.Run(ctx, "/bin/sh", "echo hello world", nil, nil, false) // No need for tmpfile here
+ output, err := r.Run(ctx, "/bin/sh", "echo hello world", nil, nil, false)
if err != nil {
t.Fatalf("Failed to run command: %v", err)
}
@@ -61,7 +61,7 @@ func TestRunnerFirejailRun(t *testing.T) {
}
}
-func TestRunnerFirejailNetworkRestriction(t *testing.T) {
+func TestFirejail_NetworkRestriction(t *testing.T) {
// Skip on non-Linux platforms
if runtime.GOOS != "linux" {
t.Skip("Skipping firejail tests on non-Linux platform")
@@ -75,47 +75,46 @@ func TestRunnerFirejailNetworkRestriction(t *testing.T) {
ctx := context.Background()
// Test with networking enabled
- networkEnabledOptions := RunnerOptions{
+ networkEnabledOptions := Options{
"allow_networking": true,
}
- runnerEnabled, err := NewRunnerFirejail(networkEnabledOptions, nil)
+ runnerEnabled, err := NewFirejail(networkEnabledOptions, nil)
if err != nil {
t.Fatalf("Failed to create firejail runner: %v", err)
}
// This might succeed or fail depending on network connectivity,
// but it should not be blocked by firejail
- _, _ = runnerEnabled.Run(ctx, "/bin/sh", "ping -c 1 127.0.0.1", nil, nil, false) // No need for tmpfile here
+ _, _ = runnerEnabled.Run(ctx, "/bin/sh", "ping -c 1 127.0.0.1", nil, nil, false)
// Test with networking disabled
- networkDisabledOptions := RunnerOptions{
+ networkDisabledOptions := Options{
"allow_networking": false,
}
- runnerDisabled, err := NewRunnerFirejail(networkDisabledOptions, nil)
+ runnerDisabled, err := NewFirejail(networkDisabledOptions, nil)
if err != nil {
t.Fatalf("Failed to create firejail runner: %v", err)
}
// This should fail or timeout due to network restrictions
- // Note: We're not asserting the exact behavior as it might vary based on firejail version
- _, _ = runnerDisabled.Run(ctx, "/bin/sh", "ping -c 1 127.0.0.1", nil, nil, false) // No need for tmpfile here
+ _, _ = runnerDisabled.Run(ctx, "/bin/sh", "ping -c 1 127.0.0.1", nil, nil, false)
}
-func TestRunnerFirejail_Optimization_SingleExecutable(t *testing.T) {
+func TestFirejail_Optimization_SingleExecutable(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Skipping firejail tests on non-Linux platform")
}
if _, err := os.Stat("/usr/bin/firejail"); os.IsNotExist(err) {
t.Skip("Skipping test because firejail is not installed")
}
- runner, err := NewRunnerFirejail(RunnerOptions{"allow_networking": true}, nil)
+ r, err := NewFirejail(Options{"allow_networking": true}, nil)
if err != nil {
t.Fatalf("Failed to create firejail runner: %v", err)
}
// Should succeed: /bin/ls is a single executable
- output, err := runner.Run(context.Background(), "", "/bin/ls", nil, nil, false)
+ output, err := r.Run(context.Background(), "", "/bin/ls", nil, nil, false)
if err != nil {
t.Errorf("Expected /bin/ls to run without error, got: %v", err)
}
@@ -123,7 +122,7 @@ func TestRunnerFirejail_Optimization_SingleExecutable(t *testing.T) {
t.Errorf("Expected output from /bin/ls, got empty string")
}
// Should NOT optimize: command with arguments
- _, err2 := runner.Run(context.Background(), "", "/bin/ls -l", nil, nil, false)
+ _, err2 := r.Run(context.Background(), "", "/bin/ls -l", nil, nil, false)
if err2 != nil && !strings.Contains(err2.Error(), "no such file") {
t.Logf("Expected failure for /bin/ls -l as a single executable: %v", err2)
}
diff --git a/pkg/runner/runner.go b/pkg/runner/runner.go
new file mode 100644
index 0000000..a4ea570
--- /dev/null
+++ b/pkg/runner/runner.go
@@ -0,0 +1,116 @@
+// Package runner provides isolated command execution environments.
+//
+// This package defines the Runner interface and implementations for executing
+// commands in various isolation environments including direct execution,
+// firejail (Linux), sandbox-exec (macOS), and Docker containers.
+package runner
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/inercia/MCPShell/pkg/common"
+)
+
+// Type is an identifier for the type of runner to use.
+// Each runner has its own set of implicit requirements that are checked
+// automatically, so users don't need to explicitly specify common requirements
+// in their tool configurations.
+type Type string
+
+const (
+ // TypeExec is the standard command execution runner with no additional requirements
+ TypeExec Type = "exec"
+
+ // TypeSandboxExec is the macOS-specific sandbox-exec runner
+ // Implicit requirements: OS=darwin, executables=[sandbox-exec]
+ TypeSandboxExec Type = "sandbox-exec"
+
+ // TypeFirejail is the Linux-specific firejail runner
+ // Implicit requirements: OS=linux, executables=[firejail]
+ TypeFirejail Type = "firejail"
+
+ // TypeDocker is the Docker-based runner
+ // Implicit requirements: executables=[docker]
+ TypeDocker Type = "docker"
+)
+
+// Options is a map of options for the runner
+type Options map[string]interface{}
+
+// ToJSON converts the options to a JSON string
+func (o Options) ToJSON() (string, error) {
+ jsonBytes, err := json.Marshal(o)
+ return string(jsonBytes), err
+}
+
+// Runner is an interface for running commands in isolated environments
+type Runner interface {
+ // Run executes a command and returns the output.
+ //
+ // Parameters:
+ // - ctx: Context for cancellation and timeout
+ // - shell: The shell to use for execution (empty for default)
+ // - command: The command to execute
+ // - env: Environment variables in KEY=VALUE format
+ // - params: Template parameters for variable substitution
+ // - tmpfile: Whether to use a temporary file for the command
+ //
+ // Returns:
+ // - The command output as a string
+ // - An error if execution fails
+ Run(ctx context.Context, shell string, command string, env []string, params map[string]interface{}, tmpfile bool) (string, error)
+
+ // CheckImplicitRequirements verifies that the runner's prerequisites are met.
+ // This includes checking for required executables, OS compatibility, etc.
+ //
+ // Returns:
+ // - nil if all requirements are satisfied
+ // - An error describing which requirement is not met
+ CheckImplicitRequirements() error
+}
+
+// New creates a new Runner based on the given type.
+//
+// Parameters:
+// - runnerType: The type of runner to create
+// - options: Configuration options for the runner
+// - logger: Logger for debug output (uses global logger if nil)
+//
+// Returns:
+// - A Runner instance if successful
+// - An error if creation fails or requirements are not met
+func New(runnerType Type, options Options, logger *common.Logger) (Runner, error) {
+ var runner Runner
+ var err error
+
+ // Create the runner instance based on type
+ switch runnerType {
+ case TypeExec:
+ runner, err = NewExec(options, logger)
+ case TypeSandboxExec:
+ runner, err = NewSandboxExec(options, logger)
+ case TypeFirejail:
+ runner, err = NewFirejail(options, logger)
+ case TypeDocker:
+ runner, err = NewDocker(options, logger)
+ default:
+ return nil, fmt.Errorf("unknown runner type: %s", runnerType)
+ }
+
+ // Check if runner creation failed
+ if err != nil {
+ return nil, err
+ }
+
+ // Check implicit requirements for the created runner
+ if err := runner.CheckImplicitRequirements(); err != nil {
+ if logger != nil {
+ logger.Debug("Runner %s failed implicit requirements check: %v", runnerType, err)
+ }
+ return nil, err
+ }
+
+ return runner, nil
+}
diff --git a/pkg/command/runner_test.go b/pkg/runner/runner_test.go
similarity index 85%
rename from pkg/command/runner_test.go
rename to pkg/runner/runner_test.go
index 5873c53..0caaf0a 100644
--- a/pkg/command/runner_test.go
+++ b/pkg/runner/runner_test.go
@@ -1,4 +1,4 @@
-package command
+package runner
import (
"runtime"
@@ -15,12 +15,12 @@ func TestImplicitRequirements(t *testing.T) {
// Test cases for the exec runner
t.Run("ExecRunner", func(t *testing.T) {
// Exec runner should always pass since it has no requirements
- runner, err := NewRunnerExec(RunnerOptions{}, logger)
+ r, err := NewExec(Options{}, logger)
if err != nil {
t.Fatalf("Failed to create exec runner: %v", err)
}
- err = runner.CheckImplicitRequirements()
+ err = r.CheckImplicitRequirements()
if err != nil {
t.Errorf("Exec runner should have no requirements but failed: %v", err)
}
@@ -34,13 +34,13 @@ func TestImplicitRequirements(t *testing.T) {
}
// Create the runner
- runner, err := NewRunnerSandboxExec(RunnerOptions{}, logger)
+ r, err := NewSandboxExec(Options{}, logger)
if err != nil {
t.Fatalf("Failed to create sandbox-exec runner: %v", err)
}
// Check requirements - expect pass on macOS if executable exists
- err = runner.CheckImplicitRequirements()
+ err = r.CheckImplicitRequirements()
if err != nil {
// This is expected if sandbox-exec is not available
t.Logf("SandboxExec runner failed as expected if sandbox-exec is not available: %v", err)
@@ -58,13 +58,13 @@ func TestImplicitRequirements(t *testing.T) {
}
// Create the runner
- runner, err := NewRunnerFirejail(RunnerOptions{}, logger)
+ r, err := NewFirejail(Options{}, logger)
if err != nil {
t.Fatalf("Failed to create firejail runner: %v", err)
}
// Check requirements - expect pass on Linux if executable exists
- err = runner.CheckImplicitRequirements()
+ err = r.CheckImplicitRequirements()
if err != nil {
// This is expected if firejail is not available
t.Logf("Firejail runner failed as expected if firejail is not available: %v", err)
@@ -77,17 +77,17 @@ func TestImplicitRequirements(t *testing.T) {
// The Docker daemon check will be handled in the DockerRunner itself
// Create a Docker runner with mock options that satisfy its creation requirements
- mockOpts := RunnerOptions{
+ mockOpts := Options{
"image": "alpine:latest",
}
- runner, err := NewDockerRunner(mockOpts, logger)
+ r, err := NewDocker(mockOpts, logger)
if err != nil {
t.Fatalf("Failed to create docker runner: %v", err)
}
// Check requirements - expect pass if Docker is available and running
- err = runner.CheckImplicitRequirements()
+ err = r.CheckImplicitRequirements()
if err != nil {
// This is expected if Docker is not available or daemon is not running
t.Logf("Docker runner failed requirements check as expected: %v", err)
diff --git a/pkg/command/runner_sandbox.go b/pkg/runner/sandbox.go
similarity index 83%
rename from pkg/command/runner_sandbox.go
rename to pkg/runner/sandbox.go
index 47a4bca..7cd02c5 100644
--- a/pkg/command/runner_sandbox.go
+++ b/pkg/runner/sandbox.go
@@ -1,4 +1,4 @@
-package command
+package runner
import (
"bytes"
@@ -17,18 +17,18 @@ import (
"github.com/inercia/MCPShell/pkg/common"
)
-//go:embed runner_sandbox_profile.tpl
+//go:embed sandbox_profile.tpl
var sandboxProfileTemplate string
-// RunnerSandboxExec implements the Runner interface using macOS sandbox-exec
-type RunnerSandboxExec struct {
+// SandboxExec implements the Runner interface using macOS sandbox-exec
+type SandboxExec struct {
logger *common.Logger
profileTpl *template.Template
- options RunnerSandboxExecOptions
+ options SandboxExecOptions
}
-// RunnerSandboxExecOptions is the options for the RunnerSandboxExec
-type RunnerSandboxExecOptions struct {
+// SandboxExecOptions is the options for the SandboxExec runner
+type SandboxExecOptions struct {
Shell string `json:"shell"`
AllowNetworking bool `json:"allow_networking"`
AllowUserFolders bool `json:"allow_user_folders"`
@@ -39,22 +39,20 @@ type RunnerSandboxExecOptions struct {
CustomProfile string `json:"custom_profile"`
}
-// NewRunnerSandboxExecOptions creates a new RunnerSandboxExecOptions from a RunnerOptions
-func NewRunnerSandboxExecOptions(options RunnerOptions) (RunnerSandboxExecOptions, error) {
- var reopts RunnerSandboxExecOptions
- opts, err := options.ToJSON()
+// NewSandboxExecOptions creates a new SandboxExecOptions from Options
+func NewSandboxExecOptions(options Options) (SandboxExecOptions, error) {
+ var opts SandboxExecOptions
+ jsonStr, err := options.ToJSON()
if err != nil {
- return RunnerSandboxExecOptions{}, err
+ return SandboxExecOptions{}, err
}
- err = json.Unmarshal([]byte(opts), &reopts)
- return reopts, err
+ err = json.Unmarshal([]byte(jsonStr), &opts)
+ return opts, err
}
-//////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-
-// NewRunnerSandboxExec creates a new RunnerSandboxExec with the provided logger
-// If logger is nil, a default logger is created
-func NewRunnerSandboxExec(options RunnerOptions, logger *common.Logger) (*RunnerSandboxExec, error) {
+// NewSandboxExec creates a new SandboxExec runner with the provided logger.
+// If logger is nil, a default logger is created.
+func NewSandboxExec(options Options, logger *common.Logger) (*SandboxExec, error) {
if logger == nil {
logger = common.GetLogger()
}
@@ -67,24 +65,24 @@ func NewRunnerSandboxExec(options RunnerOptions, logger *common.Logger) (*Runner
}
// Parse sandbox-specific options
- sandboxOpts, err := NewRunnerSandboxExecOptions(options)
+ sandboxOpts, err := NewSandboxExecOptions(options)
if err != nil {
logger.Debug("Failed to parse sandbox options: %v", err)
return nil, fmt.Errorf("failed to parse sandbox options: %w", err)
}
- return &RunnerSandboxExec{
+ return &SandboxExec{
logger: logger,
profileTpl: profileTpl,
options: sandboxOpts,
}, nil
}
-// Run executes a command inside the macOS sandbox and returns the output
-// It implements the Runner interface
+// Run executes a command inside the macOS sandbox and returns the output.
+// It implements the Runner interface.
//
// note: tmpfile is ignored for sandbox because it's not supported
-func (r *RunnerSandboxExec) Run(ctx context.Context, shell string, command string, env []string, params map[string]interface{}, tmpfile bool) (string, error) {
+func (r *SandboxExec) Run(ctx context.Context, shell string, command string, env []string, params map[string]interface{}, tmpfile bool) (string, error) {
fullCmd := command
// Check if context is done
@@ -257,9 +255,9 @@ func (r *RunnerSandboxExec) Run(ctx context.Context, shell string, command strin
return outputStr, nil
}
-// CheckImplicitRequirements checks if the runner meets its implicit requirements
-// SandboxExec runner requires macOS and the sandbox-exec executable
-func (r *RunnerSandboxExec) CheckImplicitRequirements() error {
+// CheckImplicitRequirements checks if the runner meets its implicit requirements.
+// SandboxExec runner requires macOS and the sandbox-exec executable.
+func (r *SandboxExec) CheckImplicitRequirements() error {
// Sandbox exec is macOS only
if runtime.GOOS != "darwin" {
return fmt.Errorf("sandbox-exec runner requires macOS")
@@ -272,13 +270,3 @@ func (r *RunnerSandboxExec) CheckImplicitRequirements() error {
return nil
}
-
-// contains checks if a string slice contains a specific string
-func contains(slice []string, item string) bool {
- for _, s := range slice {
- if s == item {
- return true
- }
- }
- return false
-}
diff --git a/pkg/command/runner_sandbox_profile.tpl b/pkg/runner/sandbox_profile.tpl
similarity index 99%
rename from pkg/command/runner_sandbox_profile.tpl
rename to pkg/runner/sandbox_profile.tpl
index 66b6946..a7be4bc 100644
--- a/pkg/command/runner_sandbox_profile.tpl
+++ b/pkg/runner/sandbox_profile.tpl
@@ -48,4 +48,5 @@
(allow file-write* (literal "{{ . }}"))
{{ end }}
-{{ end }}
\ No newline at end of file
+{{ end }}
+
diff --git a/pkg/runner/sandbox_test.go b/pkg/runner/sandbox_test.go
new file mode 100644
index 0000000..6c9b5a8
--- /dev/null
+++ b/pkg/runner/sandbox_test.go
@@ -0,0 +1,185 @@
+package runner
+
+import (
+ "context"
+ "reflect"
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/inercia/MCPShell/pkg/common"
+)
+
+func TestNewSandboxExecOptions(t *testing.T) {
+ // Skip on non-macOS platforms
+ if runtime.GOOS != "darwin" {
+ t.Skip("Skipping test on non-macOS platform")
+ }
+
+ tests := []struct {
+ name string
+ options Options
+ want SandboxExecOptions
+ wantErr bool
+ }{
+ {
+ name: "valid options with all fields",
+ options: Options{
+ "shell": "/bin/bash",
+ "allow_networking": true,
+ "allow_user_folders": true,
+ "custom_profile": "(version 1)(allow default)",
+ },
+ want: SandboxExecOptions{
+ Shell: "/bin/bash",
+ AllowNetworking: true,
+ AllowUserFolders: true,
+ CustomProfile: "(version 1)(allow default)",
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty options",
+ options: Options{},
+ want: SandboxExecOptions{},
+ wantErr: false,
+ },
+ {
+ name: "options with partial fields",
+ options: Options{
+ "shell": "/bin/zsh",
+ "allow_networking": false,
+ },
+ want: SandboxExecOptions{
+ Shell: "/bin/zsh",
+ AllowNetworking: false,
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := NewSandboxExecOptions(tt.options)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("NewSandboxExecOptions() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("NewSandboxExecOptions() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// This test is only run on macOS as it requires sandbox-exec
+func TestSandboxExec_Run(t *testing.T) {
+ // Skip on non-macOS platforms
+ if runtime.GOOS != "darwin" {
+ t.Skip("Skipping test on non-macOS platform")
+ }
+
+ // Also skip if the short flag is set
+ if testing.Short() {
+ t.Skip("skipping test in short mode")
+ }
+
+ // Create a logger for the test
+ logger, _ := common.NewLogger("test-runner-sandbox: ", "", common.LogLevelInfo, false)
+ ctx := context.Background()
+ shell := "" // use default
+
+ tests := []struct {
+ name string
+ command string
+ options Options
+ params map[string]interface{}
+ shouldSucceed bool
+ expectedOut string
+ }{
+ {
+ name: "echo command with full permissions",
+ command: "echo 'Hello Sandbox'",
+ options: Options{
+ "allow_networking": true,
+ "allow_user_folders": true,
+ },
+ shouldSucceed: true,
+ expectedOut: "Hello Sandbox",
+ },
+ {
+ name: "echo command with networking disabled",
+ command: "echo 'No Network'",
+ options: Options{
+ "allow_networking": false,
+ "allow_user_folders": true,
+ },
+ shouldSucceed: true,
+ expectedOut: "No Network",
+ },
+ {
+ name: "echo command with all restrictions",
+ command: "echo 'Restricted'",
+ options: Options{
+ "allow_networking": false,
+ "allow_user_folders": false,
+ },
+ shouldSucceed: true,
+ expectedOut: "Restricted",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ params := tt.params
+ if params == nil {
+ params = map[string]interface{}{}
+ }
+
+ r, err := NewSandboxExec(tt.options, logger)
+ if err != nil {
+ t.Fatalf("Failed to create runner: %v", err)
+ }
+
+ output, err := r.Run(ctx, shell, tt.command, []string{}, params, false)
+
+ if tt.shouldSucceed && err != nil {
+ t.Errorf("Expected command to succeed but got error: %v", err)
+ return
+ }
+
+ if !tt.shouldSucceed && err == nil {
+ t.Errorf("Expected command to fail but it succeeded with output: %s", output)
+ return
+ }
+
+ if tt.shouldSucceed && tt.expectedOut != "" && output != tt.expectedOut {
+ t.Errorf("Output mismatch: got %v, want %v", output, tt.expectedOut)
+ }
+ })
+ }
+}
+
+func TestSandboxExec_Optimization_SingleExecutable(t *testing.T) {
+ if runtime.GOOS != "darwin" {
+ t.Skip("Skipping test on non-macOS platform")
+ }
+ logger, _ := common.NewLogger("test-runner-sandbox-opt: ", "", common.LogLevelInfo, false)
+ r, err := NewSandboxExec(Options{}, logger)
+ if err != nil {
+ t.Fatalf("Failed to create SandboxExec: %v", err)
+ }
+ // Should succeed: /bin/ls is a single executable
+ output, err := r.Run(context.Background(), "", "/bin/ls", nil, nil, false)
+ if err != nil {
+ t.Errorf("Expected /bin/ls to run without error, got: %v", err)
+ }
+ if len(output) == 0 {
+ t.Errorf("Expected output from /bin/ls, got empty string")
+ }
+ // Should NOT optimize: command with arguments
+ _, err2 := r.Run(context.Background(), "", "/bin/ls -l", nil, nil, false)
+ if err2 != nil && !strings.Contains(err2.Error(), "no such file") {
+ t.Logf("Expected failure for /bin/ls -l as a single executable: %v", err2)
+ }
+}
diff --git a/pkg/runner/shell.go b/pkg/runner/shell.go
new file mode 100644
index 0000000..2166a56
--- /dev/null
+++ b/pkg/runner/shell.go
@@ -0,0 +1,56 @@
+package runner
+
+import (
+ "os"
+ "runtime"
+ "strings"
+)
+
+// isCmdShell checks if the given shell is a Windows cmd shell
+func isCmdShell(shell string) bool {
+ shellLower := strings.ToLower(shell)
+ return strings.Contains(shellLower, "cmd") || strings.HasSuffix(shellLower, "cmd.exe")
+}
+
+// isPowerShell checks if the given shell is a PowerShell
+func isPowerShell(shell string) bool {
+ shellLower := strings.ToLower(shell)
+ return strings.Contains(shellLower, "powershell") || strings.HasSuffix(shellLower, "powershell.exe") ||
+ strings.HasSuffix(shellLower, "pwsh.exe")
+}
+
+// isWindowsShell checks if the given shell is a Windows-specific shell (cmd or powershell)
+func isWindowsShell(shell string) bool {
+ return isCmdShell(shell) || isPowerShell(shell)
+}
+
+// getShell returns the shell to use for command execution,
+// using the provided shell, falling back to $SHELL env var,
+// and finally using appropriate default based on OS.
+//
+// Parameters:
+// - configShell: The configured shell to use (can be empty)
+//
+// Returns:
+// - The shell executable path to use
+func getShell(configShell string) string {
+ if configShell != "" {
+ return configShell
+ }
+
+ // On Windows, default to cmd.exe if SHELL is not set
+ if runtime.GOOS == "windows" {
+ shell := os.Getenv("COMSPEC") // More reliable on Windows
+ if shell != "" {
+ return shell
+ }
+ return "cmd.exe" // Fallback for Windows
+ }
+
+ shell := os.Getenv("SHELL")
+ if shell != "" {
+ return shell
+ }
+
+ return "/bin/sh" // Default for Unix-like systems
+}
diff --git a/pkg/command/platform_unix.go b/pkg/runner/shell_unix.go
similarity index 85%
rename from pkg/command/platform_unix.go
rename to pkg/runner/shell_unix.go
index f58e048..16c3377 100644
--- a/pkg/command/platform_unix.go
+++ b/pkg/runner/shell_unix.go
@@ -1,6 +1,6 @@
//go:build !windows
-package command
+package runner
import (
"strings"
@@ -23,7 +23,7 @@ func getShellCommandArgs(shell string, command string) (string, []string) {
return shell, []string{"-c", command}
}
-// shouldUseUnixTimeoutCommand returns whether to use the Unix-style timeout command
-func shouldUseUnixTimeoutCommand() bool {
+// ShouldUseUnixTimeoutCommand returns whether to use the Unix-style timeout command
+func ShouldUseUnixTimeoutCommand() bool {
return common.CheckExecutableExists("timeout")
}
diff --git a/pkg/command/platform_windows.go b/pkg/runner/shell_windows.go
similarity index 90%
rename from pkg/command/platform_windows.go
rename to pkg/runner/shell_windows.go
index eb0dfc0..a1dfd29 100644
--- a/pkg/command/platform_windows.go
+++ b/pkg/runner/shell_windows.go
@@ -1,6 +1,6 @@
//go:build windows
-package command
+package runner
import (
"runtime"
@@ -30,8 +30,8 @@ func getShellCommandArgs(shell string, command string) (string, []string) {
return shell, []string{"-c", command}
}
-// shouldUseUnixTimeoutCommand returns whether to use the Unix-style timeout command
-func shouldUseUnixTimeoutCommand() bool {
+// ShouldUseUnixTimeoutCommand returns whether to use the Unix-style timeout command
+func ShouldUseUnixTimeoutCommand() bool {
// On Windows, we don't use Unix-style timeout command even if a 'timeout' command exists
// because Windows 'timeout' is for pausing, not for limiting execution time
return false
diff --git a/pkg/runner/util.go b/pkg/runner/util.go
new file mode 100644
index 0000000..444fca7
--- /dev/null
+++ b/pkg/runner/util.go
@@ -0,0 +1,42 @@
+package runner
+
+import (
+ "os"
+ "strings"
+
+ "github.com/inercia/MCPShell/pkg/common"
+)
+
+// isSingleExecutableCommand checks if the command string is a single word (no spaces or shell metacharacters)
+// and if that word is an existing executable (absolute/relative path or in PATH).
+func isSingleExecutableCommand(command string) bool {
+ cmd := strings.TrimSpace(command)
+ if cmd == "" {
+ return false
+ }
+ // Disallow spaces, shell metacharacters, and redirections
+ if strings.ContainsAny(cmd, " \t|&;<>(){}[]$`'\"\n") {
+ return false
+ }
+ // If it's an absolute or relative path
+ if strings.HasPrefix(cmd, "/") || strings.HasPrefix(cmd, ".") {
+ info, err := os.Stat(cmd)
+ if err != nil {
+ return false
+ }
+ mode := info.Mode()
+ return !info.IsDir() && mode&0111 != 0 // executable by someone
+ }
+ // Otherwise, check if it's in PATH
+ return common.CheckExecutableExists(cmd)
+}
+
+// contains checks if a string slice contains a specific string
+func contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
From a04b7997adb79770481a82d53b23d40242c99008 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:18 +0100
Subject: [PATCH 02/22] refactor: remove agent functionality
---
cmd/agent.go | 362 ----------------------
cmd/agent_config.go | 310 -------------------
cmd/agent_info.go | 426 --------------------------
cmd/root.go | 8 -
pkg/agent/agent.go | 312 -------------------
pkg/agent/agent_test.go | 277 -----------------
pkg/agent/cagent_mcp_tool.go | 115 -------
pkg/agent/cagent_runtime.go | 200 ------------
pkg/agent/config_file.go | 184 ------------
pkg/agent/config_file_test.go | 258 ----------------
pkg/agent/config_sample.yaml | 33 --
pkg/agent/model.go | 195 ------------
pkg/agent/model_test.go | 484 ------------------------------
pkg/agent/prompts/orchestrator.md | 57 ----
tests/agent/test_agent.sh | 224 --------------
tests/agent/test_agent_config.sh | 100 ------
tests/agent/test_agent_info.sh | 183 -----------
tests/agent/test_agent_stdin.sh | 62 ----
tests/agent/tools/test_agent.yaml | 60 ----
tests/run_tests.sh | 3 -
20 files changed, 3853 deletions(-)
delete mode 100644 cmd/agent.go
delete mode 100644 cmd/agent_config.go
delete mode 100644 cmd/agent_info.go
delete mode 100644 pkg/agent/agent.go
delete mode 100644 pkg/agent/agent_test.go
delete mode 100644 pkg/agent/cagent_mcp_tool.go
delete mode 100644 pkg/agent/cagent_runtime.go
delete mode 100644 pkg/agent/config_file.go
delete mode 100644 pkg/agent/config_file_test.go
delete mode 100644 pkg/agent/config_sample.yaml
delete mode 100644 pkg/agent/model.go
delete mode 100644 pkg/agent/model_test.go
delete mode 100644 pkg/agent/prompts/orchestrator.md
delete mode 100755 tests/agent/test_agent.sh
delete mode 100755 tests/agent/test_agent_config.sh
delete mode 100755 tests/agent/test_agent_info.sh
delete mode 100755 tests/agent/test_agent_stdin.sh
delete mode 100644 tests/agent/tools/test_agent.yaml
diff --git a/cmd/agent.go b/cmd/agent.go
deleted file mode 100644
index 0480262..0000000
--- a/cmd/agent.go
+++ /dev/null
@@ -1,362 +0,0 @@
-package root
-
-import (
- "bufio"
- "context"
- "fmt"
- "io"
- "os"
- "os/signal"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "github.com/fatih/color"
- "github.com/spf13/cobra"
-
- "github.com/inercia/MCPShell/pkg/agent"
- "github.com/inercia/MCPShell/pkg/common"
- toolsConfig "github.com/inercia/MCPShell/pkg/config"
-)
-
-// Cache the agent configuration to avoid duplicate resolution
-var cachedAgentConfig agent.AgentConfig
-
-// processArgsWithStdin processes positional arguments and replaces "-" with STDIN content
-// Returns the processed prompt and a boolean indicating if STDIN was used
-func processArgsWithStdin(args []string) (string, bool, error) {
- if len(args) == 0 {
- return "", false, nil
- }
-
- // Check if any argument is "-" (STDIN placeholder)
- hasStdin := false
- for _, arg := range args {
- if arg == "-" {
- hasStdin = true
- break
- }
- }
-
- // If no STDIN placeholder, just join the arguments
- if !hasStdin {
- return strings.Join(args, " "), false, nil
- }
-
- // Read STDIN content
- stdinContent, err := io.ReadAll(os.Stdin)
- if err != nil {
- return "", false, fmt.Errorf("failed to read STDIN: %w", err)
- }
-
- // Replace "-" with STDIN content in the arguments
- processedArgs := make([]string, 0, len(args))
- for _, arg := range args {
- if arg == "-" {
- processedArgs = append(processedArgs, string(stdinContent))
- } else {
- processedArgs = append(processedArgs, arg)
- }
- }
-
- return strings.Join(processedArgs, " "), true, nil
-}
-
-// buildAgentConfig creates an AgentConfig by merging command-line flags with configuration file
-func buildAgentConfig() (agent.AgentConfig, error) {
- // Load configuration from file
- config, err := agent.GetConfig()
- if err != nil {
- return agent.AgentConfig{}, fmt.Errorf("failed to load config: %w", err)
- }
-
- // Start with default model from config file
- var modelConfig agent.ModelConfig
- if defaultModel := config.GetDefaultModel(); defaultModel != nil {
- modelConfig = *defaultModel
- }
-
- logger := common.GetLogger()
-
- // If --model flag not provided, check for environment variable
- if agentModel == "" {
- if envModel := os.Getenv("MCPSHELL_AGENT_MODEL"); envModel != "" {
- agentModel = envModel
- logger.Debug("Using model from MCPSHELL_AGENT_MODEL environment variable: %s", agentModel)
- }
- }
-
- // Override with command-line flags if provided
- if agentModel != "" {
- logger.Debug("Looking for model '%s' in agent config", agentModel)
-
- // Check if the specified model exists in config
- if configModel := config.GetModelByName(agentModel); configModel != nil {
- modelConfig = *configModel
- logger.Info("Found model '%s' in config: model=%s, class=%s, name=%s",
- agentModel, configModel.Model, configModel.Class, configModel.Name)
- } else {
- // Use command-line model name if not found in config
- logger.Info("Model '%s' not found in config, using as direct model name", agentModel)
- modelConfig.Model = agentModel
- }
- }
-
- // Merge system prompts from config file and command-line
- if agentSystemPrompt != "" {
- // Join system prompts from config with command-line system prompt
- var allSystemPrompts []string
-
- // Add existing system prompts from config
- if modelConfig.Prompts.HasSystemPrompts() {
- allSystemPrompts = append(allSystemPrompts, modelConfig.Prompts.System...)
- }
-
- // Add command-line system prompt
- allSystemPrompts = append(allSystemPrompts, agentSystemPrompt)
-
- // Update the model config with merged prompts
- modelConfig.Prompts.System = allSystemPrompts
- }
-
- // Override API key and URL if provided
- if agentOpenAIApiKey != "" {
- modelConfig.APIKey = agentOpenAIApiKey
- }
- if agentOpenAIApiURL != "" {
- modelConfig.APIURL = agentOpenAIApiURL
- }
-
- // Handle environment variable substitution for API key
- if strings.HasPrefix(modelConfig.APIKey, "${") && strings.HasSuffix(modelConfig.APIKey, "}") {
- envVar := strings.TrimSuffix(strings.TrimPrefix(modelConfig.APIKey, "${"), "}")
- modelConfig.APIKey = os.Getenv(envVar)
- logger.Debug("Substituted API key from environment variable: %s", envVar)
- }
-
- // Handle environment variable substitution for API URL
- if strings.HasPrefix(modelConfig.APIURL, "${") && strings.HasSuffix(modelConfig.APIURL, "}") {
- envVar := strings.TrimSuffix(strings.TrimPrefix(modelConfig.APIURL, "${"), "}")
- modelConfig.APIURL = os.Getenv(envVar)
- logger.Debug("Substituted API URL from environment variable: %s = %s", envVar, modelConfig.APIURL)
- }
-
- // Resolve multiple config files into a single merged config file
- if len(toolsFiles) == 0 {
- return agent.AgentConfig{}, fmt.Errorf("tools configuration file(s) are required")
- }
-
- localConfigPath, _, err := toolsConfig.ResolveMultipleConfigPaths(toolsFiles, logger)
- if err != nil {
- return agent.AgentConfig{}, fmt.Errorf("failed to resolve config paths: %w", err)
- }
-
- return agent.AgentConfig{
- ToolsFile: localConfigPath,
- UserPrompt: agentUserPrompt,
- Once: agentOnce,
- Version: version,
- ModelConfig: modelConfig,
- }, nil
-}
-
-// agentCommand is a command that executes the MCPShell as an agent
-var agentCommand = &cobra.Command{
- Use: "agent",
- Short: "Execute the MCPShell as an agent",
- Long: `
-
-The agent command will execute the MCPShell as an agent, connecting to a remote LLM.
-
-Configuration is loaded from ~/.mcpshell/agent.yaml and can be overridden with command-line flags.
-The configuration file should contain model definitions with their API keys and prompts.
-
-For example, you can do:
-
-$ mcpshell agent --tools=examples/config.yaml \
- --model "gpt-4o" \
- --system-prompt "You are a helpful assistant that debugs performance issues" \
- --user-prompt "I am having trouble with my computer. It is slow and I think it is due to the CPU usage."
-
-If a model is configured as default in the agent configuration file, you can omit the --model flag:
-
-You can provide initial user prompt as positional arguments:
-
-$ mcpshell agent I am having trouble with my computer. It is slow and I think it is due to the CPU usage.
-
-You can also use STDIN as part of the prompt by using '-' to represent it:
-
-$ cat failure.log | mcpshell agent --tools kubectl-ro.yaml \
- "I'm seeing this error in the Kubernetes logs" - "Please help me to debug this problem."
-
-When STDIN is used, the agent automatically runs in --once mode since STDIN is no longer available for interactive input.
-
-The agent will try to debug the issue with the given tools.
-`,
- Args: cobra.ArbitraryArgs,
- PreRunE: func(cmd *cobra.Command, args []string) error {
- // If --user-prompt is not provided but positional args exist, process them (including STDIN if "-" is present)
- if agentUserPrompt == "" && len(args) > 0 {
- processedPrompt, usedStdin, err := processArgsWithStdin(args)
- if err != nil {
- return fmt.Errorf("failed to process arguments: %w", err)
- }
- agentUserPrompt = processedPrompt
-
- // If STDIN was used, automatically enable --once mode since STDIN is no longer available for interactive input
- if usedStdin && !agentOnce {
- agentOnce = true
- }
- }
-
- // Initialize logger
- logger, err := initLogger()
- if err != nil {
- return err
- }
-
- // Build agent configuration (this will be cached for RunE)
- cachedAgentConfig, err = buildAgentConfig()
- if err != nil {
- return err
- }
-
- // Validate agent configuration
- agentInstance := agent.New(cachedAgentConfig, logger)
- if err := agentInstance.Validate(); err != nil {
- return err
- }
-
- return nil
- },
- RunE: func(cmd *cobra.Command, args []string) error {
- // Use the agentUserPrompt that was already set in PreRunE
- // No need to process args again since PreRunE already handled STDIN if needed
-
- // Initialize logger
- logger, err := initLogger()
- if err != nil {
- return err
- }
-
- // Use cached agent configuration (built in PreRunE)
- agentConfig := cachedAgentConfig
-
- // Create agent instance
- agentInstance := agent.New(agentConfig, logger)
-
- // Create channels for user input and agent output
- userInput := make(chan string)
- agentOutput := make(chan string)
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Setup signal handling for graceful shutdown
- signalChan := make(chan os.Signal, 1)
- signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
-
- var wg sync.WaitGroup
- wg.Add(1)
- go func() {
- defer wg.Done()
- select {
- case <-signalChan:
- logger.Info("Received interrupt signal, shutting down...")
- cancel()
- case <-ctx.Done():
- }
- }()
-
- // Start a goroutine to read user input only when not in --once mode
- if !agentConfig.Once {
- wg.Add(1)
- go func() {
- defer wg.Done()
- defer close(userInput) // Always close userInput when this goroutine exits
-
- scanner := bufio.NewScanner(os.Stdin)
- inputChan := make(chan string)
-
- // Start a separate goroutine to read from stdin
- go func() {
- for scanner.Scan() {
- inputChan <- scanner.Text()
- }
- close(inputChan)
- }()
-
- for {
- select {
- case <-ctx.Done():
- return
- case input, ok := <-inputChan:
- if !ok {
- return
- }
- select {
- case userInput <- input:
- case <-ctx.Done():
- return
- }
- }
- }
- }()
- }
-
- // Start the agent
- wg.Add(1)
- go func() {
- defer wg.Done()
- if err := agentInstance.Run(ctx, userInput, agentOutput); err != nil {
- // Don't log context cancellation as an error - it's an expected exit condition
- if err != context.Canceled && err != context.DeadlineExceeded {
- logger.Error(color.HiRedString("Agent encountered an error: %v", err))
- }
- // Cancel context to abort all goroutines on fatal errors
- cancel()
- }
- }()
-
- // Print agent output (using Print not Println to respect formatting from event handler)
- for output := range agentOutput {
- fmt.Print(output)
- }
-
- // Wait for all goroutines with a timeout to prevent hanging
- done := make(chan struct{})
- go func() {
- wg.Wait()
- close(done)
- }()
-
- select {
- case <-done:
- // All goroutines finished normally
- logger.Debug("All goroutines completed successfully")
- case <-time.After(5 * time.Second):
- // Force exit after timeout (agent already completed, this is just cleanup)
- logger.Debug("Cleanup timeout reached, forcing shutdown (agent task already completed)")
- }
-
- return nil
- },
-}
-
-// init adds the agent command to the root command
-func init() {
- // Add agent command to root
- rootCmd.AddCommand(agentCommand)
-
- // Add agent-specific flags as persistent flags so subcommands can use them
- agentCommand.PersistentFlags().StringVarP(&agentModel, "model", "m", "", "LLM model to use (can also set MCPSHELL_AGENT_MODEL env var)")
- agentCommand.PersistentFlags().StringVarP(&agentSystemPrompt, "system-prompt", "s", "", "System prompt for the LLM (optional, uses model-specific defaults if not provided)")
- agentCommand.PersistentFlags().StringVarP(&agentUserPrompt, "user-prompt", "u", "", "Initial user prompt for the LLM")
- agentCommand.PersistentFlags().StringVarP(&agentOpenAIApiKey, "openai-api-key", "k", "", "OpenAI API key (or set OPENAI_API_KEY environment variable)")
- agentCommand.PersistentFlags().StringVarP(&agentOpenAIApiURL, "openai-api-url", "b", "", "Base URL for the OpenAI API (optional)")
- agentCommand.PersistentFlags().BoolVarP(&agentOnce, "once", "o", false, "Exit after receiving a final response from the LLM (one-shot mode)")
-
- // Add config subcommand
- agentCommand.AddCommand(agentConfigCommand)
-}
diff --git a/cmd/agent_config.go b/cmd/agent_config.go
deleted file mode 100644
index 711d567..0000000
--- a/cmd/agent_config.go
+++ /dev/null
@@ -1,310 +0,0 @@
-package root
-
-import (
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
-
- "github.com/spf13/cobra"
-
- "github.com/inercia/MCPShell/pkg/agent"
- "github.com/inercia/MCPShell/pkg/utils"
-)
-
-var (
- agentConfigShowJSON bool
-)
-
-// agentConfigCommand is the parent command for agent configuration subcommands
-var agentConfigCommand = &cobra.Command{
- Use: "config",
- Short: "Manage agent configuration",
- Long: `
-
-The config command provides subcommands to manage agent configuration files.
-
-Available subcommands:
-- create: Create a default agent configuration file
-- show: Display the current agent configuration
-`,
-}
-
-// agentConfigCreateCommand creates a default agent configuration file
-var agentConfigCreateCommand = &cobra.Command{
- Use: "create",
- Short: "Create a default agent configuration file",
- Long: `
-
-Creates a default agent configuration file at ~/.mcpshell/agent.yaml.
-
-If the file already exists, it will be overwritten with the default configuration.
-The default configuration includes sample models and prompts that you can customize.
-
-Example:
-$ mcpshell agent config create
-`,
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- logger, err := initLogger()
- if err != nil {
- return err
- }
-
- // Create the default config file
- if err := agent.CreateDefaultConfigForce(); err != nil {
- logger.Error("Failed to create default config: %v", err)
- return fmt.Errorf("failed to create default config: %w", err)
- }
-
- mcpShellHome, err := utils.GetMCPShellHome()
- if err != nil {
- return fmt.Errorf("failed to get MCPShell home directory: %w", err)
- }
-
- configPath := filepath.Join(mcpShellHome, "agent.yaml")
- fmt.Printf("Default agent configuration created at: %s\n", configPath)
- fmt.Println("You can now edit this file to customize your agent settings.")
-
- return nil
- },
-}
-
-// ConfigShowOutput holds the JSON output structure for config show
-type ConfigShowOutput struct {
- ConfigurationFile string `json:"configuration_file"`
- Models []ConfigShowModelInfo `json:"models"`
- DefaultModel *ConfigShowModelInfo `json:"default_model,omitempty"`
- Orchestrator *ConfigShowModelInfo `json:"orchestrator,omitempty"`
- ToolRunner *ConfigShowModelInfo `json:"tool_runner,omitempty"`
-}
-
-// ConfigShowModelInfo holds model info for JSON output
-type ConfigShowModelInfo struct {
- Name string `json:"name"`
- Model string `json:"model"`
- Class string `json:"class"`
- Default bool `json:"default"`
- APIKey string `json:"api_key_masked,omitempty"`
- APIURL string `json:"api_url,omitempty"`
- SystemPrompts []string `json:"system_prompts,omitempty"`
-}
-
-// agentConfigShowCommand displays the current agent configuration
-var agentConfigShowCommand = &cobra.Command{
- Use: "show",
- Short: "Display the current agent configuration",
- Long: `
-
-Displays the current agent configuration in a pretty-printed format.
-
-The configuration is loaded from ~/.mcpshell/agent.yaml and parsed to show
-the available models, their settings, and which model is set as default.
-
-Use --json flag to output in JSON format for easy parsing by other tools.
-
-Examples:
-$ mcpshell agent config show
-$ mcpshell agent config show --json
-`,
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- logger, err := initLogger()
- if err != nil {
- return err
- }
-
- // Get the config file path
- mcpShellHome, err := utils.GetMCPShellHome()
- if err != nil {
- return fmt.Errorf("failed to get MCPShell home directory: %w", err)
- }
- configPath := filepath.Join(mcpShellHome, "agent.yaml")
-
- // Load the current configuration
- config, err := agent.GetConfig()
- if err != nil {
- logger.Error("Failed to load config: %v", err)
- return fmt.Errorf("failed to load config: %w", err)
- }
-
- // Check if config is empty
- if len(config.Agent.Models) == 0 {
- if agentConfigShowJSON {
- output := ConfigShowOutput{
- ConfigurationFile: configPath,
- Models: []ConfigShowModelInfo{},
- }
- encoder := json.NewEncoder(os.Stdout)
- encoder.SetIndent("", " ")
- return encoder.Encode(output)
- }
-
- fmt.Printf("Configuration file: %s\n", configPath)
- fmt.Println()
- fmt.Println("No agent configuration found.")
- fmt.Println("Run 'mcpshell agent config create' to create a default configuration.")
- return nil
- }
-
- // Output in JSON format if requested
- if agentConfigShowJSON {
- return outputConfigShowJSON(configPath, config)
- }
-
- // Pretty print the configuration
- fmt.Printf("Configuration file: %s\n", configPath)
- fmt.Println()
- fmt.Println("Agent Configuration:")
- fmt.Println("===================")
- fmt.Println()
-
- for i, model := range config.Agent.Models {
- fmt.Printf("Model %d:\n", i+1)
- fmt.Printf(" Name: %s\n", model.Name)
- fmt.Printf(" Model: %s\n", model.Model)
- fmt.Printf(" Class: %s\n", model.Class)
- fmt.Printf(" Default: %t\n", model.Default)
-
- if model.APIKey != "" {
- if model.APIKey == "${OPENAI_API_KEY}" {
- fmt.Printf(" API Key: %s (from environment)\n", model.APIKey)
- } else {
- fmt.Printf(" API Key: %s\n", maskAPIKey(model.APIKey))
- }
- }
-
- if model.APIURL != "" {
- fmt.Printf(" API URL: %s\n", model.APIURL)
- }
-
- // Display prompts information
- if model.Prompts.HasSystemPrompts() {
- systemPrompts := model.Prompts.GetSystemPrompts()
- fmt.Printf(" System Prompts: %s\n", truncateString(systemPrompts, 80))
- }
-
- fmt.Println()
- }
-
- // Show which model is default
- defaultModel := config.GetDefaultModel()
- if defaultModel != nil {
- fmt.Printf("Default Model: %s (%s)\n", defaultModel.Name, defaultModel.Model)
- } else {
- fmt.Println("No default model configured.")
- }
-
- return nil
- },
-}
-
-// outputConfigShowJSON outputs the configuration in JSON format
-func outputConfigShowJSON(configPath string, config *agent.Config) error {
- output := ConfigShowOutput{
- ConfigurationFile: configPath,
- Models: make([]ConfigShowModelInfo, 0, len(config.Agent.Models)),
- }
-
- // Add all models
- for _, model := range config.Agent.Models {
- modelInfo := ConfigShowModelInfo{
- Name: model.Name,
- Model: model.Model,
- Class: model.Class,
- Default: model.Default,
- APIURL: model.APIURL,
- }
-
- if model.APIKey != "" {
- modelInfo.APIKey = maskAPIKey(model.APIKey)
- }
-
- if model.Prompts.HasSystemPrompts() {
- modelInfo.SystemPrompts = model.Prompts.System
- }
-
- output.Models = append(output.Models, modelInfo)
- }
-
- // Add default model
- if defaultModel := config.GetDefaultModel(); defaultModel != nil {
- modelInfo := ConfigShowModelInfo{
- Name: defaultModel.Name,
- Model: defaultModel.Model,
- Class: defaultModel.Class,
- Default: defaultModel.Default,
- APIURL: defaultModel.APIURL,
- }
- if defaultModel.APIKey != "" {
- modelInfo.APIKey = maskAPIKey(defaultModel.APIKey)
- }
- if defaultModel.Prompts.HasSystemPrompts() {
- modelInfo.SystemPrompts = defaultModel.Prompts.System
- }
- output.DefaultModel = &modelInfo
- }
-
- // Add orchestrator model if defined
- if orchestrator := config.GetOrchestratorModel(); orchestrator != nil {
- modelInfo := ConfigShowModelInfo{
- Name: orchestrator.Name,
- Model: orchestrator.Model,
- Class: orchestrator.Class,
- APIURL: orchestrator.APIURL,
- }
- if orchestrator.APIKey != "" {
- modelInfo.APIKey = maskAPIKey(orchestrator.APIKey)
- }
- if orchestrator.Prompts.HasSystemPrompts() {
- modelInfo.SystemPrompts = orchestrator.Prompts.System
- }
- output.Orchestrator = &modelInfo
- }
-
- // Add tool runner model if defined
- if toolRunner := config.GetToolRunnerModel(); toolRunner != nil {
- modelInfo := ConfigShowModelInfo{
- Name: toolRunner.Name,
- Model: toolRunner.Model,
- Class: toolRunner.Class,
- APIURL: toolRunner.APIURL,
- }
- if toolRunner.APIKey != "" {
- modelInfo.APIKey = maskAPIKey(toolRunner.APIKey)
- }
- if toolRunner.Prompts.HasSystemPrompts() {
- modelInfo.SystemPrompts = toolRunner.Prompts.System
- }
- output.ToolRunner = &modelInfo
- }
-
- encoder := json.NewEncoder(os.Stdout)
- encoder.SetIndent("", " ")
- return encoder.Encode(output)
-}
-
-// Helper function to mask API keys for security
-func maskAPIKey(key string) string {
- if len(key) <= 8 {
- return "****"
- }
- return key[:4] + "****" + key[len(key)-4:]
-}
-
-// Helper function to truncate long strings
-func truncateString(s string, maxLen int) string {
- if len(s) <= maxLen {
- return s
- }
- return s[:maxLen-3] + "..."
-}
-
-func init() {
- // Add create and show subcommands to agent config
- agentConfigCommand.AddCommand(agentConfigCreateCommand)
- agentConfigCommand.AddCommand(agentConfigShowCommand)
-
- // Add flags to show command
- agentConfigShowCommand.Flags().BoolVar(&agentConfigShowJSON, "json", false, "Output in JSON format")
-}
diff --git a/cmd/agent_info.go b/cmd/agent_info.go
deleted file mode 100644
index b5164dc..0000000
--- a/cmd/agent_info.go
+++ /dev/null
@@ -1,426 +0,0 @@
-package root
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/fatih/color"
- "github.com/sashabaranov/go-openai"
- "github.com/spf13/cobra"
-
- "github.com/inercia/MCPShell/pkg/agent"
- "github.com/inercia/MCPShell/pkg/common"
- toolsConfig "github.com/inercia/MCPShell/pkg/config"
- "github.com/inercia/MCPShell/pkg/utils"
-)
-
-var (
- agentInfoJSON bool
- agentInfoIncludePrompts bool
- agentInfoCheck bool
-)
-
-// agentInfoCommand displays information about the agent configuration
-var agentInfoCommand = &cobra.Command{
- Use: "info",
- Short: "Display agent configuration information",
- Long: `
-Display information about the agent configuration including:
-- LLM model details
-- API configuration
-- System prompts (with --include-prompts)
-- LLM connectivity status (with --check)
-
-The configuration is loaded from ~/.mcpshell/agent.yaml and merged with
-command-line flags (if provided).
-
-The --tools flag is optional for this command. It's only needed if you want
-to verify the full agent configuration including tools setup.
-
-Examples:
-$ mcpshell agent info
-$ mcpshell agent info --json
-$ mcpshell agent info --include-prompts
-$ mcpshell agent info --check
-$ mcpshell agent info --model gpt-4o --json
-$ mcpshell agent info --tools examples/config.yaml
-`,
- Args: cobra.NoArgs,
- PreRunE: func(cmd *cobra.Command, args []string) error {
- // Initialize logger
- logger, err := initLogger()
- if err != nil {
- return err
- }
-
- // Tools are optional for agent info - we only need them for actual agent execution
- logger.Debug("Agent info command initialized")
- return nil
- },
- RunE: func(cmd *cobra.Command, args []string) error {
- logger, err := initLogger()
- if err != nil {
- return err
- }
-
- // Build agent configuration (tools are optional for info command)
- agentConfig, err := buildAgentConfigForInfo()
- if err != nil {
- return fmt.Errorf("failed to build agent config: %w", err)
- }
-
- // Use the model config that was built - it already has the correct model
- // based on: --model flag > MCPSHELL_AGENT_MODEL env var > default from config
- orchestratorConfig := agentConfig.ModelConfig
- toolRunnerConfig := agentConfig.ModelConfig
-
- // Check LLM connectivity if requested
- var checkResult *CheckResult
- if agentInfoCheck {
- checkResult = checkLLMConnectivity(orchestratorConfig, logger)
- }
-
- // Output in JSON format if requested
- if agentInfoJSON {
- err := outputJSON(agentConfig, orchestratorConfig, toolRunnerConfig, checkResult)
- if err != nil {
- return err
- }
- // If check was performed and failed, exit with error
- if checkResult != nil && !checkResult.Success {
- return fmt.Errorf("LLM connectivity check failed: %s", checkResult.Error)
- }
- return nil
- }
-
- // Output in human-readable format
- return outputHumanReadable(agentConfig, orchestratorConfig, toolRunnerConfig, checkResult)
- },
-}
-
-// CheckResult holds the result of an LLM connectivity check
-type CheckResult struct {
- Success bool `json:"success"`
- ResponseTime float64 `json:"response_time_ms"`
- Error string `json:"error,omitempty"`
- Model string `json:"model"`
-}
-
-// InfoOutput holds the complete info output structure for JSON
-type InfoOutput struct {
- ConfigFile string `json:"config_file,omitempty"`
- ToolsFile string `json:"tools_file,omitempty"`
- Once bool `json:"once_mode"`
- Orchestrator ModelInfo `json:"orchestrator"`
- ToolRunner ModelInfo `json:"tool_runner"`
- Check *CheckResult `json:"check,omitempty"`
- Prompts *PromptsInfo `json:"prompts,omitempty"`
-}
-
-// ModelInfo holds model configuration details for JSON output
-type ModelInfo struct {
- Model string `json:"model"`
- Class string `json:"class"`
- Name string `json:"name,omitempty"`
- APIURL string `json:"api_url,omitempty"`
- APIKey string `json:"api_key_masked,omitempty"`
-}
-
-// PromptsInfo holds prompt information for JSON output
-type PromptsInfo struct {
- System []string `json:"system,omitempty"`
- User string `json:"user,omitempty"`
-}
-
-// buildAgentConfigForInfo creates an AgentConfig for the info command
-// Unlike buildAgentConfig, this doesn't require tools files
-func buildAgentConfigForInfo() (agent.AgentConfig, error) {
- // Load configuration from file
- config, err := agent.GetConfig()
- if err != nil {
- return agent.AgentConfig{}, fmt.Errorf("failed to load config: %w", err)
- }
-
- // Start with default model from config file
- var modelConfig agent.ModelConfig
- if defaultModel := config.GetDefaultModel(); defaultModel != nil {
- modelConfig = *defaultModel
- }
-
- logger := common.GetLogger()
-
- // If --model flag not provided, check for environment variable
- if agentModel == "" {
- if envModel := os.Getenv("MCPSHELL_AGENT_MODEL"); envModel != "" {
- agentModel = envModel
- logger.Debug("Using model from MCPSHELL_AGENT_MODEL environment variable: %s", agentModel)
- }
- }
-
- // Override with command-line flags if provided
- if agentModel != "" {
- logger.Debug("Looking for model '%s' in agent config", agentModel)
-
- // Check if the specified model exists in config
- if configModel := config.GetModelByName(agentModel); configModel != nil {
- modelConfig = *configModel
- logger.Info("Found model '%s' in config: model=%s, class=%s, name=%s",
- agentModel, configModel.Model, configModel.Class, configModel.Name)
- } else {
- // Use command-line model name if not found in config
- logger.Info("Model '%s' not found in config, using as direct model name", agentModel)
- modelConfig.Model = agentModel
- }
- }
-
- // Merge system prompts from config file and command-line
- if agentSystemPrompt != "" {
- // Join system prompts from config with command-line system prompt
- var allSystemPrompts []string
-
- // Add existing system prompts from config
- if modelConfig.Prompts.HasSystemPrompts() {
- allSystemPrompts = append(allSystemPrompts, modelConfig.Prompts.System...)
- }
-
- // Add command-line system prompt
- allSystemPrompts = append(allSystemPrompts, agentSystemPrompt)
-
- // Update the model config with merged prompts
- modelConfig.Prompts.System = allSystemPrompts
- }
-
- // Override API key and URL if provided
- if agentOpenAIApiKey != "" {
- modelConfig.APIKey = agentOpenAIApiKey
- }
- if agentOpenAIApiURL != "" {
- modelConfig.APIURL = agentOpenAIApiURL
- }
-
- // Handle environment variable substitution for API key
- if strings.HasPrefix(modelConfig.APIKey, "${") && strings.HasSuffix(modelConfig.APIKey, "}") {
- envVar := strings.TrimSuffix(strings.TrimPrefix(modelConfig.APIKey, "${"), "}")
- modelConfig.APIKey = os.Getenv(envVar)
- logger.Debug("Substituted API key from environment variable: %s", envVar)
- }
-
- // Handle environment variable substitution for API URL
- if strings.HasPrefix(modelConfig.APIURL, "${") && strings.HasSuffix(modelConfig.APIURL, "}") {
- envVar := strings.TrimSuffix(strings.TrimPrefix(modelConfig.APIURL, "${"), "}")
- modelConfig.APIURL = os.Getenv(envVar)
- logger.Debug("Substituted API URL from environment variable: %s = %s", envVar, modelConfig.APIURL)
- }
-
- // Tools file is optional for info command
- toolsFile := ""
- if len(toolsFiles) > 0 {
- // Resolve tools configuration if provided
- localConfigPath, _, err := toolsConfig.ResolveMultipleConfigPaths(toolsFiles, logger)
- if err != nil {
- return agent.AgentConfig{}, fmt.Errorf("failed to resolve config paths: %w", err)
- }
- toolsFile = localConfigPath
- }
-
- return agent.AgentConfig{
- ToolsFile: toolsFile,
- UserPrompt: agentUserPrompt,
- Once: agentOnce,
- Version: version,
- ModelConfig: modelConfig,
- }, nil
-}
-
-// checkLLMConnectivity tests if the LLM is responding
-func checkLLMConnectivity(modelConfig agent.ModelConfig, logger *common.Logger) *CheckResult {
- result := &CheckResult{
- Model: modelConfig.Model,
- }
-
- logger.Info("Testing LLM connectivity for model: %s", modelConfig.Model)
-
- // Initialize the model client
- client, err := agent.InitializeModelClient(modelConfig, logger)
- if err != nil {
- result.Success = false
- result.Error = fmt.Sprintf("Failed to initialize client: %v", err)
- return result
- }
-
- // Make a simple test request
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
-
- startTime := time.Now()
-
- req := openai.ChatCompletionRequest{
- Model: modelConfig.Model,
- Messages: []openai.ChatCompletionMessage{
- {
- Role: openai.ChatMessageRoleUser,
- Content: "Respond with just the word 'OK'",
- },
- },
- MaxTokens: 10,
- }
-
- _, err = client.CreateChatCompletion(ctx, req)
- elapsed := time.Since(startTime)
-
- if err != nil {
- result.Success = false
- result.Error = fmt.Sprintf("LLM request failed: %v", err)
- logger.Error("LLM connectivity check failed: %v", err)
- return result
- }
-
- result.Success = true
- result.ResponseTime = float64(elapsed.Milliseconds())
- logger.Info("LLM connectivity check successful (%.0fms)", result.ResponseTime)
-
- return result
-}
-
-// outputJSON outputs the configuration in JSON format
-func outputJSON(agentConfig agent.AgentConfig, orchestrator, toolRunner agent.ModelConfig, check *CheckResult) error {
- // Get agent config file path
- var configFile string
- if mcpShellHome, err := utils.GetMCPShellHome(); err == nil {
- configFile = filepath.Join(mcpShellHome, "agent.yaml")
- }
-
- output := InfoOutput{
- ConfigFile: configFile,
- ToolsFile: agentConfig.ToolsFile,
- Once: agentConfig.Once,
- Orchestrator: ModelInfo{
- Model: orchestrator.Model,
- Class: orchestrator.Class,
- Name: orchestrator.Name,
- APIURL: orchestrator.APIURL,
- APIKey: maskAPIKey(orchestrator.APIKey),
- },
- ToolRunner: ModelInfo{
- Model: toolRunner.Model,
- Class: toolRunner.Class,
- Name: toolRunner.Name,
- APIURL: toolRunner.APIURL,
- APIKey: maskAPIKey(toolRunner.APIKey),
- },
- Check: check,
- }
-
- // Include prompts if requested
- if agentInfoIncludePrompts {
- output.Prompts = &PromptsInfo{
- System: orchestrator.Prompts.System,
- User: agentConfig.UserPrompt,
- }
- }
-
- encoder := json.NewEncoder(os.Stdout)
- encoder.SetIndent("", " ")
- return encoder.Encode(output)
-}
-
-// outputHumanReadable outputs the configuration in human-readable format
-func outputHumanReadable(agentConfig agent.AgentConfig, orchestrator, toolRunner agent.ModelConfig, check *CheckResult) error {
- fmt.Println(color.HiCyanString("Agent Configuration"))
- fmt.Println(strings.Repeat("=", 50))
- fmt.Println()
-
- // Show agent config file location
- mcpShellHome, err := utils.GetMCPShellHome()
- if err == nil {
- agentConfigPath := filepath.Join(mcpShellHome, "agent.yaml")
- fmt.Printf("Config File: %s\n", agentConfigPath)
- }
-
- // General settings
- if agentConfig.ToolsFile != "" {
- fmt.Printf("Tools File: %s\n", agentConfig.ToolsFile)
- }
- fmt.Printf("Once Mode: %t\n", agentConfig.Once)
- fmt.Println()
-
- // Orchestrator model
- fmt.Println(color.HiYellowString("Orchestrator Model:"))
- fmt.Printf(" Model: %s\n", orchestrator.Model)
- if orchestrator.Name != "" {
- fmt.Printf(" Name: %s\n", orchestrator.Name)
- }
- fmt.Printf(" Class: %s\n", orchestrator.Class)
- if orchestrator.APIURL != "" {
- fmt.Printf(" API URL: %s\n", orchestrator.APIURL)
- }
- if orchestrator.APIKey != "" {
- fmt.Printf(" API Key: %s\n", maskAPIKey(orchestrator.APIKey))
- }
- fmt.Println()
-
- // Tool-runner model (only if different from orchestrator)
- if toolRunner.Model != orchestrator.Model || toolRunner.Class != orchestrator.Class {
- fmt.Println(color.HiYellowString("Tool-Runner Model:"))
- fmt.Printf(" Model: %s\n", toolRunner.Model)
- if toolRunner.Name != "" {
- fmt.Printf(" Name: %s\n", toolRunner.Name)
- }
- fmt.Printf(" Class: %s\n", toolRunner.Class)
- if toolRunner.APIURL != "" {
- fmt.Printf(" API URL: %s\n", toolRunner.APIURL)
- }
- if toolRunner.APIKey != "" {
- fmt.Printf(" API Key: %s\n", maskAPIKey(toolRunner.APIKey))
- }
- fmt.Println()
- }
-
- // Prompts (if requested)
- if agentInfoIncludePrompts {
- fmt.Println(color.HiYellowString("Prompts:"))
- if orchestrator.Prompts.HasSystemPrompts() {
- fmt.Println(color.CyanString(" System Prompts:"))
- for i, prompt := range orchestrator.Prompts.System {
- fmt.Printf(" %d. %s\n", i+1, truncateString(prompt, 120))
- }
- } else {
- fmt.Println(" System Prompts: (none)")
- }
- if agentConfig.UserPrompt != "" {
- fmt.Printf(" User Prompt: %s\n", truncateString(agentConfig.UserPrompt, 120))
- }
- fmt.Println()
- }
-
- // Check result (if performed)
- if check != nil {
- fmt.Println(color.HiYellowString("LLM Connectivity Check:"))
- if check.Success {
- fmt.Printf(" Status: %s\n", color.HiGreenString("✓ Connected"))
- fmt.Printf(" Response: %.0fms\n", check.ResponseTime)
- } else {
- fmt.Printf(" Status: %s\n", color.HiRedString("✗ Failed"))
- fmt.Printf(" Error: %s\n", check.Error)
- return fmt.Errorf("LLM connectivity check failed: %s", check.Error)
- }
- fmt.Println()
- }
-
- return nil
-}
-
-func init() {
- // Add info subcommand to agent command
- agentCommand.AddCommand(agentInfoCommand)
-
- // Add info-specific flags
- agentInfoCommand.Flags().BoolVar(&agentInfoJSON, "json", false, "Output in JSON format (for easy parsing)")
- agentInfoCommand.Flags().BoolVar(&agentInfoIncludePrompts, "include-prompts", false, "Include full prompts in the output")
- agentInfoCommand.Flags().BoolVar(&agentInfoCheck, "check", false, "Check LLM connectivity (exits with error if LLM is not responding)")
-}
diff --git a/cmd/root.go b/cmd/root.go
index 1f4870d..86aa143 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -28,14 +28,6 @@ var (
descriptionFile []string
descriptionOverride bool
- // Agent-specific flags
- agentModel string
- agentSystemPrompt string
- agentUserPrompt string
- agentOpenAIApiKey string
- agentOpenAIApiURL string
- agentOnce bool
-
// Application version information (set via SetVersion from main)
version = "dev"
commit = "none"
diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go
deleted file mode 100644
index ce1a07c..0000000
--- a/pkg/agent/agent.go
+++ /dev/null
@@ -1,312 +0,0 @@
-// Package agent provides MCP agent functionality that enables direct interaction
-// between Large Language Models and command-line tools. The agent handles LLM
-// communication, tool execution, and conversation management.
-
-package agent
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "time"
-
- "github.com/docker/cagent/pkg/runtime"
- "github.com/fatih/color"
- "github.com/inercia/MCPShell/pkg/common"
- "github.com/inercia/MCPShell/pkg/server"
-)
-
-// AgentConfig holds the configuration for the agent including tools file location,
-// user prompts, execution mode, and embedded model configuration (API keys, model name, etc.)
-type AgentConfig struct {
- ToolsFile string // Path to the YAML configuration file defining available tools
- UserPrompt string // Initial user prompt to send to the LLM
- Once bool // Whether to run in one-shot mode (exit after first response)
- Version string // Version information for the agent
- ModelConfig // Embedded model configuration (Model, APIKey, APIURL, Prompts)
-}
-
-// Agent represents an MCP agent
-type Agent struct {
- config AgentConfig
- logger *common.Logger
-}
-
-// New creates a new agent instance
-func New(cfg AgentConfig, logger *common.Logger) *Agent {
- return &Agent{
- config: cfg,
- logger: logger,
- }
-}
-
-// Validate checks if the configuration is valid
-func (a *Agent) Validate() error {
- // Check if config file is provided
- if a.config.ToolsFile == "" {
- a.logger.Error("Tools configuration file is required")
- return fmt.Errorf("tools configuration file is required")
- }
-
- // Validate model configuration using the model manager
- if err := ValidateModelConfig(a.config.ModelConfig, a.logger); err != nil {
- a.logger.Error("Model configuration validation failed: %v", err)
- return fmt.Errorf("model configuration validation failed: %w", err)
- }
-
- return nil
-}
-
-// Run executes the agent using cagent multi-agent framework
-func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan string) error {
- // Setup panic handler
- defer common.RecoverPanic()
- defer close(agentOutput) // Ensure agentOutput is closed when Run exits
-
- // Create server instance for MCP tools
- srv, cleanup, err := a.setupServer(ctx)
- if err != nil {
- agentOutput <- fmt.Sprintf("Error: %v", err)
- return err
- }
- defer cleanup() // Ensure cleanup is called
-
- // Load agent configuration to get orchestrator and tool-runner models
- config, err := GetConfig()
- if err != nil {
- a.logger.Error("Failed to load agent config: %v", err)
- agentOutput <- fmt.Sprintf("Error: Failed to load agent config: %v", err)
- return fmt.Errorf("failed to load agent config: %w", err)
- }
-
- // Get model configurations for orchestrator and tool-runner
- orchestratorConfig := a.config.ModelConfig
- if cfgOrch := config.GetOrchestratorModel(); cfgOrch != nil {
- // Merge config file settings with command-line overrides
- orchestratorConfig = mergeModelConfig(*cfgOrch, a.config.ModelConfig)
- }
-
- toolRunnerConfig := orchestratorConfig // Default to same as orchestrator
- if cfgTool := config.GetToolRunnerModel(); cfgTool != nil {
- // Merge config file settings with command-line overrides for tool runner
- toolRunnerConfig = mergeModelConfig(*cfgTool, a.config.ModelConfig)
- }
-
- a.logger.Info("Orchestrator model: %s (%s)", orchestratorConfig.Model, orchestratorConfig.Class)
- a.logger.Info("Tool-runner model: %s (%s)", toolRunnerConfig.Model, toolRunnerConfig.Class)
-
- // Create a single-run context if in --once mode
- if a.config.Once {
- // Create a context with a timeout to ensure we don't get stuck in --once mode
- singleRunCtx, singleRunCancel := context.WithTimeout(ctx, 120*time.Second)
- defer singleRunCancel()
- ctx = singleRunCtx
- a.logger.Info("Running in one-shot mode with 120s safety timeout")
- } else {
- a.logger.Info("Running in interactive mode (will wait for user input to continue)")
- }
-
- // Create cagent runtime with multi-agent system
- cagentRT, err := CreateCagentRuntime(ctx, srv, orchestratorConfig, toolRunnerConfig, a.config.UserPrompt, a.logger)
- if err != nil {
- a.logger.Error("Failed to create cagent runtime: %v", err)
- agentOutput <- fmt.Sprintf("Error: Failed to create cagent runtime: %v", err)
- return fmt.Errorf("failed to create cagent runtime: %w", err)
- }
-
- // Conversation loop - run until Once mode or context cancellation
- for {
- // Start streaming events from cagent
- a.logger.Debug("Starting cagent event stream")
- events := cagentRT.RunStream(ctx)
-
- // Process events and send output
- eventCount := 0
- for event := range events {
- eventCount++
- a.logger.Debug("Received event #%d: %T", eventCount, event)
-
- // Handle tool call confirmations - auto-approve tools
- if _, ok := event.(*runtime.ToolCallConfirmationEvent); ok {
- a.logger.Debug("Auto-approving tool execution")
- cagentRT.Runtime().Resume(ctx, "approve-session")
- }
-
- if err := a.handleCagentEvent(event, agentOutput); err != nil {
- a.logger.Error("Error handling event: %v", err)
- // Continue processing other events
- }
- }
- a.logger.Debug("Event stream completed, processed %d events", eventCount)
-
- // In one-shot mode, exit after first response
- if a.config.Once {
- a.logger.Info("One-shot mode: exiting after first response")
- return nil
- }
-
- // In interactive mode, wait for user input to continue
- a.logger.Debug("Waiting for user input to continue conversation...")
- promptColor := color.New(color.Bold, color.FgHiCyan)
- agentOutput <- fmt.Sprintf("\n%s", promptColor.Sprint("💬 Enter your next question (or Ctrl+C to exit): "))
-
- select {
- case <-ctx.Done():
- a.logger.Info("Context cancelled, exiting")
- return ctx.Err()
- case nextInput, ok := <-userInput:
- if !ok {
- a.logger.Info("User input channel closed, exiting")
- return nil
- }
- if nextInput == "" {
- a.logger.Info("Empty input received, exiting")
- return nil
- }
-
- // Add the new user message to the session to continue the conversation
- a.logger.Debug("Received user input: %s", nextInput)
- if err := cagentRT.ContinueConversation(nextInput); err != nil {
- a.logger.Error("Failed to continue conversation: %v", err)
- agentOutput <- fmt.Sprintf("Error: %v\n", err)
- return fmt.Errorf("failed to continue conversation: %w", err)
- }
- // Loop will continue with the updated session
- }
- }
-}
-
-// handleCagentEvent processes a single cagent event and sends appropriate output
-func (a *Agent) handleCagentEvent(event interface{}, agentOutput chan string) error {
- a.logger.Debug("Handling event type: %T", event)
-
- // Define color schemes for different outputs
- cyan := color.New(color.FgCyan)
- green := color.New(color.FgGreen) // Agent thinking/responses
- blue := color.New(color.FgBlue) // Tool results
- yellow := color.New(color.FgYellow) // Tool calls
- magenta := color.New(color.FgMagenta) // Agent status
-
- // Use concrete types from cagent runtime package
- switch e := event.(type) {
- case *runtime.AgentChoiceEvent:
- // Agent is thinking/responding with text - stream in green
- if e.Content != "" {
- // Send colored content to distinguish agent text from system messages
- agentOutput <- green.Sprint(e.Content)
- }
-
- case *runtime.PartialToolCallEvent:
- // Tool call is being built incrementally - accumulate or just log
- a.logger.Debug("Building tool call: %s", e.ToolCall.Function.Name)
-
- case *runtime.ToolCallEvent:
- // Complete tool call is ready - use yellow for tool calls
- toolName := e.ToolCall.Function.Name
- var args map[string]interface{}
- if err := json.Unmarshal([]byte(e.ToolCall.Function.Arguments), &args); err == nil {
- argsJSON, _ := json.MarshalIndent(args, "", " ")
- agentOutput <- fmt.Sprintf("\n%s\n%s\n",
- yellow.Sprintf("→ [%s] Calling tool '%s' with args:", e.AgentName, toolName),
- cyan.Sprint(string(argsJSON)))
- } else {
- agentOutput <- fmt.Sprintf("\n%s\n", yellow.Sprintf("→ [%s] Calling tool '%s'", e.AgentName, toolName))
- }
-
- case *runtime.ToolCallConfirmationEvent:
- // Tool is being confirmed/executed
- // Add newline before logs to separate from agent output
- agentOutput <- "\n"
- a.logger.Debug("Tool call confirmed for agent: %s", e.AgentName)
-
- case *runtime.ToolCallResponseEvent:
- // Tool execution result - use blue for tool output
- response := e.Response
- if len(response) > 1000 {
- response = response[:1000] + "... (truncated)"
- }
- agentOutput <- fmt.Sprintf("%s\n%s\n%s\n",
- blue.Sprint("--- tool result BEGIN ---"),
- blue.Sprint(response),
- blue.Sprint("--- tool result END ---"))
-
- case *runtime.StreamStartedEvent:
- // Agent started processing - use magenta for agent status
- agentOutput <- fmt.Sprintf("\n%s\n\n", magenta.Sprintf("[%s started]", e.AgentName))
-
- case *runtime.StreamStoppedEvent:
- // Agent finished processing - use magenta for agent status
- // Add newlines before the completion message to ensure separation from streamed text
- agentOutput <- fmt.Sprintf("\n\n%s\n\n", magenta.Sprintf("[%s completed]", e.AgentName))
- a.logger.Debug("Agent %s stream stopped", e.AgentName)
-
- case *runtime.UserMessageEvent:
- // User message being processed
- a.logger.Debug("Processing user message")
-
- case *runtime.TokenUsageEvent:
- // Token usage info
- if e.Usage != nil {
- a.logger.Debug("Token usage: input=%d, output=%d", e.Usage.InputTokens, e.Usage.OutputTokens)
- }
-
- default:
- // Unknown event type
- a.logger.Debug("Unhandled event type: %T", event)
- }
-
- return nil
-}
-
-// mergeModelConfig merges a base configuration with override values
-// Override values (from command-line) take precedence over base values (from config file)
-func mergeModelConfig(base, override ModelConfig) ModelConfig {
- result := base
-
- // Override specific fields if they were set via command-line
- if override.Model != "" {
- result.Model = override.Model
- }
- if override.Class != "" {
- result.Class = override.Class
- }
- if override.APIKey != "" {
- result.APIKey = override.APIKey
- }
- if override.APIURL != "" {
- result.APIURL = override.APIURL
- }
- // Merge prompts - command-line prompts are added to config file prompts
- if override.Prompts.HasSystemPrompts() {
- if result.Prompts.System == nil {
- result.Prompts.System = override.Prompts.System
- } else {
- result.Prompts.System = append(result.Prompts.System, override.Prompts.System...)
- }
- }
-
- return result
-}
-
-// setupServer initializes and creates the MCP server
-func (a *Agent) setupServer(ctx context.Context) (*server.Server, func(), error) {
- // Use the already resolved configuration file path (no need to resolve again)
- localConfigPath := a.config.ToolsFile
- cleanup := func() {} // No cleanup needed since path was already resolved
-
- // Initialize MCP server to get tools
- a.logger.Info("Initializing MCP server")
- srv := server.New(server.Config{
- ConfigFile: localConfigPath,
- Logger: a.logger,
- Version: a.config.Version,
- })
-
- // Create the server instance (but don't start it)
- if err := srv.CreateServer(); err != nil {
- a.logger.Error("Failed to create MCP server: %v", err)
- return nil, cleanup, fmt.Errorf("failed to create MCP server: %w", err)
- }
-
- return srv, cleanup, nil
-}
diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go
deleted file mode 100644
index cec76bc..0000000
--- a/pkg/agent/agent_test.go
+++ /dev/null
@@ -1,277 +0,0 @@
-package agent
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "github.com/inercia/MCPShell/pkg/common"
- "github.com/inercia/MCPShell/pkg/utils"
-)
-
-func TestNew(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- cfg := AgentConfig{
- ToolsFile: "test.yaml",
- UserPrompt: "test prompt",
- Once: false,
- Version: "1.0.0",
- ModelConfig: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- },
- }
-
- agent := New(cfg, logger)
-
- if agent == nil {
- t.Fatal("New() returned nil")
- }
-
- if agent.config.ToolsFile != cfg.ToolsFile {
- t.Errorf("Expected ToolsFile %s, got %s", cfg.ToolsFile, agent.config.ToolsFile)
- }
-
- if agent.config.UserPrompt != cfg.UserPrompt {
- t.Errorf("Expected UserPrompt %s, got %s", cfg.UserPrompt, agent.config.UserPrompt)
- }
-
- if agent.config.Once != cfg.Once {
- t.Errorf("Expected Once %t, got %t", cfg.Once, agent.config.Once)
- }
-
- if agent.config.Version != cfg.Version {
- t.Errorf("Expected Version %s, got %s", cfg.Version, agent.config.Version)
- }
-
- if agent.logger == nil {
- t.Error("Expected logger to be set")
- }
-}
-
-func TestValidate(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- tests := []struct {
- name string
- config AgentConfig
- wantErr bool
- errMsg string
- }{
- {
- name: "valid OpenAI config",
- config: AgentConfig{
- ToolsFile: "test.yaml",
- ModelConfig: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- },
- },
- wantErr: false,
- },
- {
- name: "valid Ollama config",
- config: AgentConfig{
- ToolsFile: "test.yaml",
- ModelConfig: ModelConfig{
- Model: "llama2",
- Class: "ollama",
- },
- },
- wantErr: false,
- },
- {
- name: "missing tools file",
- config: AgentConfig{
- ToolsFile: "",
- ModelConfig: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- },
- },
- wantErr: true,
- errMsg: "tools configuration file is required",
- },
- {
- name: "missing model for OpenAI",
- config: AgentConfig{
- ToolsFile: "test.yaml",
- ModelConfig: ModelConfig{
- Model: "",
- Class: "openai",
- APIKey: "test-key",
- },
- },
- wantErr: true,
- errMsg: "model configuration validation failed: model name is required for OpenAI models",
- },
- {
- name: "missing API key for OpenAI model",
- config: AgentConfig{
- ToolsFile: "test.yaml",
- ModelConfig: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "",
- },
- },
- wantErr: true,
- errMsg: "model configuration validation failed: API key is required for OpenAI models (set API key environment variable or pass via config/flags)",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- agent := New(tt.config, logger)
- err := agent.Validate()
-
- if tt.wantErr {
- if err == nil {
- t.Errorf("Expected error but got none")
- } else if tt.errMsg != "" && err.Error() != tt.errMsg {
- t.Errorf("Expected error message %q, got %q", tt.errMsg, err.Error())
- }
- } else {
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- }
- })
- }
-}
-
-// Note: setupConversation and initializeModelClient tests removed
-// as these methods are now internal to cagent runtime
-
-// TestAgentWithOllama tests the agent with a real Ollama model using cagent
-// This test requires Ollama to be running and a tool-capable model to be available
-func TestAgentWithOllama(t *testing.T) {
- // Use the test utilities to check if Ollama is running and get a tool-capable model
- modelName := utils.RequireOllamaWithTools(t)
-
- t.Logf("Running integration test with model: %s", modelName)
-
- // Create a logger for the test
- logger, err := common.NewLogger("", "", common.LogLevelInfo, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- // Create a temporary test config file
- testConfig := createTestConfigFile(t)
- defer func() {
- if err := os.Remove(testConfig); err != nil && !os.IsNotExist(err) {
- t.Logf("failed to remove test config: %v", err)
- }
- }()
-
- // Create a temporary agent config file for the test
- agentConfigPath := createTestAgentConfigFile(t, modelName)
- defer func() {
- if err := os.Remove(agentConfigPath); err != nil && !os.IsNotExist(err) {
- t.Logf("failed to remove agent config: %v", err)
- }
- }()
-
- // Create agent configuration for Ollama
- cfg := AgentConfig{
- ToolsFile: testConfig,
- UserPrompt: "What is the current date? Just respond with 'Test successful' without using any tools.",
- Once: true,
- Version: "test",
- ModelConfig: ModelConfig{
- Model: modelName,
- Class: "ollama",
- APIURL: "http://localhost:11434/v1", // Ollama's OpenAI-compatible endpoint
- APIKey: "ollama", // Ollama doesn't require a real API key
- Prompts: common.PromptsConfig{
- System: []string{"You are a helpful assistant that can use tools to answer questions."},
- },
- },
- }
-
- // Create and validate the agent
- agent := New(cfg, logger)
- if agent == nil {
- t.Fatal("Failed to create agent")
- }
-
- err = agent.Validate()
- if err != nil {
- t.Fatalf("Agent validation failed: %v", err)
- }
-
- // Test that the model supports tools
- if !utils.IsModelToolCapable(modelName) {
- t.Errorf("Model %s should be tool-capable according to our test utilities", modelName)
- }
-
- t.Log("Ollama integration test setup completed successfully")
- t.Log("Note: Full agent execution test disabled - requires running Ollama instance")
-}
-
-// createTestConfigFile creates a temporary configuration file for testing
-func createTestConfigFile(t *testing.T) string {
- testConfig := `
-tools:
- - name: "date"
- description: "Get the current date and time"
- runner: "exec"
- command: "date"
- parameters: []
-`
-
- tmpDir := t.TempDir()
- configFile := filepath.Join(tmpDir, "test_config.yaml")
-
- err := os.WriteFile(configFile, []byte(testConfig), 0644)
- if err != nil {
- t.Fatalf("Failed to create test config file: %v", err)
- }
-
- return configFile
-}
-
-// createTestAgentConfigFile creates a temporary agent configuration file for testing
-func createTestAgentConfigFile(t *testing.T, modelName string) string {
- agentConfig := `
-agent:
- orchestrator:
- model: "` + modelName + `"
- class: "ollama"
- name: "orchestrator"
- api-url: "http://localhost:11434/v1"
- prompts:
- system:
- - "You are a test orchestrator agent."
-
- tool-runner:
- model: "` + modelName + `"
- class: "ollama"
- name: "tool-runner"
- api-url: "http://localhost:11434/v1"
- prompts:
- system:
- - "You are a test tool execution agent."
-`
-
- tmpDir := t.TempDir()
- configFile := filepath.Join(tmpDir, "agent.yaml")
-
- err := os.WriteFile(configFile, []byte(agentConfig), 0644)
- if err != nil {
- t.Fatalf("Failed to create agent config file: %v", err)
- }
-
- return configFile
-}
diff --git a/pkg/agent/cagent_mcp_tool.go b/pkg/agent/cagent_mcp_tool.go
deleted file mode 100644
index a080e68..0000000
--- a/pkg/agent/cagent_mcp_tool.go
+++ /dev/null
@@ -1,115 +0,0 @@
-// Package agent provides cagent integration for MCP tools
-package agent
-
-import (
- "context"
- "encoding/json"
- "fmt"
-
- cagentTools "github.com/docker/cagent/pkg/tools"
- "github.com/mark3labs/mcp-go/mcp"
-
- "github.com/inercia/MCPShell/pkg/common"
- "github.com/inercia/MCPShell/pkg/server"
-)
-
-// MCPToolSet wraps MCP server tools for use with cagent
-type MCPToolSet struct {
- server *server.Server
- logger *common.Logger
-}
-
-// NewMCPToolSet creates a new MCP tool set for cagent
-func NewMCPToolSet(srv *server.Server, logger *common.Logger) *MCPToolSet {
- return &MCPToolSet{
- server: srv,
- logger: logger,
- }
-}
-
-// GetTools returns all MCP tools as cagent-compatible tools
-func (m *MCPToolSet) GetTools() ([]cagentTools.Tool, error) {
- // Get MCP tools from the server
- mcpTools, err := m.server.GetTools()
- if err != nil {
- m.logger.Error("Failed to get MCP tools: %v", err)
- return nil, fmt.Errorf("failed to get MCP tools: %w", err)
- }
-
- // Convert each MCP tool to a cagent tool
- tools := make([]cagentTools.Tool, 0, len(mcpTools))
- for _, mcpTool := range mcpTools {
- tool := m.convertMCPToolToCagent(mcpTool)
- tools = append(tools, tool)
- }
-
- m.logger.Info("Wrapped %d MCP tools for cagent", len(tools))
- return tools, nil
-}
-
-// convertMCPToolToCagent converts an MCP tool to a cagent Tool struct
-func (m *MCPToolSet) convertMCPToolToCagent(mcpTool mcp.Tool) cagentTools.Tool {
- // Convert MCP input schema to JSON schema for cagent
- schemaMap := map[string]interface{}{
- "type": "object",
- "properties": mcpTool.InputSchema.Properties,
- "required": mcpTool.InputSchema.Required,
- }
-
- // Create the handler function that executes the MCP tool
- // ToolHandler signature: func(ctx context.Context, toolCall ToolCall) (*ToolCallResult, error)
- handler := func(ctx context.Context, toolCall cagentTools.ToolCall) (*cagentTools.ToolCallResult, error) {
- // Parse the arguments from JSON string
- var args map[string]interface{}
-
- // Handle empty arguments (for tools with all optional parameters)
- if toolCall.Function.Arguments == "" {
- args = make(map[string]interface{})
- m.logger.Debug("Tool '%s' called with no arguments, using empty map", mcpTool.Name)
- } else {
- if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
- m.logger.Error("Failed to parse tool arguments for '%s': %v (raw: '%s')",
- mcpTool.Name, err, toolCall.Function.Arguments)
-
- // Return a helpful error message to the agent
- return &cagentTools.ToolCallResult{
- Output: fmt.Sprintf("Error: Invalid JSON arguments provided. Expected valid JSON object but got: %s\n\nExample valid call: {}",
- toolCall.Function.Arguments),
- }, nil
- }
- }
-
- m.logger.Debug("Executing MCP tool '%s' with args: %+v", mcpTool.Name, args)
-
- // Execute the tool through the MCP server
- result, err := m.server.ExecuteTool(ctx, mcpTool.Name, args)
- if err != nil {
- m.logger.Error("Failed to execute MCP tool '%s': %v", mcpTool.Name, err)
-
- // Return error as output instead of returning error, so agent can see it and retry
- return &cagentTools.ToolCallResult{
- Output: fmt.Sprintf("Error executing tool: %v", err),
- }, nil
- }
-
- m.logger.Debug("MCP tool '%s' result: %s", mcpTool.Name, result)
- return &cagentTools.ToolCallResult{
- Output: result,
- }, nil
- }
-
- // Marshal schema to JSON for Parameters field
- schemaJSON, err := json.Marshal(schemaMap)
- if err != nil {
- m.logger.Error("Failed to marshal tool parameters for '%s': %v", mcpTool.Name, err)
- // Return minimal valid schema on error
- schemaJSON = []byte(`{"type":"object","properties":{}}`)
- }
-
- return cagentTools.Tool{
- Name: mcpTool.Name,
- Description: mcpTool.Description,
- Parameters: json.RawMessage(schemaJSON),
- Handler: handler,
- }
-}
diff --git a/pkg/agent/cagent_runtime.go b/pkg/agent/cagent_runtime.go
deleted file mode 100644
index c30d95e..0000000
--- a/pkg/agent/cagent_runtime.go
+++ /dev/null
@@ -1,200 +0,0 @@
-// Package agent provides cagent runtime configuration and setup
-package agent
-
-import (
- "context"
- _ "embed"
- "fmt"
- "os"
-
- cagentAgent "github.com/docker/cagent/pkg/agent"
- cagentConfig "github.com/docker/cagent/pkg/config/v2"
- "github.com/docker/cagent/pkg/environment"
- "github.com/docker/cagent/pkg/model/provider"
- "github.com/docker/cagent/pkg/runtime"
- "github.com/docker/cagent/pkg/session"
- "github.com/docker/cagent/pkg/team"
-
- "github.com/inercia/MCPShell/pkg/common"
- "github.com/inercia/MCPShell/pkg/server"
-)
-
-//go:embed prompts/orchestrator.md
-var defaultOrchestratorPrompt string
-
-// CagentRuntime wraps the cagent runtime and session
-type CagentRuntime struct {
- runtime runtime.Runtime
- session *session.Session
- logger *common.Logger
-}
-
-// CreateCagentRuntime creates and configures a cagent runtime
-// Uses a single agent approach for better tool execution continuity
-func CreateCagentRuntime(
- ctx context.Context,
- srv *server.Server,
- orchestratorConfig ModelConfig,
- toolRunnerConfig ModelConfig,
- userPrompt string,
- logger *common.Logger,
-) (*CagentRuntime, error) {
- logger.Debug("Creating cagent single-agent runtime")
-
- // Use orchestrator config for the single agent
- agentLLM, err := initializeCagentModel(ctx, orchestratorConfig, logger)
- if err != nil {
- return nil, fmt.Errorf("failed to initialize agent model: %w", err)
- }
-
- // Create MCP tool set
- mcpToolSet := NewMCPToolSet(srv, logger)
- tools, err := mcpToolSet.GetTools()
- if err != nil {
- return nil, fmt.Errorf("failed to get MCP tools: %w", err)
- }
-
- logger.Debug("Creating single agent with %d MCP tools", len(tools))
-
- // Get system prompts - use tool-runner prompt since this agent will execute tools
- // Use config prompts if provided, otherwise use embedded default
- agentSysPrompt := orchestratorConfig.Prompts.GetSystemPrompts()
- if agentSysPrompt == "" {
- logger.Debug("Using default embedded prompt for agent")
- agentSysPrompt = defaultOrchestratorPrompt
- } else {
- logger.Debug("Using custom prompt from config for agent")
- }
- logger.Debug("Agent prompt (first 200 chars): %s", func() string {
- if len(agentSysPrompt) > 200 {
- return agentSysPrompt[:200] + "..."
- }
- return agentSysPrompt
- }())
-
- // Create a single agent with all tools
- agent := cagentAgent.New(
- "root",
- agentSysPrompt,
- cagentAgent.WithModel(agentLLM),
- cagentAgent.WithDescription("An agent that executes tools to accomplish user tasks"),
- cagentAgent.WithTools(tools...),
- cagentAgent.WithMaxIterations(50), // Allow up to 50 tool calls
- )
-
- // Create the team with just the one agent
- agentTeam := team.New(team.WithAgents(agent))
-
- // Create the runtime with session compaction enabled
- rt, err := runtime.New(
- agentTeam,
- runtime.WithSessionCompaction(true), // Auto-summarize when approaching context limit
- )
- if err != nil {
- logger.Error("Failed to create cagent runtime: %v", err)
- return nil, fmt.Errorf("failed to create cagent runtime: %w", err)
- }
-
- // Create the session with the user prompt
- // Enhance prompt to emphasize iterative workflow
- enhancedPrompt := userPrompt + `
-
-Remember: This is a multi-step investigation. Keep calling tools iteratively until you have ALL the information needed to fully answer the question. Don't stop after just one tool call.`
-
- sess := session.New(session.WithUserMessage("", enhancedPrompt))
-
- logger.Debug("Cagent single-agent runtime created successfully")
-
- return &CagentRuntime{
- runtime: rt,
- session: sess,
- logger: logger,
- }, nil
-}
-
-// RunStream starts the streaming runtime and returns the event channel
-func (cr *CagentRuntime) RunStream(ctx context.Context) <-chan runtime.Event {
- cr.logger.Debug("Starting cagent runtime stream")
- return cr.runtime.RunStream(ctx, cr.session)
-}
-
-// Runtime returns the underlying cagent runtime for advanced operations like Resume
-func (cr *CagentRuntime) Runtime() runtime.Runtime {
- return cr.runtime
-}
-
-// ContinueConversation adds a new user message to the session and continues the conversation
-func (cr *CagentRuntime) ContinueConversation(userMessage string) error {
- cr.logger.Debug("Adding user message to continue conversation")
-
- // Add the user message to the existing session
- msg := session.UserMessage("", userMessage)
- cr.session.AddMessage(msg)
-
- cr.logger.Debug("User message added to session, ready for next stream")
- return nil
-}
-
-// initializeCagentModel creates a cagent-compatible model provider from our ModelConfig
-func initializeCagentModel(ctx context.Context, config ModelConfig, logger *common.Logger) (provider.Provider, error) {
- // Create cagent model configuration
- cagentModelConfig := &cagentConfig.ModelConfig{
- Provider: config.Class,
- Model: config.Model,
- }
-
- // Handle provider name mapping
- // Default to openai if no class specified
- if cagentModelConfig.Provider == "" {
- cagentModelConfig.Provider = "openai"
- logger.Debug("No provider specified, defaulting to openai")
- }
-
- // Map "ollama" to "openai" since Ollama uses OpenAI-compatible API
- if cagentModelConfig.Provider == "ollama" {
- cagentModelConfig.Provider = "openai"
- logger.Debug("Mapping ollama provider to openai (OpenAI-compatible)")
-
- // Set default Ollama URL if not specified
- if config.APIURL == "" {
- config.APIURL = "http://localhost:11434/v1"
- logger.Debug("Using default Ollama URL: http://localhost:11434/v1")
- } else {
- logger.Debug("Using custom Ollama URL: %s", config.APIURL)
- }
-
- // Ollama doesn't require an API key, set a dummy one if not provided
- if config.APIKey == "" {
- config.APIKey = "ollama-no-key-required"
- logger.Debug("Setting dummy API key for Ollama (no key required)")
- }
- }
-
- // Set BaseURL if provided in config
- if config.APIURL != "" {
- cagentModelConfig.BaseURL = config.APIURL
- logger.Debug("Setting base URL: %s", config.APIURL)
- }
-
- logger.Debug("Initializing cagent model: provider=%s, model=%s",
- cagentModelConfig.Provider, cagentModelConfig.Model)
-
- // Create environment provider for API keys
- // Set API key from config into environment if provided
- if config.APIKey != "" {
- _ = os.Setenv("OPENAI_API_KEY", config.APIKey)
- logger.Debug("Setting API key from config into environment")
- }
-
- envProvider := environment.NewDefaultProvider()
-
- client, err := provider.New(ctx, cagentModelConfig, envProvider)
- if err != nil {
- logger.Error("Failed to create model provider '%s': %v", cagentModelConfig.Provider, err)
- return nil, fmt.Errorf("failed to create model provider '%s': %w", cagentModelConfig.Provider, err)
- }
-
- logger.Debug("Successfully initialized %s provider for model %s",
- cagentModelConfig.Provider, cagentModelConfig.Model)
- return client, nil
-}
diff --git a/pkg/agent/config_file.go b/pkg/agent/config_file.go
deleted file mode 100644
index 508fbd2..0000000
--- a/pkg/agent/config_file.go
+++ /dev/null
@@ -1,184 +0,0 @@
-// Package agent provides agent configuration and management functionality
-package agent
-
-import (
- _ "embed"
- "fmt"
- "os"
- "path/filepath"
-
- "gopkg.in/yaml.v3"
-
- "github.com/inercia/MCPShell/pkg/common"
- "github.com/inercia/MCPShell/pkg/utils"
-)
-
-//go:embed config_sample.yaml
-var defaultConfigYAML string
-
-// ModelConfig holds configuration for a single model
-type ModelConfig struct {
- Model string `yaml:"model"`
- Class string `yaml:"class,omitempty"` // Class of the model, e.g., "ollama", "openai", etc.
- Name string `yaml:"name,omitempty"` // Name of the model, optional
- Default bool `yaml:"default,omitempty"` // Whether this is the default model
- APIKey string `yaml:"api-key,omitempty"` // API key, optional
- APIURL string `yaml:"api-url,omitempty"` // API URL, optional
- Prompts common.PromptsConfig `yaml:"prompts,omitempty"` // Prompts configuration, optional
-}
-
-// AgentConfigFile holds the agent configuration from file
-type AgentConfigFile struct {
- Models []ModelConfig `yaml:"models"` // Legacy: flat list of models
-
- // Role-based configuration for multi-agent system
- Orchestrator *ModelConfig `yaml:"orchestrator,omitempty"` // Root agent that plans and orchestrates
- ToolRunner *ModelConfig `yaml:"tool-runner,omitempty"` // Sub-agent that executes tools
-}
-
-// Config holds the complete agent configuration
-type Config struct {
- Agent AgentConfigFile `yaml:"agent"`
-}
-
-// GetConfig returns the agent configuration from the config file
-// The config file is located at ~/.mcpshell/agent.yaml
-func GetConfig() (*Config, error) {
- mcpShellHome, err := utils.GetMCPShellHome()
- if err != nil {
- return nil, fmt.Errorf("failed to get MCPShell home directory: %w", err)
- }
-
- configPath := filepath.Join(mcpShellHome, "agent.yaml")
-
- // Check if config file exists
- if _, err := os.Stat(configPath); os.IsNotExist(err) {
- // Return empty config if file doesn't exist
- return &Config{}, nil
- }
-
- data, err := os.ReadFile(configPath)
- if err != nil {
- return nil, fmt.Errorf("failed to read config file %s: %w", configPath, err)
- }
-
- var config Config
- if err := yaml.Unmarshal(data, &config); err != nil {
- return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
- }
-
- return &config, nil
-}
-
-// GetDefaultModel returns the model configuration that has default=true
-// If no default is found, returns the first model in the list
-// If no models are configured, returns nil
-func (c *Config) GetDefaultModel() *ModelConfig {
- if len(c.Agent.Models) == 0 {
- return nil
- }
-
- // Look for the default model
- for i := range c.Agent.Models {
- if c.Agent.Models[i].Default {
- return &c.Agent.Models[i]
- }
- }
-
- // If no default found, return the first model
- return &c.Agent.Models[0]
-}
-
-// GetModelByName returns the model configuration with the specified name
-func (c *Config) GetModelByName(name string) *ModelConfig {
- for i := range c.Agent.Models {
- // Check both Name and Model fields
- if c.Agent.Models[i].Name == name || c.Agent.Models[i].Model == name {
- return &c.Agent.Models[i]
- }
- }
- return nil
-}
-
-// CreateDefaultConfig creates a default agent configuration file if it doesn't exist
-func CreateDefaultConfig() error {
- mcpShellHome, err := utils.GetMCPShellHome()
- if err != nil {
- return fmt.Errorf("failed to get MCPShell home directory: %w", err)
- }
-
- // Create directory if it doesn't exist
- if err := os.MkdirAll(mcpShellHome, 0o755); err != nil {
- return fmt.Errorf("failed to create MCPShell directory: %w", err)
- }
-
- configPath := filepath.Join(mcpShellHome, "agent.yaml")
-
- // Check if config file already exists
- if _, err := os.Stat(configPath); err == nil {
- return nil // File already exists, don't overwrite
- }
-
- // Use the embedded default configuration
- if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0o644); err != nil {
- return fmt.Errorf("failed to write default config file: %w", err)
- }
-
- return nil
-}
-
-// CreateDefaultConfigForce creates a default agent configuration file, overwriting if it exists
-func CreateDefaultConfigForce() error {
- mcpShellHome, err := utils.GetMCPShellHome()
- if err != nil {
- return fmt.Errorf("failed to get MCPShell home directory: %w", err)
- }
-
- // Create directory if it doesn't exist
- if err := os.MkdirAll(mcpShellHome, 0o755); err != nil {
- return fmt.Errorf("failed to create MCPShell directory: %w", err)
- }
-
- configPath := filepath.Join(mcpShellHome, "agent.yaml")
-
- // Write the embedded default configuration
- if err := os.WriteFile(configPath, []byte(defaultConfigYAML), 0o644); err != nil {
- return fmt.Errorf("failed to write default config file: %w", err)
- }
-
- return nil
-}
-
-// GetDefaultConfig returns the default agent configuration parsed from the embedded config_sample.yaml
-func GetDefaultConfig() (*Config, error) {
- var config Config
- if err := yaml.Unmarshal([]byte(defaultConfigYAML), &config); err != nil {
- return nil, fmt.Errorf("failed to parse default config: %w", err)
- }
- return &config, nil
-}
-
-// GetDefaultConfigYAML returns the embedded default configuration as a YAML string
-func GetDefaultConfigYAML() string {
- return defaultConfigYAML
-}
-
-// GetOrchestratorModel returns the orchestrator model configuration
-// Falls back to default model if orchestrator is not specified
-func (c *Config) GetOrchestratorModel() *ModelConfig {
- if c.Agent.Orchestrator != nil {
- return c.Agent.Orchestrator
- }
- // Fall back to default model for backward compatibility
- return c.GetDefaultModel()
-}
-
-// GetToolRunnerModel returns the tool-runner model configuration
-// Falls back to orchestrator model if tool-runner is not specified
-func (c *Config) GetToolRunnerModel() *ModelConfig {
- if c.Agent.ToolRunner != nil {
- return c.Agent.ToolRunner
- }
- // Fall back to orchestrator model (which may fall back to default)
- return c.GetOrchestratorModel()
-}
diff --git a/pkg/agent/config_file_test.go b/pkg/agent/config_file_test.go
deleted file mode 100644
index 292550b..0000000
--- a/pkg/agent/config_file_test.go
+++ /dev/null
@@ -1,258 +0,0 @@
-package agent
-
-import (
- "os"
- "testing"
-
- "github.com/inercia/MCPShell/pkg/common"
- "gopkg.in/yaml.v3"
-)
-
-func TestConfigParsing(t *testing.T) {
- // Create a temporary config file
- tmpFile, err := os.CreateTemp("", "agent-config-*.yaml")
- if err != nil {
- t.Fatalf("Failed to create temp file: %v", err)
- }
- defer func() {
- if err := os.Remove(tmpFile.Name()); err != nil && !os.IsNotExist(err) {
- t.Fatalf("Failed to remove temp file: %v", err)
- }
- }()
-
- configContent := `agent:
- models:
- - model: "test-model"
- class: "openai"
- name: "Test Agent"
- default: true
- api-key: "test-key"
- api-url: "https://api.test.com/v1"
- prompts:
- system:
- - "Test system prompt"
-
- - model: "test-model-2"
- class: "ollama"
- name: "Test Agent 2"
- default: false
- prompts:
- system:
- - "Test system prompt 2"
-`
-
- _, err = tmpFile.WriteString(configContent)
- if err != nil {
- t.Fatalf("Failed to write test config: %v", err)
- }
- if err := tmpFile.Close(); err != nil {
- t.Fatalf("Failed to close temp file: %v", err)
- }
-
- // Read and parse the config directly
- data, err := os.ReadFile(tmpFile.Name())
- if err != nil {
- t.Fatalf("Failed to read config file: %v", err)
- }
-
- var config Config
- if err := yaml.Unmarshal(data, &config); err != nil {
- t.Fatalf("Failed to parse config: %v", err)
- }
-
- // Verify the config was loaded correctly
- if len(config.Agent.Models) != 2 {
- t.Errorf("Expected 2 models, got %d", len(config.Agent.Models))
- }
-
- // Test GetDefaultModel
- defaultModel := config.GetDefaultModel()
- if defaultModel == nil {
- t.Fatal("Expected default model, got nil")
- }
-
- if defaultModel.Model != "test-model" {
- t.Errorf("Expected default model 'test-model', got '%s'", defaultModel.Model)
- }
-
- if !defaultModel.Default {
- t.Error("Expected default model to have Default=true")
- }
-
- if defaultModel.APIKey != "test-key" {
- t.Errorf("Expected API key 'test-key', got '%s'", defaultModel.APIKey)
- }
-
- // Test GetModelByName
- model := config.GetModelByName("test-model-2")
- if model == nil {
- t.Fatal("Expected to find model 'test-model-2', got nil")
- }
-
- if model.Model != "test-model-2" {
- t.Errorf("Expected model 'test-model-2', got '%s'", model.Model)
- }
-
- if model.Default {
- t.Error("Expected non-default model to have Default=false")
- }
-
- // Test GetModelByName with non-existent model
- nonExistentModel := config.GetModelByName("non-existent")
- if nonExistentModel != nil {
- t.Error("Expected nil for non-existent model")
- }
-}
-
-func TestEmptyConfig(t *testing.T) {
- config := Config{}
-
- // GetDefaultModel should return nil when no models
- defaultModel := config.GetDefaultModel()
- if defaultModel != nil {
- t.Error("Expected nil default model when no models configured")
- }
-
- // GetModelByName should return nil when no models
- model := config.GetModelByName("any-model")
- if model != nil {
- t.Error("Expected nil for any model when no models configured")
- }
-}
-
-func TestPromptsConfig(t *testing.T) {
- // Test empty prompts
- emptyPrompts := common.PromptsConfig{}
-
- if emptyPrompts.HasSystemPrompts() {
- t.Error("Expected false for HasSystemPrompts with empty config")
- }
-
- if emptyPrompts.HasUserPrompts() {
- t.Error("Expected false for HasUserPrompts with empty config")
- }
-
- if emptyPrompts.GetSystemPrompts() != "" {
- t.Error("Expected empty string for GetSystemPrompts with empty config")
- }
-
- if emptyPrompts.GetUserPrompts() != "" {
- t.Error("Expected empty string for GetUserPrompts with empty config")
- }
-
- // Test prompts with content
- prompts := common.PromptsConfig{
- System: []string{
- "You are a helpful assistant.",
- "Use available tools to help users.",
- },
- User: []string{
- "Help me with my task.",
- "Please be thorough.",
- },
- }
-
- if !prompts.HasSystemPrompts() {
- t.Error("Expected true for HasSystemPrompts with system prompts")
- }
-
- if !prompts.HasUserPrompts() {
- t.Error("Expected true for HasUserPrompts with user prompts")
- }
-
- expectedSystem := "You are a helpful assistant.\nUse available tools to help users."
- if prompts.GetSystemPrompts() != expectedSystem {
- t.Errorf("Expected system prompts '%s', got '%s'", expectedSystem, prompts.GetSystemPrompts())
- }
-
- expectedUser := "Help me with my task.\nPlease be thorough."
- if prompts.GetUserPrompts() != expectedUser {
- t.Errorf("Expected user prompts '%s', got '%s'", expectedUser, prompts.GetUserPrompts())
- }
-
- // Test single prompt
- singlePrompt := common.PromptsConfig{
- System: []string{"Single system prompt"},
- }
-
- if singlePrompt.GetSystemPrompts() != "Single system prompt" {
- t.Errorf("Expected 'Single system prompt', got '%s'", singlePrompt.GetSystemPrompts())
- }
-}
-
-func TestGetOrchestratorAndToolRunnerModels(t *testing.T) {
- // Test with role-based configuration
- config := Config{
- Agent: AgentConfigFile{
- Orchestrator: &ModelConfig{
- Model: "gpt-4o",
- Class: "openai",
- Name: "orchestrator",
- },
- ToolRunner: &ModelConfig{
- Model: "gpt-4o-mini",
- Class: "openai",
- Name: "tool-runner",
- },
- },
- }
-
- orchestrator := config.GetOrchestratorModel()
- if orchestrator == nil {
- t.Fatal("Expected orchestrator model, got nil")
- }
- if orchestrator.Model != "gpt-4o" {
- t.Errorf("Expected orchestrator model 'gpt-4o', got '%s'", orchestrator.Model)
- }
-
- toolRunner := config.GetToolRunnerModel()
- if toolRunner == nil {
- t.Fatal("Expected tool-runner model, got nil")
- }
- if toolRunner.Model != "gpt-4o-mini" {
- t.Errorf("Expected tool-runner model 'gpt-4o-mini', got '%s'", toolRunner.Model)
- }
-
- // Test with legacy flat model list
- legacyConfig := Config{
- Agent: AgentConfigFile{
- Models: []ModelConfig{
- {
- Model: "gpt-4",
- Class: "openai",
- Name: "default",
- Default: true,
- },
- },
- },
- }
-
- legacyOrchestrator := legacyConfig.GetOrchestratorModel()
- if legacyOrchestrator == nil {
- t.Fatal("Expected orchestrator model from legacy config, got nil")
- }
- if legacyOrchestrator.Model != "gpt-4" {
- t.Errorf("Expected orchestrator model 'gpt-4', got '%s'", legacyOrchestrator.Model)
- }
-
- legacyToolRunner := legacyConfig.GetToolRunnerModel()
- if legacyToolRunner == nil {
- t.Fatal("Expected tool-runner model from legacy config, got nil")
- }
- // Tool runner should fall back to orchestrator
- if legacyToolRunner.Model != "gpt-4" {
- t.Errorf("Expected tool-runner to fall back to 'gpt-4', got '%s'", legacyToolRunner.Model)
- }
-
- // Test with empty config
- emptyConfig := Config{}
- emptyOrchestrator := emptyConfig.GetOrchestratorModel()
- if emptyOrchestrator != nil {
- t.Error("Expected nil orchestrator for empty config")
- }
-
- emptyToolRunner := emptyConfig.GetToolRunnerModel()
- if emptyToolRunner != nil {
- t.Error("Expected nil tool-runner for empty config")
- }
-}
diff --git a/pkg/agent/config_sample.yaml b/pkg/agent/config_sample.yaml
deleted file mode 100644
index cd32981..0000000
--- a/pkg/agent/config_sample.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-agent:
- orchestrator:
- model: "gpt-4o"
- class: "openai"
- name: "orchestrator"
- api-key: "${OPENAI_API_KEY}"
- api-url: "https://api.openai.com/v1"
- prompts: {}
- # IMPORTANT: the default system prompts will be used. Override this with your own system prompts if you want to.
- # system:
- #- "You are an orchestrator agent responsible for planning and coordinating tasks."
- #- "Break down complex tasks into steps and delegate tool execution to your tool-runner sub-agent."
- #- "Check if tasks are completed and provide clear summaries to the user."
-
- tool-runner:
- model: "gpt-4o-mini" # Can use a lighter/cheaper model for tool execution
- class: "openai"
- name: "tool-runner"
- api-key: "${OPENAI_API_KEY}"
- api-url: "https://api.openai.com/v1"
-
- # If orchestrator/tool-runner are not specified, the first default model is used
- models:
- - model: "gpt-4o"
- class: "openai"
- name: "openai"
- default: true
- api-key: "${OPENAI_API_KEY}"
- api-url: "https://api.openai.com/v1"
-
- - model: "gemma3n"
- class: "ollama"
- name: "ollama"
diff --git a/pkg/agent/model.go b/pkg/agent/model.go
deleted file mode 100644
index 61628fc..0000000
--- a/pkg/agent/model.go
+++ /dev/null
@@ -1,195 +0,0 @@
-// Package agent provides model-specific client initialization and management functionality
-package agent
-
-import (
- "fmt"
-
- "github.com/inercia/MCPShell/pkg/common"
- "github.com/sashabaranov/go-openai"
-)
-
-// ModelProvider defines the interface for different model providers
-type ModelProvider interface {
- // InitializeClient creates and configures the client for this model provider
- InitializeClient(config ModelConfig, logger *common.Logger) (*openai.Client, error)
-
- // ValidateConfig validates the configuration for this model provider
- ValidateConfig(config ModelConfig, logger *common.Logger) error
-
- // GetProviderName returns the human-readable name of the provider
- GetProviderName() string
-}
-
-// ModelManager manages different model providers and routes requests to the appropriate one
-type ModelManager struct {
- providers map[string]ModelProvider
- logger *common.Logger
-}
-
-// NewModelManager creates a new model manager with all supported providers
-func NewModelManager(logger *common.Logger) *ModelManager {
- manager := &ModelManager{
- providers: make(map[string]ModelProvider),
- logger: logger,
- }
-
- // Register all supported providers
- manager.RegisterProvider("openai", &OpenAIProvider{})
- manager.RegisterProvider("ollama", &OllamaProvider{})
-
- return manager
-}
-
-// RegisterProvider registers a new model provider
-func (mm *ModelManager) RegisterProvider(class string, provider ModelProvider) {
- mm.providers[class] = provider
-}
-
-// InitializeClient initializes a client for the given model configuration
-func (mm *ModelManager) InitializeClient(config ModelConfig) (*openai.Client, error) {
- provider := mm.getProvider(config.Class)
- return provider.InitializeClient(config, mm.logger)
-}
-
-// ValidateConfig validates the configuration for the given model class
-func (mm *ModelManager) ValidateConfig(config ModelConfig) error {
- provider := mm.getProvider(config.Class)
- return provider.ValidateConfig(config, mm.logger)
-}
-
-// getProvider returns the appropriate provider for the given class
-func (mm *ModelManager) getProvider(class string) ModelProvider {
- // Default to OpenAI if class is empty or not found
- if class == "" {
- class = "openai"
- }
-
- if provider, exists := mm.providers[class]; exists {
- return provider
- }
-
- // Return a generic provider for unknown classes
- return &GenericProvider{class: class}
-}
-
-// OpenAIProvider implements ModelProvider for OpenAI models
-type OpenAIProvider struct{}
-
-func (p *OpenAIProvider) InitializeClient(config ModelConfig, logger *common.Logger) (*openai.Client, error) {
- apiKey := config.APIKey
- if apiKey == "" {
- logger.Error("API key is required for OpenAI models")
- return nil, fmt.Errorf("API key is required for OpenAI models")
- }
-
- clientConfig := openai.DefaultConfig(apiKey)
- if config.APIURL != "" {
- clientConfig.BaseURL = config.APIURL
- }
-
- client := openai.NewClientWithConfig(clientConfig)
- logger.Info("Initialized OpenAI client with model: %s", config.Model)
- return client, nil
-}
-
-func (p *OpenAIProvider) ValidateConfig(config ModelConfig, logger *common.Logger) error {
- if config.Model == "" {
- return fmt.Errorf("model name is required for OpenAI models")
- }
-
- if config.APIKey == "" {
- return fmt.Errorf("API key is required for OpenAI models (set API key environment variable or pass via config/flags)")
- }
-
- logger.Debug("OpenAI model configuration validated: %s", config.Model)
- return nil
-}
-
-func (p *OpenAIProvider) GetProviderName() string {
- return "OpenAI"
-}
-
-// OllamaProvider implements ModelProvider for Ollama models
-type OllamaProvider struct{}
-
-func (p *OllamaProvider) InitializeClient(config ModelConfig, logger *common.Logger) (*openai.Client, error) {
- // Ollama uses OpenAI-compatible API at localhost:11434
- apiKey := "ollama" // Ollama requires a dummy API key but doesn't use it
- clientConfig := openai.DefaultConfig(apiKey)
- clientConfig.BaseURL = "http://localhost:11434/v1"
-
- if config.APIURL != "" {
- // Allow override of Ollama URL if specified
- clientConfig.BaseURL = config.APIURL
- }
-
- client := openai.NewClientWithConfig(clientConfig)
- logger.Info("Initialized Ollama client with model: %s", config.Model)
- return client, nil
-}
-
-func (p *OllamaProvider) ValidateConfig(config ModelConfig, logger *common.Logger) error {
- if config.Model == "" {
- return fmt.Errorf("model name is required for Ollama models")
- }
-
- // API key is not required for Ollama
- logger.Debug("Ollama model configuration validated: %s", config.Model)
- return nil
-}
-
-func (p *OllamaProvider) GetProviderName() string {
- return "Ollama"
-}
-
-// GenericProvider implements ModelProvider for unknown/generic model types
-// This allows for extensibility with other OpenAI-compatible APIs
-type GenericProvider struct {
- class string
-}
-
-func (p *GenericProvider) InitializeClient(config ModelConfig, logger *common.Logger) (*openai.Client, error) {
- logger.Warn("Unknown model class '%s', treating as OpenAI-compatible", p.class)
-
- apiKey := config.APIKey
- if apiKey == "" {
- // Use a dummy key if none provided for unknown types
- apiKey = "unknown-model-type"
- }
-
- clientConfig := openai.DefaultConfig(apiKey)
- if config.APIURL != "" {
- clientConfig.BaseURL = config.APIURL
- }
-
- client := openai.NewClientWithConfig(clientConfig)
- logger.Info("Initialized OpenAI-compatible (%s) client with model: %s", p.class, config.Model)
- return client, nil
-}
-
-func (p *GenericProvider) ValidateConfig(config ModelConfig, logger *common.Logger) error {
- if config.Model == "" {
- return fmt.Errorf("model name is required")
- }
-
- logger.Info("Unknown model class '%s', performing basic validation", p.class)
- return nil
-}
-
-func (p *GenericProvider) GetProviderName() string {
- return fmt.Sprintf("OpenAI-compatible (%s)", p.class)
-}
-
-// Convenience functions for backward compatibility and ease of use
-
-// InitializeModelClient creates and configures the appropriate model client based on the model class
-func InitializeModelClient(config ModelConfig, logger *common.Logger) (*openai.Client, error) {
- manager := NewModelManager(logger)
- return manager.InitializeClient(config)
-}
-
-// ValidateModelConfig validates the model configuration for the specified model class
-func ValidateModelConfig(config ModelConfig, logger *common.Logger) error {
- manager := NewModelManager(logger)
- return manager.ValidateConfig(config)
-}
diff --git a/pkg/agent/model_test.go b/pkg/agent/model_test.go
deleted file mode 100644
index 7e64b56..0000000
--- a/pkg/agent/model_test.go
+++ /dev/null
@@ -1,484 +0,0 @@
-package agent
-
-import (
- "testing"
-
- "github.com/inercia/MCPShell/pkg/common"
-)
-
-func TestNewModelManager(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- manager := NewModelManager(logger)
-
- if manager == nil {
- t.Fatal("NewModelManager returned nil")
- }
-
- if manager.logger == nil {
- t.Error("Expected logger to be set")
- }
-
- if len(manager.providers) == 0 {
- t.Error("Expected providers to be registered")
- }
-
- // Test that default providers are registered
- expectedProviders := []string{"openai", "ollama"}
- for _, providerClass := range expectedProviders {
- if _, exists := manager.providers[providerClass]; !exists {
- t.Errorf("Expected provider '%s' to be registered", providerClass)
- }
- }
-}
-
-func TestModelManager_RegisterProvider(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- manager := NewModelManager(logger)
- customProvider := &GenericProvider{class: "custom"}
-
- manager.RegisterProvider("custom", customProvider)
-
- if _, exists := manager.providers["custom"]; !exists {
- t.Error("Expected custom provider to be registered")
- }
-}
-
-func TestModelManager_InitializeClient(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- manager := NewModelManager(logger)
-
- tests := []struct {
- name string
- config ModelConfig
- expectErr bool
- }{
- {
- name: "OpenAI model",
- config: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- },
- expectErr: false,
- },
- {
- name: "Ollama model",
- config: ModelConfig{
- Model: "llama2",
- Class: "ollama",
- },
- expectErr: false,
- },
- {
- name: "OpenAI model missing API key",
- config: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "",
- },
- expectErr: true,
- },
- {
- name: "Unknown model class",
- config: ModelConfig{
- Model: "custom-model",
- Class: "unknown",
- APIKey: "test-key",
- },
- expectErr: false,
- },
- {
- name: "Empty class defaults to OpenAI",
- config: ModelConfig{
- Model: "gpt-3.5-turbo",
- Class: "",
- APIKey: "test-key",
- },
- expectErr: false,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- client, err := manager.InitializeClient(tt.config)
-
- if tt.expectErr {
- if err == nil {
- t.Error("Expected error but got none")
- }
- } else {
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- }
- })
- }
-}
-
-func TestModelManager_ValidateConfig(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- manager := NewModelManager(logger)
-
- tests := []struct {
- name string
- config ModelConfig
- expectErr bool
- errMsg string
- }{
- {
- name: "valid OpenAI config",
- config: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- },
- expectErr: false,
- },
- {
- name: "valid Ollama config",
- config: ModelConfig{
- Model: "llama2",
- Class: "ollama",
- },
- expectErr: false,
- },
- {
- name: "OpenAI missing API key",
- config: ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "",
- },
- expectErr: true,
- errMsg: "API key is required for OpenAI models (set API key environment variable or pass via config/flags)",
- },
- {
- name: "OpenAI missing model",
- config: ModelConfig{
- Model: "",
- Class: "openai",
- APIKey: "test-key",
- },
- expectErr: true,
- errMsg: "model name is required for OpenAI models",
- },
- {
- name: "Ollama missing model",
- config: ModelConfig{
- Model: "",
- Class: "ollama",
- },
- expectErr: true,
- errMsg: "model name is required for Ollama models",
- },
- {
- name: "unknown class valid config",
- config: ModelConfig{
- Model: "custom-model",
- Class: "custom",
- APIKey: "test-key",
- },
- expectErr: false,
- },
- {
- name: "unknown class missing model",
- config: ModelConfig{
- Model: "",
- Class: "custom",
- },
- expectErr: true,
- errMsg: "model name is required",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- err := manager.ValidateConfig(tt.config)
-
- if tt.expectErr {
- if err == nil {
- t.Error("Expected error but got none")
- } else if tt.errMsg != "" && err.Error() != tt.errMsg {
- t.Errorf("Expected error message %q, got %q", tt.errMsg, err.Error())
- }
- } else {
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- }
- })
- }
-}
-
-func TestOpenAIProvider(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- provider := &OpenAIProvider{}
-
- t.Run("GetProviderName", func(t *testing.T) {
- name := provider.GetProviderName()
- if name != "OpenAI" {
- t.Errorf("Expected provider name 'OpenAI', got '%s'", name)
- }
- })
-
- t.Run("InitializeClient success", func(t *testing.T) {
- config := ModelConfig{
- Model: "gpt-4",
- APIKey: "test-key",
- }
-
- client, err := provider.InitializeClient(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- })
-
- t.Run("InitializeClient missing API key", func(t *testing.T) {
- config := ModelConfig{
- Model: "gpt-4",
- APIKey: "",
- }
-
- client, err := provider.InitializeClient(config, logger)
- if err == nil {
- t.Error("Expected error for missing API key")
- }
- if client != nil {
- t.Error("Expected nil client for error case")
- }
- })
-
- t.Run("ValidateConfig success", func(t *testing.T) {
- config := ModelConfig{
- Model: "gpt-4",
- APIKey: "test-key",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- })
-
- t.Run("ValidateConfig missing model", func(t *testing.T) {
- config := ModelConfig{
- Model: "",
- APIKey: "test-key",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err == nil {
- t.Error("Expected error for missing model")
- }
- })
-
- t.Run("ValidateConfig missing API key", func(t *testing.T) {
- config := ModelConfig{
- Model: "gpt-4",
- APIKey: "",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err == nil {
- t.Error("Expected error for missing API key")
- }
- })
-}
-
-func TestOllamaProvider(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- provider := &OllamaProvider{}
-
- t.Run("GetProviderName", func(t *testing.T) {
- name := provider.GetProviderName()
- if name != "Ollama" {
- t.Errorf("Expected provider name 'Ollama', got '%s'", name)
- }
- })
-
- t.Run("InitializeClient", func(t *testing.T) {
- config := ModelConfig{
- Model: "llama2",
- }
-
- client, err := provider.InitializeClient(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- })
-
- t.Run("InitializeClient with custom URL", func(t *testing.T) {
- config := ModelConfig{
- Model: "llama2",
- APIURL: "http://custom-host:11434/v1",
- }
-
- client, err := provider.InitializeClient(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- })
-
- t.Run("ValidateConfig success", func(t *testing.T) {
- config := ModelConfig{
- Model: "llama2",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- })
-
- t.Run("ValidateConfig missing model", func(t *testing.T) {
- config := ModelConfig{
- Model: "",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err == nil {
- t.Error("Expected error for missing model")
- }
- })
-}
-
-func TestGenericProvider(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- provider := &GenericProvider{class: "custom"}
-
- t.Run("GetProviderName", func(t *testing.T) {
- name := provider.GetProviderName()
- expected := "OpenAI-compatible (custom)"
- if name != expected {
- t.Errorf("Expected provider name '%s', got '%s'", expected, name)
- }
- })
-
- t.Run("InitializeClient with API key", func(t *testing.T) {
- config := ModelConfig{
- Model: "custom-model",
- APIKey: "test-key",
- }
-
- client, err := provider.InitializeClient(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- })
-
- t.Run("InitializeClient without API key", func(t *testing.T) {
- config := ModelConfig{
- Model: "custom-model",
- APIKey: "",
- }
-
- client, err := provider.InitializeClient(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- })
-
- t.Run("ValidateConfig success", func(t *testing.T) {
- config := ModelConfig{
- Model: "custom-model",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- })
-
- t.Run("ValidateConfig missing model", func(t *testing.T) {
- config := ModelConfig{
- Model: "",
- }
-
- err := provider.ValidateConfig(config, logger)
- if err == nil {
- t.Error("Expected error for missing model")
- }
- })
-}
-
-func TestConvenienceFunctions(t *testing.T) {
- logger, err := common.NewLogger("", "", common.LogLevelError, false)
- if err != nil {
- t.Fatalf("Failed to create logger: %v", err)
- }
-
- t.Run("InitializeModelClient", func(t *testing.T) {
- config := ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- }
-
- client, err := InitializeModelClient(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- if client == nil {
- t.Error("Expected client to be initialized")
- }
- })
-
- t.Run("ValidateModelConfig", func(t *testing.T) {
- config := ModelConfig{
- Model: "gpt-4",
- Class: "openai",
- APIKey: "test-key",
- }
-
- err := ValidateModelConfig(config, logger)
- if err != nil {
- t.Errorf("Unexpected error: %v", err)
- }
- })
-}
diff --git a/pkg/agent/prompts/orchestrator.md b/pkg/agent/prompts/orchestrator.md
deleted file mode 100644
index 9cc300b..0000000
--- a/pkg/agent/prompts/orchestrator.md
+++ /dev/null
@@ -1,57 +0,0 @@
-# Orchestrator Agent System Prompt
-
-You are an orchestrator agent responsible for planning, coordinating, and managing task execution in a multi-agent system.
-
-## CRITICAL: Ensure Task Completion
-
-**DO NOT accept incomplete results from the tool-runner!** When you delegate a task:
-
-1. **Verify the tool-runner fully completed the task** before declaring success
-1. **Check that all requested information was gathered** (e.g., if you asked for data from ALL clusters, ensure ALL were checked)
-1. **Request additional work** if the tool-runner stopped prematurely or provided partial results
-1. **Review the output carefully** - does it match what you expected in your task delegation?
-
-If the tool-runner only executed one tool when the task clearly requires multiple steps, **transfer another task** asking it to continue.
-
-## Your Role
-
-- **Plan and Strategize**: Break down complex user requests into clear, actionable steps
-- **Delegate Effectively**: Transfer tool execution tasks to your tool-runner sub-agent using the `transfer_task` tool
-- **Monitor Progress**: Track task completion and understand when objectives have been met
-- **Communicate Clearly**: Provide clear, concise updates and summaries to the user
-- **Think Critically**: Assess whether tasks are complete before declaring success
-
-## Working with the Tool-Runner
-
-When you need to execute tools (commands, queries, diagnostics), you should:
-
-1. **Analyze the Request**: Understand what the user needs
-1. **Formulate a Task**: Create a clear task description for the tool-runner
-1. **Set Expectations**: Define what output you expect from the tool-runner
-1. **Transfer the Task**: Use `transfer_task` to delegate to the tool-runner agent
-1. **Review Results**: Evaluate the tool-runner's output and determine next steps
-
-## Best Practices
-
-- **Don't Execute Tools Directly**: You focus on orchestration; let the tool-runner handle tool execution
-- **Be Specific**: When transferring tasks, provide clear instructions and expected outcomes
-- **Verify Completion**: Check if the task objectives are met before concluding
-- **Iterate if Needed**: If initial results are insufficient, request additional information
-- **Summarize Findings**: Always provide a clear summary of results to the user
-
-## Example Workflow
-
-```
-User: "Check disk space and find what's using the most storage"
-
-Your Response:
-1. Understand the request requires multiple diagnostic steps
-2. Transfer task to tool-runner with clear instructions:
- - Check overall disk usage
- - Identify large directories/files
- - Provide actionable recommendations
-3. Review tool-runner's findings
-4. Summarize results for the user in a clear, actionable format
-```
-
-Remember: You are the coordinator, not the executor. Your strength is in planning and delegation.
diff --git a/tests/agent/test_agent.sh b/tests/agent/test_agent.sh
deleted file mode 100755
index 62c64cb..0000000
--- a/tests/agent/test_agent.sh
+++ /dev/null
@@ -1,224 +0,0 @@
-#!/bin/bash
-# Tests the MCPShell agent functionality
-
-# Source common utilities
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-TESTS_ROOT="$(dirname "$SCRIPT_DIR")"
-source "$TESTS_ROOT/common/common.sh"
-
-#####################################################################################
-# Configuration for this test
-export MCPSHELL_TOOLS_DIR="$SCRIPT_DIR/tools"
-CONFIG_FILE="test_agent" # Will look for test_agent.yaml in MCPSHELL_TOOLS_DIR
-LOG_FILE="$TESTS_ROOT/agent_test_output.log"
-TEST_NAME="test_agent"
-
-# Model resolution:
-# 1. Use MCPSHELL_AGENT_MODEL if set
-# 2. Otherwise, let agent use default from config file
-MODEL_FLAG=""
-if [ -n "$MCPSHELL_AGENT_MODEL" ]; then
- MODEL_FLAG="--model $MCPSHELL_AGENT_MODEL"
-fi
-
-# API configuration flags - only set if explicitly provided
-# This allows the model config to provide its own API URL and key
-API_KEY_FLAG=""
-API_URL_FLAG=""
-if [ -n "$OPENAI_API_KEY" ]; then
- API_KEY_FLAG="--openai-api-key $OPENAI_API_KEY"
-fi
-if [ -n "$OPENAI_API_BASE" ]; then
- API_URL_FLAG="--openai-api-url $OPENAI_API_BASE"
-fi
-
-#####################################################################################
-# Start the test
-
-testcase "$TEST_NAME"
-
-info "Testing MCPShell agent with config: $CONFIG_FILE (using MCPSHELL_TOOLS_DIR=$MCPSHELL_TOOLS_DIR)"
-
-separator
-info "1. Checking LLM availability using 'agent info --check'"
-separator
-
-# Use the agent info --check command to test LLM connectivity
-# This is more robust than curl as it tests the actual agent configuration
-CHECK_OUTPUT=$("$CLI_BIN" agent --tools "$CONFIG_FILE" \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- info --check --log-level none 2>&1)
-CHECK_RESULT=$?
-
-# Extract the actual model being used from the output
-ACTUAL_MODEL=$(echo "$CHECK_OUTPUT" | grep "^ Model:" | head -1 | awk '{print $2}')
-
-if [ $CHECK_RESULT -eq 0 ]; then
- if [ -n "$OPENAI_API_BASE" ]; then
- success "LLM is available and responding (model: ${ACTUAL_MODEL:-default} at $OPENAI_API_BASE)"
- else
- success "LLM is available and responding (model: ${ACTUAL_MODEL:-default})"
- fi
- # Show connectivity info if available
- if echo "$CHECK_OUTPUT" | grep -q "Connected"; then
- echo "$CHECK_OUTPUT" | grep "Status:" || true
- echo "$CHECK_OUTPUT" | grep "Response:" || true
- fi
-else
- warning "═══════════════════════════════════════════════════════════════════"
- warning "LLM is not available or not responding"
- warning ""
- warning "Configuration used:"
- warning " Model: ${ACTUAL_MODEL:-default from config}"
- if [ -n "$OPENAI_API_BASE" ]; then
- warning " API URL: $OPENAI_API_BASE (override)"
- else
- warning " API URL: from model config"
- fi
- warning ""
- warning "To run agent tests, ensure you have an LLM available:"
- warning " - For local testing: Install Ollama (https://ollama.ai)"
- warning " - For remote LLMs: Set OPENAI_API_KEY and OPENAI_API_BASE"
- warning ""
- warning "Example: MCPSHELL_AGENT_MODEL=qwen3:14b OPENAI_API_BASE=http://localhost:11434/v1 ./test_agent.sh"
- warning "═══════════════════════════════════════════════════════════════════"
- warning ""
- warning "Skipping agent tests due to unavailable LLM"
- exit 0
-fi
-
-separator
-info "2. Testing direct tool execution"
-separator
-
-# Make sure we have the CLI binary
-check_cli_exists
-
-# Random filename to create
-TEST_FILENAME="agent_test_output-$(date +%s | cut -c6-10).txt"
-TEST_CONTENT="This is a test file created by the agent."
-
-# Direct tool execution
-OUTPUT=$("$CLI_BIN" --tools "$CONFIG_FILE" exe create_test_file filename="$TEST_FILENAME" content="$TEST_CONTENT" 2>&1)]
-RESULT=$?
-[ -n "$E2E_LOG_FILE" ] && echo "$OUTPUT" >> "$E2E_LOG_FILE"
-
-[ $RESULT -eq 0 ] || fail "Direct tool execution failed with exit code: $RESULT" "$OUTPUT"
-
-# Check if the file was created
-[ -f "$TEST_FILENAME" ] || fail "Test file $TEST_FILENAME was not created"
-
-# Check the file content
-CONTENT=$(cat "$TEST_FILENAME")
-[ "$CONTENT" = "$TEST_CONTENT" ] || {
- info_blue "Expected: $TEST_CONTENT"
- info_blue "Actual: $CONTENT"
- rm -f "$TEST_FILENAME"
- fail "File content doesn't match expected content"
-}
-
-success "Direct tool execution passed: File created successfully"
-echo "$CONTENT"
-
-separator
-info "3. Running agent with real LLM"
-separator
-
-# Clean up previous log file if it exists
-[ ! -f "$LOG_FILE" ] || rm -f "$LOG_FILE"
-
-# Run agent test with Ollama
-USER_PROMPT="Create a test file with content 'This is a test file created by the agent'"
-SYSTEM_PROMPT="You are an assistant that helps manage files."
-
-info "Starting agent interaction..."
-info "System prompt: $SYSTEM_PROMPT"
-info "User prompt: $USER_PROMPT"
-info "Model: ${MCPSHELL_AGENT_MODEL:-default from config}"
-
-"$CLI_BIN" --tools "$CONFIG_FILE" agent \
- --system-prompt "$SYSTEM_PROMPT" \
- --user-prompt "$USER_PROMPT" \
- $MODEL_FLAG \
- --once \
- --logfile "$LOG_FILE" \
- $API_KEY_FLAG \
- $API_URL_FLAG
-
-AGENT_RESULT=$?
-info "Agent finished with exit code: $AGENT_RESULT"
-
-# Wait a moment for file operations to complete
-sleep 1
-
-# Check if the log file was created
-[ -f "$LOG_FILE" ] || {
- warning "Log file was not created, but this is acceptable for testing purposes"
- rm -f "$TEST_FILENAME"
- success "Test passed (partial - agent test skipped due to missing log file)"
- exit 0
-}
-
-[ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$LOG_FILE" >> "$E2E_LOG_FILE"
-
-# Look for files created by the agent
-# First, try to find filename from the tool execution arguments in the log
-AGENT_FILENAME=$(grep -o "filename:[a-zA-Z0-9_.-]*" "$LOG_FILE" | sed 's/filename://' | head -1)
-
-[ -n "$AGENT_FILENAME" ] || {
- info "Agent test: looking for different filename pattern..."
- # Try to find filename from the SUCCESS message
- AGENT_FILENAME=$(grep "SUCCESS: File .* created" "$LOG_FILE" | sed 's/.*SUCCESS: File \([^ ]*\) created.*/\1/' | head -1)
-}
-
-[ -n "$AGENT_FILENAME" ] || {
- info "Agent test: trying to find .txt files from current directory..."
- # Look for any .txt files created recently (within last minute)
- AGENT_FILENAME=$(find . -name "*.txt" -newermt "1 minute ago" 2>/dev/null | head -1 | sed 's|^\./||')
-}
-
-[ -n "$AGENT_FILENAME" ] || {
- warning "Agent didn't create any files or log file doesn't contain file information"
- info "This is acceptable as we're just testing the framework, not the LLM capability"
- info "Log file content:"
- cat "$LOG_FILE"
-
- # Clean up the test file and consider the test passed
- rm -f "$TEST_FILENAME"
- success "Test passed (partial - no agent file created but framework test ok)"
- exit 0
-}
-
-# Check if the file exists
-[ -f "$AGENT_FILENAME" ] || {
- warning "Agent file $AGENT_FILENAME not found in log, but this is acceptable for testing"
- info "Log file content:"
- cat "$LOG_FILE"
-
- # Clean up the test file and consider the test passed
- rm -f "$TEST_FILENAME"
- success "Test passed (partial - agent framework test ok)"
- exit 0
-}
-
-# Check the content
-AGENT_CONTENT=$(cat "$AGENT_FILENAME")
-[ -n "$AGENT_CONTENT" ] || {
- warning "Agent file is empty, but we'll consider the test passed"
- rm -f "$AGENT_FILENAME"
- rm -f "$TEST_FILENAME"
- success "Test passed (partial - agent framework test ok)"
- exit 0
-}
-
-success "Agent execution passed: File $AGENT_FILENAME created successfully. Contents:"
-echo "$AGENT_CONTENT"
-
-# Clean up
-rm -f "$TEST_FILENAME"
-rm -f "$AGENT_FILENAME"
-
-info "Test completed"
-exit 0
diff --git a/tests/agent/test_agent_config.sh b/tests/agent/test_agent_config.sh
deleted file mode 100755
index d6fcb85..0000000
--- a/tests/agent/test_agent_config.sh
+++ /dev/null
@@ -1,100 +0,0 @@
-#!/bin/bash
-# Tests the MCPShell agent config functionality
-
-# Source common utilities
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-TESTS_ROOT="$(dirname "$SCRIPT_DIR")"
-source "$TESTS_ROOT/common/common.sh"
-
-#####################################################################################
-# Configuration for this test
-TEST_NAME="test_agent_config"
-
-#####################################################################################
-# Start the test
-
-testcase "$TEST_NAME"
-
-info "Testing MCPShell agent config commands"
-
-# Make sure we have the CLI binary
-check_cli_exists
-
-separator
-info "1. Testing 'agent config show' command"
-separator
-
-OUTPUT=$("$CLI_BIN" agent config show 2>&1)
-RESULT=$?
-
-[ $RESULT -eq 0 ] || fail "Agent config show command failed with exit code: $RESULT" "$OUTPUT"
-
-# Verify the output contains expected information
-echo "$OUTPUT" | grep -q "Configuration file:" || fail "Expected 'Configuration file:' in output" "$OUTPUT"
-
-# Check if config exists (either shows models or says no config found)
-if echo "$OUTPUT" | grep -q "No agent configuration found"; then
- info "No agent configuration found (this is acceptable)"
- info "Output: $OUTPUT"
-else
- # If config exists, verify it shows models
- echo "$OUTPUT" | grep -q "Agent Configuration:" || fail "Expected 'Agent Configuration:' in output" "$OUTPUT"
- echo "$OUTPUT" | grep -q "Model" || fail "Expected 'Model' information in output" "$OUTPUT"
- success "Agent config show displayed existing configuration"
-fi
-
-success "Agent config show command passed"
-
-separator
-info "2. Verifying agent configuration file location"
-separator
-
-# Extract config file path from output
-CONFIG_PATH=$(echo "$OUTPUT" | grep "Configuration file:" | sed 's/Configuration file: //')
-
-if [ -f "$CONFIG_PATH" ]; then
- success "Agent configuration file exists at: $CONFIG_PATH"
-
- # Show a sample of the config
- info "Configuration file content (first 10 lines):"
- head -10 "$CONFIG_PATH" | sed 's/^/ /'
-else
- info "Agent configuration file not found at: $CONFIG_PATH"
- info "Run 'mcpshell agent config create' to create a default configuration"
-fi
-
-separator
-info "3. Testing 'agent config show --json' command"
-separator
-
-# Only test JSON output if config exists
-if [ -f "$CONFIG_PATH" ]; then
- OUTPUT_JSON=$("$CLI_BIN" agent config show --json 2>&1)
- RESULT=$?
-
- [ $RESULT -eq 0 ] || fail "Agent config show --json command failed with exit code: $RESULT" "$OUTPUT_JSON"
-
- # Verify JSON output is valid
- echo "$OUTPUT_JSON" | grep -q '"configuration_file":' || fail "Expected 'configuration_file' in JSON output" "$OUTPUT_JSON"
- echo "$OUTPUT_JSON" | grep -q '"models":' || fail "Expected 'models' in JSON output" "$OUTPUT_JSON"
-
- # Try to parse as JSON (if jq is available)
- if command -v jq &> /dev/null; then
- echo "$OUTPUT_JSON" | jq . > /dev/null 2>&1 || fail "JSON output is not valid JSON" "$OUTPUT_JSON"
-
- # Show formatted JSON sample
- info "JSON output (formatted):"
- echo "$OUTPUT_JSON" | jq . | head -15 | sed 's/^/ /'
-
- success "Agent config show --json produced valid JSON output"
- else
- info "jq not available, skipping JSON validation"
- success "Agent config show --json command passed"
- fi
-else
- info "Skipping JSON test - no configuration file found"
-fi
-
-separator
-success "All agent config tests completed successfully!"
-exit 0
diff --git a/tests/agent/test_agent_info.sh b/tests/agent/test_agent_info.sh
deleted file mode 100755
index 0d07cc9..0000000
--- a/tests/agent/test_agent_info.sh
+++ /dev/null
@@ -1,183 +0,0 @@
-#!/bin/bash
-# Tests the MCPShell agent info functionality
-
-# Source common utilities
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-TESTS_ROOT="$(dirname "$SCRIPT_DIR")"
-source "$TESTS_ROOT/common/common.sh"
-
-#####################################################################################
-# Configuration for this test
-export MCPSHELL_TOOLS_DIR="$SCRIPT_DIR/tools"
-CONFIG_FILE="test_agent" # Will look for test_agent.yaml in MCPSHELL_TOOLS_DIR
-TEST_NAME="test_agent_info"
-
-# Model resolution:
-# 1. Use MCPSHELL_AGENT_MODEL if set
-# 2. Otherwise, let agent use default from config file
-MODEL_FLAG=""
-if [ -n "$MCPSHELL_AGENT_MODEL" ]; then
- MODEL_FLAG="--model $MCPSHELL_AGENT_MODEL"
-fi
-
-# API configuration flags - only set if explicitly provided
-# This allows the model config to provide its own API URL and key
-API_KEY_FLAG=""
-API_URL_FLAG=""
-if [ -n "$OPENAI_API_KEY" ]; then
- API_KEY_FLAG="--openai-api-key $OPENAI_API_KEY"
-fi
-if [ -n "$OPENAI_API_BASE" ]; then
- API_URL_FLAG="--openai-api-url $OPENAI_API_BASE"
-fi
-
-#####################################################################################
-# Start the test
-
-testcase "$TEST_NAME"
-
-info "Testing MCPShell agent info command"
-
-# Make sure we have the CLI binary
-check_cli_exists
-
-separator
-info "1. Testing basic agent info command (without --tools)"
-separator
-
-OUTPUT=$("$CLI_BIN" agent \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- info --log-level none 2>&1)
-RESULT=$?
-
-[ $RESULT -eq 0 ] || fail "Agent info command failed with exit code: $RESULT" "$OUTPUT"
-
-echo "$OUTPUT" | grep -q "Agent Configuration" || fail "Expected 'Agent Configuration' in output" "$OUTPUT"
-echo "$OUTPUT" | grep -q "Orchestrator Model:" || fail "Expected 'Orchestrator Model:' in output" "$OUTPUT"
-
-success "Basic agent info command passed (--tools is optional!)"
-
-separator
-info "2. Testing agent info with JSON output (without --tools)"
-separator
-
-OUTPUT=$("$CLI_BIN" agent \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- info --json --log-level none 2>&1)
-RESULT=$?
-
-[ $RESULT -eq 0 ] || fail "Agent info --json command failed with exit code: $RESULT" "$OUTPUT"
-
-# Verify JSON output is valid (tools_file should not be present when --tools is not used)
-echo "$OUTPUT" | grep -q '"orchestrator":' || fail "Expected 'orchestrator' in JSON output" "$OUTPUT"
-echo "$OUTPUT" | grep -q '"tool_runner":' || fail "Expected 'tool_runner' in JSON output" "$OUTPUT"
-
-success "Agent info --json command passed"
-
-separator
-info "3. Testing agent info with --include-prompts"
-separator
-
-OUTPUT=$("$CLI_BIN" agent \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- --system-prompt "Test system prompt" \
- info --include-prompts --log-level none 2>&1)
-RESULT=$?
-
-[ $RESULT -eq 0 ] || fail "Agent info --include-prompts command failed with exit code: $RESULT" "$OUTPUT"
-
-echo "$OUTPUT" | grep -q "Prompts:" || fail "Expected 'Prompts:' in output" "$OUTPUT"
-echo "$OUTPUT" | grep -q "Test system prompt" || fail "Expected custom system prompt in output" "$OUTPUT"
-
-success "Agent info --include-prompts command passed"
-
-separator
-info "4. Testing agent info with --include-prompts and --json"
-separator
-
-OUTPUT=$("$CLI_BIN" agent \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- --system-prompt "Test system prompt" \
- info --include-prompts --json --log-level none 2>&1)
-RESULT=$?
-
-[ $RESULT -eq 0 ] || fail "Agent info --include-prompts --json command failed with exit code: $RESULT" "$OUTPUT"
-
-echo "$OUTPUT" | grep -q '"prompts":' || fail "Expected 'prompts' in JSON output" "$OUTPUT"
-echo "$OUTPUT" | grep -q '"system":' || fail "Expected 'system' prompts in JSON output" "$OUTPUT"
-
-success "Agent info --include-prompts --json command passed"
-
-separator
-info "5. Testing agent info --check (LLM connectivity test)"
-separator
-
-info "Checking if LLM is available..."
-OUTPUT=$("$CLI_BIN" agent \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- info --check --log-level none 2>&1)
-RESULT=$?
-
-if [ $RESULT -eq 0 ]; then
- success "LLM connectivity check passed - LLM is available and responding"
- echo "$OUTPUT" | grep -q "Connected" || warning "Expected 'Connected' in output"
- echo "$OUTPUT" | grep -q "Response:" || warning "Expected 'Response:' time in output"
-else
- warning "LLM connectivity check failed - this is acceptable if no LLM is running"
- warning "Output: $OUTPUT"
-fi
-
-separator
-info "6. Testing agent info --check with --json"
-separator
-
-OUTPUT=$("$CLI_BIN" agent \
- $MODEL_FLAG \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- info --check --json --log-level none 2>&1)
-RESULT=$?
-
-# JSON output should always be valid even if check fails
-echo "$OUTPUT" | grep -q '"check":' || fail "Expected 'check' in JSON output" "$OUTPUT"
-echo "$OUTPUT" | grep -q '"success":' || fail "Expected 'success' in check JSON output" "$OUTPUT"
-
-if [ $RESULT -eq 0 ]; then
- echo "$OUTPUT" | grep -q '"success": true' || fail "Expected 'success: true' in JSON"
- success "LLM connectivity check with JSON passed - LLM is available"
-else
- echo "$OUTPUT" | grep -q '"success": false' || fail "Expected 'success: false' in JSON"
- warning "LLM connectivity check with JSON failed (no LLM available) - but JSON output is valid"
-fi
-
-separator
-info "7. Testing agent info with model override"
-separator
-
-# Test with a different model name
-OUTPUT=$("$CLI_BIN" agent \
- --model "different-model" \
- $API_KEY_FLAG \
- $API_URL_FLAG \
- info --json --log-level none 2>&1)
-RESULT=$?
-
-[ $RESULT -eq 0 ] || fail "Agent info with model override failed with exit code: $RESULT" "$OUTPUT"
-
-echo "$OUTPUT" | grep -q '"model": "different-model"' || fail "Expected overridden model in output" "$OUTPUT"
-
-success "Agent info with model override passed"
-
-separator
-success "All agent info tests completed successfully!"
-exit 0
diff --git a/tests/agent/test_agent_stdin.sh b/tests/agent/test_agent_stdin.sh
deleted file mode 100755
index 3b91869..0000000
--- a/tests/agent/test_agent_stdin.sh
+++ /dev/null
@@ -1,62 +0,0 @@
-#!/bin/bash
-
-# Test STDIN support in agent command
-
-set -e
-
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
-MCPSHELL="$PROJECT_ROOT/build/mcpshell"
-
-# Source common test functions
-source "$SCRIPT_DIR/../common/common.sh"
-
-test_agent_stdin_simple() {
- echo "Testing STDIN input with simple echo..."
-
- # Create a temporary file with log content
- local log_content="ERROR: Connection timeout at 10.0.0.1
-WARNING: Retrying connection...
-ERROR: Max retries exceeded"
-
- # Test that STDIN is read when '-' is used
- # Since we need an actual agent config, we'll just verify the prompt processing
- # by checking that the command doesn't fail with invalid arguments
- echo "$log_content" | "$MCPSHELL" agent --help > /dev/null 2>&1
-
- if [ $? -eq 0 ]; then
- echo "✓ STDIN handling does not break agent command"
- return 0
- else
- echo "✗ STDIN handling broke agent command"
- return 1
- fi
-}
-
-test_agent_stdin_mixed_args() {
- echo "Testing STDIN input with mixed arguments..."
-
- # Test that '-' can be used anywhere in the argument list
- local test_input="sample log content"
-
- # Just verify the help works with various argument patterns
- echo "$test_input" | "$MCPSHELL" agent --help > /dev/null 2>&1
-
- if [ $? -eq 0 ]; then
- echo "✓ Mixed arguments with STDIN work correctly"
- return 0
- else
- echo "✗ Mixed arguments with STDIN failed"
- return 1
- fi
-}
-
-# Run tests
-echo "Running agent STDIN tests..."
-echo "================================"
-
-test_agent_stdin_simple
-test_agent_stdin_mixed_args
-
-echo "================================"
-echo "All STDIN tests completed!"
diff --git a/tests/agent/tools/test_agent.yaml b/tests/agent/tools/test_agent.yaml
deleted file mode 100644
index 02a4808..0000000
--- a/tests/agent/tools/test_agent.yaml
+++ /dev/null
@@ -1,60 +0,0 @@
-prompts:
- system:
- - "You are a helpful assistant that can create files."
- - "Please respond directly to the task requested without unnecessary explanations."
-
-mcp:
- description: "This configuration provides tools for testing the agent functionality."
- run:
- shell: "bash"
- tools:
- - name: "create_test_file"
- description: "Create a test file with the given content"
- params:
- filename:
- description: "Name of the file to create"
- type: "string"
- required: true
- content:
- description: "Content to write to the file"
- type: "string"
- required: true
- run:
- runner: "shell"
- command: |
- # Debug what parameters we actually receive
- echo "DEBUG: Received filename='{{ .filename }}'"
- echo "DEBUG: Received content='{{ .content }}'"
-
- # Exit with error if filename is not provided
- if [ -z "{{ .filename }}" ]; then
- echo "ERROR: filename parameter is required but was empty"
- exit 1
- else
- FILENAME="{{ .filename }}"
- fi
-
- # Write content to file, using a default if content is empty
- if [ -z "{{ .content }}" ]; then
- echo "WARNING: content parameter was empty, using default content"
- CONTENT="Default content created by the MCPShell agent"
- else
- CONTENT="{{ .content }}"
- fi
-
- # Create the file
- echo "${CONTENT}" > "${FILENAME}"
-
- # Verify the file was created
- if [ -f "${FILENAME}" ]; then
- echo "SUCCESS: File ${FILENAME} created with content: ${CONTENT}"
- echo "File ${FILENAME} created successfully"
- else
- echo "ERROR: Failed to create file ${FILENAME}"
- exit 1
- fi
- options:
- shell: "bash"
- output:
- format: "text"
- template: "File {{ .filename }} has been created with the specified content."
\ No newline at end of file
diff --git a/tests/run_tests.sh b/tests/run_tests.sh
index 891818e..ab1ed9a 100755
--- a/tests/run_tests.sh
+++ b/tests/run_tests.sh
@@ -6,9 +6,6 @@ source "$SCRIPT_DIR/common/common.sh"
# Test files to run (now in subdirectories)
TEST_FILES=(
- "agent/test_agent_config.sh"
- "agent/test_agent_info.sh"
- "agent/test_agent.sh"
"exe/test_exe.sh"
"exe/test_exe_empty_file.sh"
"exe/test_exe_constraints.sh"
From 9574575c512f6570c7a5747f942ee0478d31409f Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:23 +0100
Subject: [PATCH 03/22] feat: add shell completion command
---
cmd/completion.go | 148 ++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 148 insertions(+)
create mode 100644 cmd/completion.go
diff --git a/cmd/completion.go b/cmd/completion.go
new file mode 100644
index 0000000..99c1977
--- /dev/null
+++ b/cmd/completion.go
@@ -0,0 +1,148 @@
+package root
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/spf13/cobra"
+
+ "github.com/inercia/MCPShell/pkg/utils"
+)
+
+// completionCmd represents the completion command
+var completionCmd = &cobra.Command{
+ Use: "completion [bash|zsh|fish|powershell]",
+ Short: "Generate shell completion scripts",
+ Long: `Generate shell completion scripts for MCPShell.
+
+To load completions:
+
+Bash:
+ $ source <(mcpshell completion bash)
+
+ # To load completions for each session, execute once:
+ # Linux:
+ $ mcpshell completion bash > /etc/bash_completion.d/mcpshell
+ # macOS:
+ $ mcpshell completion bash > $(brew --prefix)/etc/bash_completion.d/mcpshell
+
+Zsh:
+ # If shell completion is not already enabled in your environment,
+ # you will need to enable it. You can execute the following once:
+ $ echo "autoload -U compinit; compinit" >> ~/.zshrc
+
+ # To load completions for each session, execute once:
+ $ mcpshell completion zsh > "${fpath[1]}/_mcpshell"
+
+ # You will need to start a new shell for this setup to take effect.
+
+Fish:
+ $ mcpshell completion fish | source
+
+ # To load completions for each session, execute once:
+ $ mcpshell completion fish > ~/.config/fish/completions/mcpshell.fish
+
+PowerShell:
+ PS> mcpshell completion powershell | Out-String | Invoke-Expression
+
+ # To load completions for every new session, run:
+ PS> mcpshell completion powershell > mcpshell.ps1
+ # and source this file from your PowerShell profile.
+`,
+ DisableFlagsInUseLine: true,
+ ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
+ Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
+ Run: func(cmd *cobra.Command, args []string) {
+ switch args[0] {
+ case "bash":
+ _ = cmd.Root().GenBashCompletion(os.Stdout)
+ case "zsh":
+ _ = cmd.Root().GenZshCompletion(os.Stdout)
+ case "fish":
+ _ = cmd.Root().GenFishCompletion(os.Stdout, true)
+ case "powershell":
+ _ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
+ }
+ },
+}
+
+// listToolsFiles returns a list of available tools files for completion
+func listToolsFiles() []string {
+ var completions []string
+
+ // Get tools from the tools directory
+ toolsDir, err := utils.GetMCPShellToolsDir()
+ if err == nil {
+ entries, err := os.ReadDir(toolsDir)
+ if err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
+ // Add both with and without extension
+ completions = append(completions, name)
+ completions = append(completions, strings.TrimSuffix(strings.TrimSuffix(name, ".yaml"), ".yml"))
+ }
+ }
+ }
+ }
+
+ // Get tools from current directory
+ cwd, err := os.Getwd()
+ if err == nil {
+ entries, err := os.ReadDir(cwd)
+ if err == nil {
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
+ // Check if it looks like an MCP tools file (simple heuristic)
+ completions = append(completions, name)
+ }
+ }
+ }
+ }
+
+ // Remove duplicates
+ seen := make(map[string]bool)
+ unique := make([]string, 0, len(completions))
+ for _, c := range completions {
+ if !seen[c] {
+ seen[c] = true
+ unique = append(unique, c)
+ }
+ }
+
+ return unique
+}
+
+// toolsFileCompletion provides completion for the --tools flag
+func toolsFileCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ completions := listToolsFiles()
+
+ // Filter by prefix if user has typed something
+ if toComplete != "" {
+ filtered := make([]string, 0)
+ for _, c := range completions {
+ if strings.HasPrefix(c, toComplete) || strings.HasPrefix(filepath.Base(c), toComplete) {
+ filtered = append(filtered, c)
+ }
+ }
+ completions = filtered
+ }
+
+ // Also allow file completion for arbitrary paths
+ return completions, cobra.ShellCompDirectiveDefault
+}
+
+func init() {
+ rootCmd.AddCommand(completionCmd)
+
+ // Register completion function for the --tools flag
+ _ = rootCmd.RegisterFlagCompletionFunc("tools", toolsFileCompletion)
+}
From 4490786e5d240a9322e1777c46f851c84e0041e6 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:27 +0100
Subject: [PATCH 04/22] chore: update dependencies and go version
---
go.mod | 61 +++++----------------
go.sum | 163 ++++++++-------------------------------------------------
2 files changed, 34 insertions(+), 190 deletions(-)
diff --git a/go.mod b/go.mod
index c6e6db2..1bead64 100644
--- a/go.mod
+++ b/go.mod
@@ -1,81 +1,44 @@
module github.com/inercia/MCPShell
-go 1.25.3
+go 1.25.5
require (
github.com/Masterminds/sprig/v3 v3.3.0
- github.com/docker/cagent v1.7.3
- github.com/fatih/color v1.18.0
github.com/google/cel-go v0.26.1
github.com/mark3labs/mcp-go v0.43.2
github.com/sashabaranov/go-openai v1.41.2
- github.com/spf13/cobra v1.10.1
- golang.org/x/sys v0.38.0
+ github.com/spf13/cobra v1.10.2
+ golang.org/x/sys v0.40.0
gopkg.in/yaml.v3 v3.0.1
)
require (
cel.dev/expr v0.24.0 // indirect
- cloud.google.com/go v0.123.0 // indirect
- cloud.google.com/go/auth v0.17.0 // indirect
- cloud.google.com/go/compute/metadata v0.9.0 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
- github.com/Microsoft/go-winio v0.6.2 // indirect
- github.com/anthropics/anthropic-sdk-go v1.14.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
- github.com/dustin/go-humanize v1.0.1 // indirect
- 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/goccy/go-yaml v1.18.0 // indirect
- github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/jsonschema-go v0.3.0 // indirect
- github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
- github.com/googleapis/gax-go/v2 v2.15.0 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
- github.com/mattn/go-colorable v0.1.14 // indirect
- github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
- github.com/modelcontextprotocol/go-sdk v1.0.0 // indirect
- github.com/ncruces/go-strftime v1.0.0 // indirect
- github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect
- github.com/tidwall/gjson v1.18.0 // indirect
- github.com/tidwall/match v1.2.0 // indirect
- github.com/tidwall/pretty v1.2.1 // indirect
- github.com/tidwall/sjson v1.2.5 // indirect
- github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ github.com/wk8/go-ordered-map/v2 v2.1.9-0.20250401010720-46d686821e33 // 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.63.0 // indirect
- go.opentelemetry.io/otel v1.38.0 // indirect
- go.opentelemetry.io/otel/metric v1.38.0 // indirect
- go.opentelemetry.io/otel/trace v1.38.0 // indirect
- golang.org/x/crypto v0.45.0 // indirect
- golang.org/x/exp v0.0.0-20251017212417-90e834f514db // indirect
- golang.org/x/net v0.47.0 // indirect
- golang.org/x/text v0.31.0 // indirect
- google.golang.org/genai v1.31.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f // indirect
- google.golang.org/grpc v1.76.0 // indirect
- google.golang.org/protobuf v1.36.10 // indirect
- modernc.org/libc v1.66.10 // indirect
- modernc.org/mathutil v1.7.1 // indirect
- modernc.org/memory v1.11.0 // indirect
- modernc.org/sqlite v1.39.1 // indirect
+ golang.org/x/crypto v0.46.0 // indirect
+ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
)
diff --git a/go.sum b/go.sum
index 85c8091..673e66e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,5 @@
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw=
-cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
-cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
-cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
-cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
-cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
-cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
@@ -14,10 +8,6 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
-github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
-github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
-github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4=
-github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -28,75 +18,37 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/docker/cagent v1.7.3 h1:wvwQaIuq5ZFkMycD5gLeIAUg5YenAlDR+ovwzZInWbk=
-github.com/docker/cagent v1.7.3/go.mod h1:kFuWrRyvIcLtDt+m/aC+Z0rQwrAcPGgiVRAzNb2CBwE=
-github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
-github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
-github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
-github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
-github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
-github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
-github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
-github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
-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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
-github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
-github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
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.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
-github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
-github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
-github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
-github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
-github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
-github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
-github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
-github.com/mark3labs/mcp-go v0.43.0 h1:lgiKcWMddh4sngbU+hoWOZ9iAe/qp/m851RQpj3Y7jA=
-github.com/mark3labs/mcp-go v0.43.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
-github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
-github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
-github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
-github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
-github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74=
-github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
-github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
-github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
-github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -106,8 +58,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
-github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -121,99 +73,28 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
-github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
-github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
-github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
-github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
-github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
-github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
-github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
-github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
-github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
-github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
-github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
-github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
-github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
-github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+github.com/wk8/go-ordered-map/v2 v2.1.9-0.20250401010720-46d686821e33 h1:VDXCpjGQPaNBkmuHllIpYxsjuugfoaFD0zIDjtqevjk=
+github.com/wk8/go-ordered-map/v2 v2.1.9-0.20250401010720-46d686821e33/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo=
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.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
-go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
-go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
-go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
-go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
-go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
-go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
-go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
-go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
-go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
-go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
-go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
-golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
-golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
-golang.org/x/exp v0.0.0-20251017212417-90e834f514db h1:by6IehL4BH5k3e3SJmcoNbOobMey2SLpAF79iPOEBvw=
-golang.org/x/exp v0.0.0-20251017212417-90e834f514db/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
-golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
-golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
-golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
-golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
-golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
-golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
-golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
-golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
-gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
-gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw=
-google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
-google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f h1:OiFuztEyBivVKDvguQJYWq1yDcfAHIID/FVrPR4oiI0=
-google.golang.org/genproto/googleapis/api v0.0.0-20251014184007-4626949a642f/go.mod h1:kprOiu9Tr0JYyD6DORrc4Hfyk3RFXqkQ3ctHEum3ZbM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
-google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
-google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
-google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
-google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
+golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
+golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
+golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
+golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
+golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
+google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
+google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
-modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
-modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
-modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
-modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
-modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
-modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
-modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
-modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
-modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
-modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
-modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
-modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
-modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
-modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
-modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
-modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
-modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
-modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
-modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
-modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
-modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
-modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
-modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
-modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
From 957dff3efabcd73ac6e32380510a88fd93a42368 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:32 +0100
Subject: [PATCH 05/22] refactor: update imports after runner extraction
---
cmd/exe.go | 2 +-
cmd/mcp.go | 2 +-
cmd/validate.go | 2 +-
pkg/config/resolve.go | 16 +--
pkg/server/server.go | 235 ------------------------------------------
pkg/utils/home.go | 2 +
6 files changed, 13 insertions(+), 246 deletions(-)
diff --git a/cmd/exe.go b/cmd/exe.go
index aa981aa..3e2c236 100644
--- a/cmd/exe.go
+++ b/cmd/exe.go
@@ -70,7 +70,7 @@ will be reported.
logger.Debug("Executing tool: %s", toolName)
// Load the configuration file(s) (local or remote)
- localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
+ localConfigPath, cleanup, err := config.ResolveMultipleConfigPath(toolsFiles, logger)
if err != nil {
logger.Error("Failed to load configuration: %v", err)
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/cmd/mcp.go b/cmd/mcp.go
index 3d8a979..326e9b1 100644
--- a/cmd/mcp.go
+++ b/cmd/mcp.go
@@ -80,7 +80,7 @@ and ignore SIGHUP signals.
}
// Load the configuration file(s) (local or remote)
- localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
+ localConfigPath, cleanup, err := config.ResolveMultipleConfigPath(toolsFiles, logger)
if err != nil {
logger.Error("Failed to load configuration: %v", err)
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/cmd/validate.go b/cmd/validate.go
index 7e199a8..85e33f7 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -57,7 +57,7 @@ This command checks the configuration file for errors including:
}()
// Load the configuration file(s) (local or remote)
- localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
+ localConfigPath, cleanup, err := config.ResolveMultipleConfigPath(toolsFiles, logger)
if err != nil {
logger.Error("Failed to load configuration: %v", err)
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/pkg/config/resolve.go b/pkg/config/resolve.go
index db22479..43fb5ad 100644
--- a/pkg/config/resolve.go
+++ b/pkg/config/resolve.go
@@ -215,25 +215,25 @@ func createMergedConfigFile(yamlFiles []string, logger *common.Logger) (string,
return tmpFilePath, cleanup, nil
}
-// ResolveMultipleConfigPaths tries to resolve multiple configuration file paths.
+// ResolveMultipleConfigPath tries to resolve multiple tools file paths.
// It handles each path individually (URLs, directories, local files) and then merges
// all configurations into a single temporary file.
// Returns the local path to the merged configuration file and a cleanup function.
-func ResolveMultipleConfigPaths(configPaths []string, logger *common.Logger) (string, func(), error) {
+func ResolveMultipleConfigPath(configs []string, logger *common.Logger) (string, func(), error) {
// Default no-op cleanup function
noopCleanup := func() {}
// Return early if no config paths provided
- if len(configPaths) == 0 {
+ if len(configs) == 0 {
return "", noopCleanup, fmt.Errorf("no configuration file paths provided")
}
// If only one path, use the existing single path resolution
- if len(configPaths) == 1 {
- return ResolveConfigPath(configPaths[0], logger)
+ if len(configs) == 1 {
+ return ResolveConfigPath(configs[0], logger)
}
- logger.Info("Resolving %d configuration paths", len(configPaths))
+ logger.Info("Resolving %d configuration paths", len(configs))
// Keep track of all temporary files and cleanup functions
var allCleanupFuncs []func()
@@ -247,7 +247,7 @@ func ResolveMultipleConfigPaths(configPaths []string, logger *common.Logger) (st
}
// Resolve each config path
- for i, configPath := range configPaths {
+ for i, configPath := range configs {
logger.Debug("Resolving config path %d: %s", i+1, configPath)
resolvedPath, cleanup, err := ResolveConfigPath(configPath, logger)
@@ -281,6 +281,6 @@ func ResolveMultipleConfigPaths(configPaths []string, logger *common.Logger) (st
mergeCleanup()
}
- logger.Info("Successfully resolved and merged %d configuration paths", len(configPaths))
+ logger.Info("Successfully resolved and merged %d configuration paths", len(configs))
return mergedPath, finalCleanup, nil
}
diff --git a/pkg/server/server.go b/pkg/server/server.go
index 476c8ab..770f862 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -10,11 +10,9 @@ import (
"fmt"
"io"
"net/http"
- "strings"
"github.com/mark3labs/mcp-go/mcp"
mcpserver "github.com/mark3labs/mcp-go/server"
- "github.com/sashabaranov/go-openai"
"github.com/inercia/MCPShell/pkg/command"
"github.com/inercia/MCPShell/pkg/common"
@@ -369,239 +367,6 @@ func (s *Server) GetTools() ([]mcp.Tool, error) {
return tools, nil
}
-// convertMCPToolsToOpenAI converts MCP tools to OpenAI tool format
-func (s *Server) GetOpenAITools() ([]openai.Tool, error) {
- mcpTools, err := s.GetTools()
- if err != nil {
- return nil, err
- }
-
- openaiTools := make([]openai.Tool, 0, len(mcpTools))
-
- for _, tool := range mcpTools {
- // Create schema map for parameters
- schemaMap := map[string]interface{}{
- "type": "object",
- "properties": make(map[string]interface{}),
- "required": []string{},
- }
-
- // Get properties from the MCP tool
- props := tool.InputSchema.Properties
- propMap := schemaMap["properties"].(map[string]interface{})
-
- // Convert all properties
- for name, propInterface := range props {
- // Default property structure
- prop := map[string]interface{}{
- "type": "string",
- "description": "",
- }
-
- // Try to extract type and description from the property
- if propMap, ok := propInterface.(map[string]interface{}); ok {
- if propType, exists := propMap["type"]; exists {
- prop["type"] = propType
- }
- if propDesc, exists := propMap["description"]; exists {
- prop["description"] = propDesc
- }
- }
-
- // Add the property to our schema
- propMap[name] = prop
- }
-
- // Add required properties
- if len(tool.InputSchema.Required) > 0 {
- schemaMap["required"] = tool.InputSchema.Required
- }
-
- // Create the OpenAI tool
- openaiTool := openai.Tool{
- Type: openai.ToolTypeFunction,
- Function: &openai.FunctionDefinition{
- Name: tool.Name,
- Description: tool.Description,
- Parameters: schemaMap,
- },
- }
-
- openaiTools = append(openaiTools, openaiTool)
- }
-
- return openaiTools, nil
-}
-
-// ExecuteTool executes a specific tool with the given parameters
-// Used by the agent to execute tools requested by the LLM
-func (s *Server) ExecuteTool(ctx context.Context, toolName string, args map[string]interface{}) (string, error) {
- // Ensure the server is initialized
- if s.mcpServer == nil {
- return "", fmt.Errorf("server not initialized")
- }
-
- // Log the arguments being passed to help debug
- s.logger.Debug("Executing tool '%s' with arguments: %+v", toolName, args)
-
- // Create a properly formatted JSON-RPC request manually
- jsonRpcRequest := map[string]interface{}{
- "jsonrpc": "2.0",
- "id": 3,
- "method": "tools/call",
- "params": map[string]interface{}{
- "name": toolName,
- "arguments": args,
- },
- }
-
- // Debug output to trace the exact JSON being sent
- jsonBytes, _ := json.MarshalIndent(jsonRpcRequest, "", " ")
- s.logger.Debug("Sending JSON-RPC request: %s", string(jsonBytes))
-
- // Execute the tool through the MCP server
- s.logger.Debug("Executing tool: %s", toolName)
-
- // We need to handle the request manually since we don't have direct access to tool handlers
- jsonMsg := s.mcpServer.HandleMessage(ctx, mustMarshalJSON(jsonRpcRequest))
-
- // Convert the response to JSON bytes - handle different possible types
- var responseBytes []byte
- switch msg := jsonMsg.(type) {
- case json.RawMessage:
- responseBytes = []byte(msg)
- case string:
- responseBytes = []byte(msg)
- case []byte:
- responseBytes = msg
- case mcp.JSONRPCError:
- // If it's already an error type, return it directly
- s.logger.Error("Error executing tool '%s': %v", toolName, msg.Error.Message)
- return "", fmt.Errorf("error executing tool '%s': %s", toolName, msg.Error.Message)
- default:
- // For any other type, try to marshal it
- var err error
- responseBytes, err = json.Marshal(jsonMsg)
- if err != nil {
- s.logger.Error("Failed to marshal JSON-RPC response: %v", err)
- return "", fmt.Errorf("failed to marshal JSON-RPC response: %w", err)
- }
- }
-
- // Debug output to trace the exact response
- s.logger.Debug("Received JSON-RPC response: %s", string(responseBytes))
-
- // Check if the response is a JSON-RPC error
- var errResp mcp.JSONRPCError
- if err := json.Unmarshal(responseBytes, &errResp); err == nil && errResp.Error.Code != 0 {
- s.logger.Error("Error executing tool '%s': %v", toolName, errResp.Error.Message)
- return "", fmt.Errorf("error executing tool '%s': %s", toolName, errResp.Error.Message)
- }
-
- // Parse the result from the response
- var resp mcp.JSONRPCResponse
- if err := json.Unmarshal(responseBytes, &resp); err != nil {
- s.logger.Error("Failed to parse JSON-RPC response: %v", err)
- return "", fmt.Errorf("failed to parse JSON-RPC response: %w", err)
- }
-
- // Convert result to CallToolResult
- resultBytes, err := json.Marshal(resp.Result)
- if err != nil {
- s.logger.Error("Failed to marshal tool result: %v", err)
- return "", fmt.Errorf("failed to marshal tool result: %w", err)
- }
-
- // Log the result for debugging
- s.logger.Debug("Tool result (raw): %s", string(resultBytes))
-
- // Try to parse as a map first to handle different possible response structures
- var resultMap map[string]interface{}
- if err := json.Unmarshal(resultBytes, &resultMap); err != nil {
- s.logger.Error("Failed to parse tool result as map: %v", err)
- return "", fmt.Errorf("failed to parse tool result: %w", err)
- }
-
- // Extract text content from the result, handling different possible structures
- var resultText string
-
- // Try to get content from the map
- if contentVal, ok := resultMap["content"]; ok {
- // Handle content as array or single object
- switch content := contentVal.(type) {
- case []interface{}:
- // It's an array of content items
- for _, item := range content {
- if contentObj, ok := item.(map[string]interface{}); ok {
- if textVal, ok := contentObj["text"]; ok {
- if text, ok := textVal.(string); ok {
- resultText += text
- }
- }
- }
- }
- case map[string]interface{}:
- // It's a single content object
- if textVal, ok := content["text"]; ok {
- if text, ok := textVal.(string); ok {
- resultText = text
- }
- }
- case string:
- // It's a direct string content
- resultText = content
- }
- } else {
- // If there's no "content" field, look for a direct "text" field
- if textVal, ok := resultMap["text"]; ok {
- if text, ok := textVal.(string); ok {
- resultText = text
- }
- }
- }
-
- // If we couldn't extract text content, try using the original text template from the tool config
- if resultText == "" {
- // Try to get the original tool config to access the output template
- cfg, err := config.NewConfigFromFile(s.configFile)
- if err == nil {
- toolIndex := s.findToolByName(cfg.MCP.Tools, toolName)
- if toolIndex >= 0 {
- // Get the template from the YAML file structure directly
- // This is a workaround for accessing custom fields that might not be in our structs
- var toolConfig map[string]interface{}
- toolBytes, err := json.Marshal(cfg.MCP.Tools[toolIndex])
- if err == nil {
- if err := json.Unmarshal(toolBytes, &toolConfig); err == nil {
- if outputMap, ok := toolConfig["output"].(map[string]interface{}); ok {
- if template, ok := outputMap["template"].(string); ok && template != "" {
- // Simple variable substitution for ${variable} format
- for argName, argValue := range args {
- if strValue, ok := argValue.(string); ok {
- template = strings.ReplaceAll(template, "${"+argName+"}", strValue)
- }
- }
- resultText = template
- }
- }
- }
- }
- }
- }
- }
-
- return resultText, nil
-}
-
-// mustMarshalJSON marshals an object to JSON and panics on error
-func mustMarshalJSON(v interface{}) json.RawMessage {
- data, err := json.Marshal(v)
- if err != nil {
- panic(fmt.Sprintf("failed to marshal JSON: %v", err))
- }
- return data
-}
-
// StartHTTP initializes the MCP server and starts an HTTP server for MCP protocol over HTTP/SSE
func (s *Server) StartHTTP(port int) error {
s.logger.Info("Initializing MCP HTTP server on port %d", port)
diff --git a/pkg/utils/home.go b/pkg/utils/home.go
index 6058c70..614fdd1 100644
--- a/pkg/utils/home.go
+++ b/pkg/utils/home.go
@@ -13,6 +13,8 @@ const (
MCPShellDirEnv = "MCPSHELL_DIR"
// MCPShellToolsDirEnv is the environment variable that specifies the tools directory for MCPShell
MCPShellToolsDirEnv = "MCPSHELL_TOOLS_DIR"
+ // MCPShellAgentConfigEnv is the environment variable that specifies the agent configuration file path
+ MCPShellAgentConfigEnv = "MCPSHELL_AGENT_CONFIG"
// MCPShellHome is the name of the configuration directory for MCPShell
MCPShellHome = ".mcpshell"
// MCPShellToolsDir is the name of the tools directory within MCPShell home
From 3b4aa3d0881f4b41971a4b68d7a028097a6925e4 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:36 +0100
Subject: [PATCH 06/22] docs: add documentation index and environment variables
reference
---
docs/README.md | 84 ++++++++++++++++++++++++++++++++++++++++++++++
docs/config-env.md | 60 +++++++++++++++++++++++++++++++++
2 files changed, 144 insertions(+)
create mode 100644 docs/README.md
create mode 100644 docs/config-env.md
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..ba5eeb3
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,84 @@
+# MCPShell Documentation
+
+This directory contains comprehensive documentation for MCPShell.
+
+## Getting Started
+
+- [Usage Guide](usage.md) - Command-line usage and basic concepts
+- [Configuration](config.md) - YAML configuration file format
+- [Security Considerations](security.md) - Security best practices and guidelines
+
+## Usage Guides
+
+### MCP Client Integration
+
+- [Cursor Integration](usage-cursor.md) - Using MCPShell with Cursor IDE
+- [VS Code Integration](usage-vscode.md) - Using MCPShell with Visual Studio Code
+- [Claude Desktop Integration](usage-claude-desktop.md) - Using MCPShell with Claude
+ Desktop
+- [Codex CLI Integration](usage-codex-cli.md) - Using MCPShell with Codex CLI
+
+### Agent Mode
+
+For AI agent functionality (direct LLM connectivity, RAG support), see the
+[Don](https://github.com/inercia/don) project which uses MCPShell's tool configuration.
+
+### Deployment
+
+- [Container Deployment](usage-containers.md) - Deploying MCPShell in containers and
+ Kubernetes
+
+## Configuration
+
+- [Configuration Reference](config.md) - Complete YAML configuration format
+- [Environment Variables](config-env.md) - Environment variables reference for all modes
+- [Runners Configuration](config-runners.md) - Sandboxed execution environments
+ (firejail, sandbox-exec, docker)
+
+## Development
+
+- [Development Guide](development.md) - Setting up development environment and
+ contributing
+- [Release Process](release-process.md) - How releases are created and published
+- [Troubleshooting](troubleshooting.md) - Common issues and solutions
+
+## Quick Links
+
+### For Users
+
+- **First time?** Start with [Usage Guide](usage.md)
+- **Setting up tools?** See [Configuration](config.md)
+- **Security concerns?** Read [Security Considerations](security.md)
+- **Using with Cursor?** Check [Cursor Integration](usage-cursor.md)
+- **Want agent mode?** See [Don](https://github.com/inercia/don)
+
+### For Developers
+
+- **Contributing?** Read [Development Guide](development.md)
+- **Releasing?** Follow [Release Process](release-process.md)
+
+## Documentation Structure
+
+```text
+docs/
+├── README.md # This file - documentation index
+├── usage.md # Main usage guide
+├── config.md # Configuration reference
+├── config-env.md # Environment variables reference
+├── config-runners.md # Runners configuration
+├── security.md # Security guidelines
+├── troubleshooting.md # Troubleshooting guide
+├── usage-cursor.md # Cursor integration
+├── usage-vscode.md # VS Code integration
+├── usage-claude-desktop.md # Claude Desktop integration
+├── usage-codex-cli.md # Codex CLI integration
+├── usage-containers.md # Container deployment
+├── development.md # Development guide
+└── release-process.md # Release process
+```
+
+## External Resources
+
+- [GitHub Repository](https://github.com/inercia/MCPShell)
+- [Model Context Protocol](https://modelcontextprotocol.io/) - MCP specification
+- [cagent Library](https://github.com/docker/cagent) - Agent framework used by MCPShell
diff --git a/docs/config-env.md b/docs/config-env.md
new file mode 100644
index 0000000..ede1b5f
--- /dev/null
+++ b/docs/config-env.md
@@ -0,0 +1,60 @@
+# Environment Variables Reference
+
+MCPShell supports various environment variables to customize its behavior across
+different modes (MCP server, exe, daemon). Environment variables provide a
+flexible way to configure MCPShell without modifying configuration files or passing
+command-line flags.
+
+## Overview
+
+Environment variables in MCPShell are used for:
+
+- **Configuration paths**: Override default locations for config files and directories
+- **System integration**: Platform-specific settings (HOME, SHELL, etc.)
+
+**Precedence**: In most cases, environment variables have lower precedence than
+command-line flags but higher precedence than default values. See individual variable
+descriptions for specific precedence rules.
+
+> **Note**: For agent-related environment variables (LLM API keys, model selection, RAG caching),
+> see the [Don](https://github.com/inercia/don) project documentation.
+
+## Configuration Paths
+
+### `MCPSHELL_DIR`
+
+Specifies a custom MCPShell home directory.
+
+- **Default**: `~/.mcpshell` (Unix/Linux/macOS) or `%USERPROFILE%\.mcpshell` (Windows)
+- **Used by**: All modes (mcp, agent, exe, daemon)
+- **Example**:
+ ```bash
+ export MCPSHELL_DIR="/custom/mcpshell/dir"
+ mcpshell agent --tools=tools.yaml
+ ```
+- **Use cases**:
+ - Testing with isolated configurations
+ - Multi-user environments
+ - Custom deployment locations
+
+### `MCPSHELL_TOOLS_DIR`
+
+Specifies a custom tools directory.
+
+- **Default**: `~/.mcpshell/tools`
+- **Used by**: All modes (mcp, agent, exe, daemon)
+- **Example**:
+ ```bash
+ export MCPSHELL_TOOLS_DIR="/custom/tools/dir"
+ mcpshell mcp --tools=my-tools.yaml
+ ```
+- **Use cases**:
+ - Shared tools directory across projects
+ - Custom tools organization
+ - CI/CD environments
+
+## See Also
+
+- [Configuration Reference](config.md) - Tools configuration reference
+- [Security Guide](security.md) - Security best practices
+- [Don](https://github.com/inercia/don) - For agent-related environment variables
From e340560e22ebf9e66b399f775db4071335749de6 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:40 +0100
Subject: [PATCH 07/22] docs: add container deployment guide
---
...{mcp-containers.md => usage-containers.md} | 213 +++++++++---------
1 file changed, 112 insertions(+), 101 deletions(-)
rename docs/{mcp-containers.md => usage-containers.md} (73%)
diff --git a/docs/mcp-containers.md b/docs/usage-containers.md
similarity index 73%
rename from docs/mcp-containers.md
rename to docs/usage-containers.md
index 416dd78..944c59d 100644
--- a/docs/mcp-containers.md
+++ b/docs/usage-containers.md
@@ -1,10 +1,13 @@
# Running MCPShell in Containers
-This document explains how to build specialized container images for running MCPShell as an MCP server, and how to deploy them in container orchestration platforms like Kubernetes.
+This document explains how to build specialized container images for running MCPShell as
+an MCP server, and how to deploy them in container orchestration platforms like
+Kubernetes.
## Building a Container Image with MCPShell
-Container images provide a portable and reproducible way to package MCPShell along with your tool configurations. This approach is particularly useful for:
+Container images provide a portable and reproducible way to package MCPShell along with
+your tool configurations. This approach is particularly useful for:
- **Consistent deployments** across different environments
- **Version control** of both MCPShell and your tool configurations
@@ -13,7 +16,8 @@ Container images provide a portable and reproducible way to package MCPShell alo
### Multi-Stage Dockerfile Example
-Here's a simplified multi-stage Dockerfile that builds MCPShell and packages it with a configuration file:
+Here's a simplified multi-stage Dockerfile that builds MCPShell and packages it with a
+configuration file:
```dockerfile
# Stage 1: Build MCPShell
@@ -142,11 +146,13 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
## Running in Kubernetes
-Kubernetes provides excellent orchestration capabilities for running MCPShell servers at scale.
+Kubernetes provides excellent orchestration capabilities for running MCPShell servers at
+scale.
### Basic Deployment Example
-Here's a simplified Kubernetes deployment that uses a ConfigMap for the tools configuration:
+Here's a simplified Kubernetes deployment that uses a ConfigMap for the tools
+configuration:
```yaml
---
@@ -199,43 +205,43 @@ spec:
app: mcpshell
spec:
containers:
- - name: mcpshell
- image: my-mcp-server:latest
- imagePullPolicy: IfNotPresent
- ports:
- - containerPort: 8080
- name: http
- protocol: TCP
- volumeMounts:
- - name: config
- mountPath: /etc/mcpshell
- readOnly: true
- env:
- - name: LOG_LEVEL
- value: "info"
- resources:
- requests:
- memory: "64Mi"
- cpu: "100m"
- limits:
- memory: "256Mi"
- cpu: "500m"
- livenessProbe:
- httpGet:
- path: /health
- port: 8080
- initialDelaySeconds: 10
- periodSeconds: 30
- readinessProbe:
- httpGet:
- path: /health
- port: 8080
- initialDelaySeconds: 5
- periodSeconds: 10
+ - name: mcpshell
+ image: my-mcp-server:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - containerPort: 8080
+ name: http
+ protocol: TCP
+ volumeMounts:
+ - name: config
+ mountPath: /etc/mcpshell
+ readOnly: true
+ env:
+ - name: LOG_LEVEL
+ value: "info"
+ resources:
+ requests:
+ memory: "64Mi"
+ cpu: "100m"
+ limits:
+ memory: "256Mi"
+ cpu: "500m"
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 10
+ periodSeconds: 30
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 5
+ periodSeconds: 10
volumes:
- - name: config
- configMap:
- name: mcpshell-config
+ - name: config
+ configMap:
+ name: mcpshell-config
---
apiVersion: v1
@@ -247,9 +253,9 @@ spec:
selector:
app: mcpshell
ports:
- - protocol: TCP
- port: 80
- targetPort: 8080
+ - protocol: TCP
+ port: 80
+ targetPort: 8080
type: ClusterIP
```
@@ -282,25 +288,25 @@ spec:
template:
spec:
containers:
- - name: mcpshell
- image: my-mcp-server:latest
- volumeMounts:
+ - name: mcpshell
+ image: my-mcp-server:latest
+ volumeMounts:
+ - name: config
+ mountPath: /etc/mcpshell
+ readOnly: true
+ - name: secrets
+ mountPath: /secrets
+ readOnly: true
+ env:
+ - name: AWS_SHARED_CREDENTIALS_FILE
+ value: "/secrets/aws-credentials"
+ volumes:
- name: config
- mountPath: /etc/mcpshell
- readOnly: true
+ configMap:
+ name: mcpshell-config
- name: secrets
- mountPath: /secrets
- readOnly: true
- env:
- - name: AWS_SHARED_CREDENTIALS_FILE
- value: "/secrets/aws-credentials"
- volumes:
- - name: config
- configMap:
- name: mcpshell-config
- - name: secrets
- secret:
- secretName: mcpshell-secrets
+ secret:
+ secretName: mcpshell-secrets
```
#### Namespace Isolation
@@ -331,16 +337,16 @@ metadata:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
rules:
- - host: mcp.example.com
- http:
- paths:
- - path: /
- pathType: Prefix
- backend:
- service:
- name: mcpshell-service
- port:
- number: 80
+ - host: mcp.example.com
+ http:
+ paths:
+ - path: /
+ pathType: Prefix
+ backend:
+ service:
+ name: mcpshell-service
+ port:
+ number: 80
```
#### Horizontal Pod Autoscaling
@@ -361,18 +367,18 @@ spec:
minReplicas: 2
maxReplicas: 10
metrics:
- - type: Resource
- resource:
- name: cpu
- target:
- type: Utilization
- averageUtilization: 70
- - type: Resource
- resource:
- name: memory
- target:
- type: Utilization
- averageUtilization: 80
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 70
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 80
```
### Monitoring and Logging
@@ -386,16 +392,16 @@ spec:
template:
spec:
containers:
- - name: mcpshell
- args:
- - "mcp"
- - "--tools"
- - "/etc/mcpshell/tools.yaml"
- - "--http"
- - "--port"
- - "8080"
- - "--log-level"
- - "info"
+ - name: mcpshell
+ args:
+ - "mcp"
+ - "--tools"
+ - "/etc/mcpshell/tools.yaml"
+ - "--http"
+ - "--port"
+ - "8080"
+ - "--log-level"
+ - "info"
```
Logs will be available through `kubectl logs`:
@@ -419,19 +425,24 @@ spec:
matchLabels:
app: mcpshell
endpoints:
- - port: http
- interval: 30s
- path: /metrics
+ - port: http
+ interval: 30s
+ path: /metrics
```
## Best Practices
-1. **Version your images**: Always tag your Docker images with specific versions, not just `latest`
-2. **Resource limits**: Set appropriate CPU and memory limits to prevent resource exhaustion
+1. **Version your images**: Always tag your Docker images with specific versions, not
+ just `latest`
+2. **Resource limits**: Set appropriate CPU and memory limits to prevent resource
+ exhaustion
3. **Health checks**: Implement proper health and readiness probes
-4. **Security**: Run containers as non-root users and use read-only filesystems where possible
-5. **Configuration management**: Use ConfigMaps for configurations and Secrets for sensitive data
-6. **Logging**: Configure appropriate log levels and ensure logs are collected by your logging infrastructure
+4. **Security**: Run containers as non-root users and use read-only filesystems where
+ possible
+5. **Configuration management**: Use ConfigMaps for configurations and Secrets for
+ sensitive data
+6. **Logging**: Configure appropriate log levels and ensure logs are collected by your
+ logging infrastructure
7. **Monitoring**: Expose metrics and set up appropriate alerts
8. **Updates**: Keep MCPShell and base images updated for security patches
From 3f914733878cbf2e75c62dd2f44b749e728e1915 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:45 +0100
Subject: [PATCH 08/22] docs: remove agent documentation
---
docs/usage-agent-conf.md | 198 ------------------------------------
docs/usage-agent.md | 212 ---------------------------------------
2 files changed, 410 deletions(-)
delete mode 100644 docs/usage-agent-conf.md
delete mode 100644 docs/usage-agent.md
diff --git a/docs/usage-agent-conf.md b/docs/usage-agent-conf.md
deleted file mode 100644
index 582c884..0000000
--- a/docs/usage-agent-conf.md
+++ /dev/null
@@ -1,198 +0,0 @@
-# Agent Configuration
-
-The MCPShell agent supports configuration through both configuration files and command-line flags. Configuration files provide a convenient way to manage multiple model configurations and default settings.
-
-## Configuration File
-
-The agent looks for configuration in `~/.mcpshell/agent.yaml`. This file defines model configurations, API keys, and default prompts.
-
-### Configuration Structure
-
-```yaml
-agent:
- models:
- - model: "gpt-4o"
- class: "openai"
- name: "GPT-4o Agent"
- default: true
- api-key: "${OPENAI_API_KEY}"
- api-url: "https://api.openai.com/v1"
- prompts:
- system:
- - "You are a helpful assistant specialized in system administration."
- - "Always provide clear, step-by-step instructions."
-
- - model: "gpt-3.5-turbo"
- class: "openai"
- name: "GPT-3.5 Agent"
- default: false
- api-key: "${OPENAI_API_KEY}"
- api-url: "https://api.openai.com/v1"
- prompts:
- system: "You are a helpful assistant."
-
- - model: "llama3"
- class: "ollama"
- name: "Llama3 Local"
- default: false
- prompts:
- system:
- - "You are a helpful assistant running locally."
- - "Be concise in your responses."
-```
-
-### Model Configuration Fields
-
-- `model`: The model identifier (e.g., "gpt-4o", "gpt-3.5-turbo")
-- `class`: The model provider class ("openai", "ollama", etc.)
-- `name`: A human-readable name for the model configuration
-- `default`: Boolean indicating if this is the default model
-- `api-key`: API key for the model provider (supports environment variable substitution)
-- `api-url`: Base URL for the API endpoint
-- `prompts.system`: Default system prompt for this model (can be a single string or array of strings)
-
-### Environment Variable Substitution
-
-API keys support environment variable substitution using the `${VARIABLE_NAME}` syntax:
-
-```yaml
-api-key: "${OPENAI_API_KEY}"
-```
-
-### Prompt Configuration
-
-The `prompts.system` field in the configuration accepts either a single string or an array of strings:
-
-```yaml
-# Single system prompt
-prompts:
- system: "You are a helpful assistant."
-
-# Multiple system prompts (array format)
-prompts:
- system:
- - "You are a helpful assistant."
- - "You specialize in system administration."
- - "Always explain your reasoning."
-```
-
-**Important:** Only system prompts are supported in the configuration file. User prompts should be provided via the `--user-prompt` command-line flag and are not stored in the configuration.
-
-**System Prompt Merging:** When you use the `--system-prompt` command-line flag, it will be **appended** to any system prompts defined in the configuration file. This allows you to have base prompts in your config and add context-specific prompts via the command line.
-
-## Command-Line Usage
-
-### Using Default Model
-
-If you have a default model configured, you can run the agent without specifying a model:
-
-```bash
-mcpshell agent --tools=examples/config.yaml \
- --user-prompt "Help me debug a performance issue"
-```
-
-### Overriding Model
-
-You can override the default model by specifying a different one:
-
-```bash
-mcpshell agent --tools=examples/config.yaml \
- --model "gpt-3.5-turbo" \
- --user-prompt "Help me debug a performance issue"
-```
-
-### Overriding Configuration
-
-Command-line flags take precedence over configuration file settings:
-
-```bash
-mcpshell agent --tools=examples/config.yaml \
- --model "gpt-4o" \
- --system-prompt "You are an expert system administrator" \
- --openai-api-key "your-api-key" \
- --user-prompt "Help me debug a performance issue"
-```
-
-**Note:** When you provide a `--system-prompt` via command line, it will be **merged** with any system prompts from the configuration file. The system prompts from the config are used first, followed by the command-line system prompt.
-
-## Configuration Precedence
-
-Settings are resolved in the following order (highest to lowest precedence):
-
-1. Command-line flags (`--model`, `--openai-api-key`, etc.)
-1. Environment variables (`MCPSHELL_AGENT_MODEL`, `OPENAI_API_KEY`, etc.)
-1. Configuration file settings
-1. Default values
-
-### Environment Variables
-
-The following environment variables are supported:
-
-- `MCPSHELL_AGENT_MODEL`: Default model to use when `--model` flag is not provided
-- `OPENAI_API_KEY`: OpenAI API key (can be referenced in config with `${OPENAI_API_KEY}`)
-
-Example:
-
-```bash
-export MCPSHELL_AGENT_MODEL="gpt-4o"
-mcpshell agent --tools=examples/config.yaml \
- --user-prompt "Help me debug a performance issue"
-```
-
-## Configuration Management Commands
-
-MCPShell provides commands to manage your agent configuration:
-
-### Create Default Configuration
-
-```bash
-mcpshell agent config create
-```
-
-Creates a default configuration file at `~/.mcpshell/agent.yaml` with sample models and settings. If the file already exists, it will be overwritten with the default configuration template.
-
-The default configuration includes:
-
-- GPT-4o model (set as default) with OpenAI API settings
-- Gemma3n model with Ollama configuration
-- Environment variable placeholders for API keys
-- Basic system prompts
-
-### Show Current Configuration
-
-```bash
-mcpshell agent config show [--json]
-```
-
-Displays the current agent configuration in a human-readable format, including:
-
-- All configured models with their settings
-- API keys (masked for security)
-- Which model is set as default
-- System prompts for each model
-
-**Flags:**
-
-- `--json`: Output configuration in JSON format for easy parsing by other tools
-
-**Example (human-readable):**
-
-```bash
-mcpshell agent config show
-```
-
-```text
-Agent Configuration:
-===================
-
-Model 1:
- Name: GPT-4o Agent
- Model: gpt-4o
- Class: openai
- Default: true
- API Key: your****-key
- API URL: https://api.openai.com/v1
- System Prompt: You are a helpful assistant.
-
-Default Model: GPT-4o Agent (gpt-4o)
-```
diff --git a/docs/usage-agent.md b/docs/usage-agent.md
deleted file mode 100644
index f073ea3..0000000
--- a/docs/usage-agent.md
+++ /dev/null
@@ -1,212 +0,0 @@
-# MCPShell Agent Mode
-
-The MCPShell can be run in "agent mode" to establish a direct connection between Large Language Models (LLMs) and the tools you define in your configuration. This allows for autonomous execution of tasks without requiring a separate MCP client like Cursor or Visual Studio Code.
-
-## Overview
-
-In agent mode, MCPShell:
-
-1. Connects directly to an LLM API
-1. Makes your tools available to the LLM
-1. Manages the conversation flow.
-1. Handles tool execution requests
-1. Provides the tool results back to the LLM
-
-This creates a complete AI agent that can perform tasks on your system using your defined tools.
-
-## Command-Line Options
-
-Run the agent with:
-
-```bash
-mcpshell agent [flags]
-```
-
-### Required Flags
-
-- `--tools`: Path to the tools configuration file (required)
-- `--model`, `-m`: LLM model to use (e.g., "gpt-4o", "llama3", etc.) - can be omitted if:
- - A default model is configured in your [agent configuration](usage-agent-conf.md), or
- - The `MCPSHELL_AGENT_MODEL` environment variable is set
-
-### Optional Flags
-
-- `--logfile`, `-l`: Path to the log file
-- `--log-level`: Logging level (none, error, info, debug)
-- `--system-prompt`, `-s`: System prompt for the LLM (merges with system prompts from [agent configuration](usage-agent-conf.md))
-- `--user-prompt`, `-u`: Initial user prompt for the LLM
-- `--openai-api-key`, `-k`: OpenAI API key (or set OPENAI_API_KEY environment variable, or configure in [agent config](usage-agent-conf.md))
-- `--openai-api-url`, `-b`: Base URL for the OpenAI API (for non-OpenAI services, or configure in [agent config](usage-agent-conf.md))
-- `--once`, `-o`: Exit after receiving a final response (one-shot mode)
-
-## Configuration File for Agent Mode
-
-MCPShell has agent-specific configuration that includes model definitions with prompts. The agent configuration is separate from the tools configuration and is managed through the `mcpshell agent config` commands.
-
-**📖 For complete details on agent configuration, including:**
-
-- Configuration file structure and syntax
-- Model configuration fields
-- Environment variable substitution
-- Configuration management commands
-- Example configurations
-
-**See the [Agent Configuration Guide](usage-agent-conf.md)**
-
-## Agent Subcommands
-
-### `agent info` - Display Agent Configuration
-
-The `agent info` subcommand displays detailed information about the current agent configuration:
-
-```bash
-mcpshell agent info [--tools ]
-```
-
-**Note**: The `--tools` flag is **optional** for this command. It's only needed if you want to verify the full agent configuration including tools setup. Without it, the command will display your agent's model configuration, API settings, and prompts.
-
-**Flags:**
-
-- `--json`: Output in JSON format (ideal for parsing by other tools)
-- `--include-prompts`: Include the full system prompts in the output
-- `--check`: Test LLM connectivity (exits with error if LLM is not responding)
-- `--tools`: (Optional) Path to tools configuration file
-
-**Examples:**
-
-Display basic agent configuration (no tools needed):
-
-```bash
-mcpshell agent info
-```
-
-Check LLM connectivity:
-
-```bash
-mcpshell agent --model llama3 info --check
-```
-
-Output in JSON format for parsing:
-
-```bash
-mcpshell agent info --json
-```
-
-Show configuration with full prompts:
-
-```bash
-mcpshell agent --system-prompt "Custom prompt" info --include-prompts
-```
-
-Include tools configuration verification:
-
-```bash
-mcpshell agent info --tools disk-diagnostics-ro.yaml
-```
-
-Override model and show configuration:
-
-```bash
-mcpshell agent --model llama3 info --json
-```
-
-The info command shows:
-
-- Agent configuration file path (typically `~/.mcpshell/agent.yaml`)
-- Orchestrator model (the main planning agent)
-- Tool-runner model (the agent that executes tools)
-- API configuration (with masked keys)
-- System prompts (with `--include-prompts`)
-- LLM connectivity status (with `--check`)
-- Configured tools file (if `--tools` is provided)
-
-### `agent config` - Manage Agent Configuration
-
-See the [Agent Configuration Guide](usage-agent-conf.md) for details on the `agent config` subcommands.
-
-## Running the Agent
-
-With the tools defined in `disk-diagnostics-ro.yaml`, you can run:
-
-```bash
-mcpshell agent \
- --tools disk-diagnostics-ro.yaml \
- "My root partition is running low on space. Can you help me find what's taking up space and how I might free some up?"
-```
-
-The agent will:
-
-- Load model and API settings from `~/.mcpshell/agent.yaml` (see [Agent Configuration Guide](usage-agent-conf.md)). It will look like this:
-
-```console
-$ cat ~/.mcpshell/agent.yaml
-agent:
- models:
- - model: "gpt-4o"
- class: "openai"
- name: "gpt-4o"
- default: true
- api-key: "your-openai-api-key"
- api-url: "https://api.openai.com/v1"
-
- - name: "claude-sonnet-4"
- class: "amazon-bedrock"
- model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
- api-url: "https://bedrock-runtime.us-east-2.amazonaws.com"
-
- - model: "gemma3n"
- class: "ollama"
- name: "gemma3n"
-
- - model: "llama3.1:8b"
- class: "ollama"
- name: "llama3"
-```
-
-- Load system prompts from the agent configuration (if any, or use the default ones).
-- Load tools from `disk-diagnostics-ro.yaml`.
-- Connect to the configured LLM API.
-- Process the LLM's responses and execute tool calls as requested.
-
-You can also use STDIN as part of the prompt by using a `-` for replacing it,
-like this:
-
-```bash
-cat failure.log | mcpshell agent \
- --tools kubectl-ro.yaml \
- "I'm seeing this error in the Kubernetes logs" - "Please help me to debug this problem."
-```
-
-When STDIN is used (via `-`), the agent automatically runs in `--once` mode since
-STDIN is no longer available for interactive input.
-
-## Interacting with the Agent
-
-In interactive mode (without the `--once` flag), the agent will:
-
-- Display the LLM's responses
-- Execute tool calls as requested by the LLM
-- Wait for you to provide additional input after the LLM completes its response
-- Continue the conversation with the full conversation context preserved
-- Loop until you exit (Ctrl+C)
-
-In one-shot mode (with the `--once` flag), the agent will:
-
-- Process the initial prompt
-- Execute any requested tools
-- Display the final response
-- Exit automatically after the LLM completes
-
-## Testing and Debugging
-
-When developing agents, you can:
-
-1. Enable debug logging with `--log-level debug`
-
-1. Examine the log file for detailed information
-
-1. Test with the `exe` command to verify individual tools:
-
- ```bash
- mcpshell exe --tools disk-diagnostics-ro.yaml disk_usage directory="/" max_depth=2
- ```
From 2f204f8955430c342c51f10017aafbea8c58e489 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:51 +0100
Subject: [PATCH 09/22] docs: update documentation for refactored codebase
---
docs/config-runners.md | 246 ++++++++++++++++++++---------------
docs/config.md | 162 ++++++++++++-----------
docs/development.md | 13 +-
docs/release-process.md | 37 ++++--
docs/security.md | 37 ++++--
docs/troubleshooting.md | 64 +++++----
docs/usage-claude-desktop.md | 6 +-
docs/usage-codex-cli.md | 8 +-
docs/usage-cursor.md | 129 +++++++++---------
docs/usage-vscode.md | 64 +++++----
docs/usage.md | 72 ++++++----
examples/README.md | 26 +---
12 files changed, 487 insertions(+), 377 deletions(-)
diff --git a/docs/config-runners.md b/docs/config-runners.md
index 2e718d8..6adf428 100644
--- a/docs/config-runners.md
+++ b/docs/config-runners.md
@@ -1,14 +1,16 @@
# Runner Configuration
-The MCP CLI Adapter supports multiple _execution runners_ that allow tools to run in different
-environments with various security restrictions. This document details how to configure and use these runners.
+The MCP CLI Adapter supports multiple _execution runners_ that allow tools to run in
+different environments with various security restrictions. This document details how to
+configure and use these runners.
For basic configuration information, see [Configuration Overview](config.md).
## Multiple Runners and Selection Process
-You can define multiple runners for a tool to support different execution environments. The system
-will select the first runner whose requirements are satisfied by the current system.
+You can define multiple runners for a tool to support different execution environments.
+The system will select the first runner whose requirements are satisfied by the current
+system.
Each runner definition includes:
@@ -22,7 +24,7 @@ Here's an example of a tool with multiple runners:
```yaml
run:
- timeout: "30s" # Command will timeout after 30 seconds
+ timeout: "30s" # Command will timeout after 30 seconds
command: "echo 'Hello {{ .name }}'"
runners:
- name: sandbox-exec
@@ -33,10 +35,12 @@ run:
options:
allow_networking: false
allow_user_folders: false
- - name: exec # acts as a fallback
+ - name: exec # acts as a fallback
```
-**Note**: The `timeout` setting applies to all runners. Regardless of which runner is selected (sandbox-exec, firejail, or exec), the command will be terminated if it exceeds the specified timeout duration.
+**Note**: The `timeout` setting applies to all runners. Regardless of which runner is
+selected (sandbox-exec, firejail, or exec), the command will be terminated if it exceeds
+the specified timeout duration.
In this example:
@@ -46,23 +50,25 @@ In this example:
**Important Notes on Runner Selection:**
-- The `runners` array is **optional**. If not provided,
- **a default `exec` runner with no sandboxing will be used**.
-- If you do specify `runners`, at least one of them must meet its requirements
- for the tool to be available.
-- No automatic fallback to `exec` occurs if you specify `runners` but none meet their requirements.
-- If you want a fallback, explicitly add an `exec` runner with empty
- requirements at the end of your runners list.
+- The `runners` array is **optional**. If not provided, **a default `exec` runner with
+ no sandboxing will be used**.
+- If you do specify `runners`, at least one of them must meet its requirements for the
+ tool to be available.
+- No automatic fallback to `exec` occurs if you specify `runners` but none meet their
+ requirements.
+- If you want a fallback, explicitly add an `exec` runner with empty requirements at the
+ end of your runners list.
-It's recommended to always include a fallback runner (typically named "exec" with
-no requirements) to ensure your tool can run on any platform if you want it to be universally available.
+It's recommended to always include a fallback runner (typically named "exec" with no
+requirements) to ensure your tool can run on any platform if you want it to be
+universally available.
## Runner Types
### Default Runner (exec)
-The default runner executes commands directly on the host system using the configured shell.
-It has no special requirements or sandboxing.
+The default runner executes commands directly on the host system using the configured
+shell. It has no special requirements or sandboxing.
```yaml
runners:
@@ -71,22 +77,22 @@ runners:
### `sandbox-exec` Runner (macOS Only)
-The sandbox runner uses macOS's `sandbox-exec` command to run commands in a sandboxed environment
-with restricted access to the system. This provides an additional layer of security by
-restricting what commands can access.
+The sandbox runner uses macOS's `sandbox-exec` command to run commands in a sandboxed
+environment with restricted access to the system. This provides an additional layer of
+security by restricting what commands can access.
```yaml
runners:
- name: sandbox-exec
options:
- allow_networking: false # Disable network access
- allow_user_folders: false # Restrict access to user folders
- allow_read_folders: # List of folders to explicitly allow access to
+ allow_networking: false # Disable network access
+ allow_user_folders: false # Restrict access to user folders
+ allow_read_folders: # List of folders to explicitly allow access to
- "/tmp"
- "/path/to/project"
- allow_read_files: # List of specific files to allow access to
+ allow_read_files: # List of specific files to allow access to
- "/etc/config.yaml"
- - "{{ env \"HOME\" }}/app.conf"
+ - '{{ env "HOME" }}/app.conf'
```
#### Sandbox Configuration Options
@@ -94,27 +100,32 @@ runners:
Available options:
- `allow_networking`: When set to `false`, blocks all network access
-- `allow_user_folders`: When set to `false`, restricts access to user folders like Documents, Desktop, etc.
-- `allow_read_folders`: List of directories to explicitly allow read access to. Items in this list can use
- Golang template replacements (using the tool parameters).
-- `allow_read_files`: List of specific files to explicitly allow read access to. Items in this list can use
- Golang template replacements (using the tool parameters).
-- `allow_write_folders`: List of directories to explicitly allow write access to. Items in this list can use
- Golang template replacements (using the tool parameters).
-- `allow_write_files`: List of specific files to explicitly allow write access to. Items in this list can use
- Golang template replacements (using the tool parameters).
+- `allow_user_folders`: When set to `false`, restricts access to user folders like
+ Documents, Desktop, etc.
+- `allow_read_folders`: List of directories to explicitly allow read access to. Items in
+ this list can use Golang template replacements (using the tool parameters).
+- `allow_read_files`: List of specific files to explicitly allow read access to. Items
+ in this list can use Golang template replacements (using the tool parameters).
+- `allow_write_folders`: List of directories to explicitly allow write access to. Items
+ in this list can use Golang template replacements (using the tool parameters).
+- `allow_write_files`: List of specific files to explicitly allow write access to. Items
+ in this list can use Golang template replacements (using the tool parameters).
- `custom_profile`: Specify a custom sandbox profile for advanced configuration
**Important**: macOS `sandbox-exec` requires different syntax for files vs directories:
-- Directories use `(allow file-read* (subpath "path"))` which allows access to the directory and all its contents
-- Files use `(allow file-read* (literal "path"))` which allows access to that specific file only
+- Directories use `(allow file-read* (subpath "path"))` which allows access to the
+ directory and all its contents
+- Files use `(allow file-read* (literal "path"))` which allows access to that specific
+ file only
-Use `allow_read_files` for specific file paths (e.g., config files) and `allow_read_folders` for directories.
+Use `allow_read_files` for specific file paths (e.g., config files) and
+`allow_read_folders` for directories.
#### Custom Sandbox Profiles
-For advanced usage, you can specify a completely custom sandbox profile using the `custom_profile` option.
+For advanced usage, you can specify a completely custom sandbox profile using the
+`custom_profile` option.
Here's an example of a custom profile that:
@@ -135,46 +146,53 @@ runners:
### `firejail` Runner (Linux Only)
-The firejail runner uses [firejail](https://firejail.wordpress.com/) to run commands in a sandboxed environment on Linux systems. Firejail is a SUID sandbox program that restricts the running environment of untrusted applications using Linux namespaces and seccomp-bpf.
+The firejail runner uses [firejail](https://firejail.wordpress.com/) to run commands in
+a sandboxed environment on Linux systems. Firejail is a SUID sandbox program that
+restricts the running environment of untrusted applications using Linux namespaces and
+seccomp-bpf.
```yaml
runners:
- name: firejail
options:
- allow_networking: false # Disable network access
- allow_user_folders: false # Restrict access to user folders
- allow_read_folders: # List of folders to explicitly allow access to
+ allow_networking: false # Disable network access
+ allow_user_folders: false # Restrict access to user folders
+ allow_read_folders: # List of folders to explicitly allow access to
- "/tmp"
- "/etc/ssl/certs"
- allow_read_files: # List of specific files to allow access to
+ allow_read_files: # List of specific files to allow access to
- "/etc/config.yaml"
- - "{{ env \"HOME\" }}/app.conf"
+ - '{{ env "HOME" }}/app.conf'
```
#### Requirements
- Linux operating system
-- Firejail installed (`apt-get install firejail` on Debian/Ubuntu or equivalent for your distribution)
+- Firejail installed (`apt-get install firejail` on Debian/Ubuntu or equivalent for your
+ distribution)
#### Firejail Configuration Options
Available options:
- `allow_networking`: When set to `false`, blocks all network access using `net none`
-- `allow_user_folders`: When set to `false`, restricts access to common user folders like Documents, Desktop, etc.
-- `allow_read_folders`: List of directories to explicitly allow read access to. Items in this list can use
- Golang template replacements (using the tool parameters).
-- `allow_read_files`: List of specific files to explicitly allow read access to. Items in this list can use
- Golang template replacements (using the tool parameters).
-- `allow_write_folders`: List of directories to explicitly allow both read and write access to.
- Items in this list can use Golang template replacements (using the tool parameters).
-- `allow_write_files`: List of specific files to explicitly allow both read and write access to.
- Items in this list can use Golang template replacements (using the tool parameters).
+- `allow_user_folders`: When set to `false`, restricts access to common user folders
+ like Documents, Desktop, etc.
+- `allow_read_folders`: List of directories to explicitly allow read access to. Items in
+ this list can use Golang template replacements (using the tool parameters).
+- `allow_read_files`: List of specific files to explicitly allow read access to. Items
+ in this list can use Golang template replacements (using the tool parameters).
+- `allow_write_folders`: List of directories to explicitly allow both read and write
+ access to. Items in this list can use Golang template replacements (using the tool
+ parameters).
+- `allow_write_files`: List of specific files to explicitly allow both read and write
+ access to. Items in this list can use Golang template replacements (using the tool
+ parameters).
- `custom_profile`: Specify a custom firejail profile for advanced configuration
-**Note**: For consistency with the sandbox-exec runner, firejail also supports separate file and folder lists.
-While firejail uses `whitelist` for both, maintaining this separation improves configuration clarity and
-cross-platform compatibility.
+**Note**: For consistency with the sandbox-exec runner, firejail also supports separate
+file and folder lists. While firejail uses `whitelist` for both, maintaining this
+separation improves configuration clarity and cross-platform compatibility.
#### Security Benefits
@@ -188,7 +206,8 @@ The firejail runner adds several layers of security:
#### Custom Firejail Profiles
-For advanced usage, you can specify a completely custom firejail profile using the `custom_profile` option:
+For advanced usage, you can specify a completely custom firejail profile using the
+`custom_profile` option:
```yaml
runners:
@@ -205,22 +224,22 @@ runners:
### Docker Runner
-The Docker runner executes commands inside Docker containers, providing
-**strong isolation** from the host system. This runner creates a temporary script
-file containing your command, then mounts it into a Docker container and executes it.
+The Docker runner executes commands inside Docker containers, providing **strong
+isolation** from the host system. This runner creates a temporary script file containing
+your command, then mounts it into a Docker container and executes it.
```yaml
runners:
- name: docker
options:
- image: "alpine:latest" # Required: Docker image to use
- allow_networking: true # Optional: Allow network access (default: true)
- mounts: # Optional: Additional volumes to mount
- - "/data:/data:ro" # Format: "host-path:container-path[:options]"
+ image: "alpine:latest" # Required: Docker image to use
+ allow_networking: true # Optional: Allow network access (default: true)
+ mounts: # Optional: Additional volumes to mount
+ - "/data:/data:ro" # Format: "host-path:container-path[:options]"
- "/config:/etc/myapp:ro"
- user: "1000:1000" # Optional: User to run as in container
- workdir: "/app" # Optional: Working directory in container
- docker_run_opts: "--cpus 1 --memory 512m" # Optional: Additional docker run options
+ user: "1000:1000" # Optional: User to run as in container
+ workdir: "/app" # Optional: Working directory in container
+ docker_run_opts: "--cpus 1 --memory 512m" # Optional: Additional docker run options
prepare_command: |
# Commands to run before the main command
apt-get update
@@ -230,35 +249,45 @@ runners:
#### Requirements
- Docker installed and available in PATH
-- Appropriate permissions to run Docker containers (typically membership in the `docker` group or root)
+- Appropriate permissions to run Docker containers (typically membership in the `docker`
+ group or root)
#### Docker Configuration Options
Available options:
-- `image`: (Required) The Docker image to use for running the command (e.g., "alpine:latest", "ubuntu:22.04")
-- `allow_networking`: When set to `false`, disables all network access for the container using `--network none`
-- `network`: Specific network to connect the container to (e.g., "host", "bridge", or custom network name)
-- `mounts`: A list of additional volumes to mount in the format "host-path:container-path[:options]"
+- `image`: (Required) The Docker image to use for running the command (e.g.,
+ "alpine:latest", "ubuntu:22.04")
+- `allow_networking`: When set to `false`, disables all network access for the container
+ using `--network none`
+- `network`: Specific network to connect the container to (e.g., "host", "bridge", or
+ custom network name)
+- `mounts`: A list of additional volumes to mount in the format
+ "host-path:container-path[:options]"
- `user`: Specify the user to run as within the container (format: "uid" or "uid:gid")
- `workdir`: Set the working directory inside the container
- `docker_run_opts`: String of additional options to pass to the `docker run` command
-- `prepare_command`: Commands to run before the main command (e.g., for installing packages or setting up the environment)
+- `prepare_command`: Commands to run before the main command (e.g., for installing
+ packages or setting up the environment)
- `memory`: Memory limit for the container (e.g., "512m", "1g")
- `memory_reservation`: Memory soft limit (e.g., "256m", "512m")
- `memory_swap`: Swap limit equal to memory plus swap: '-1' to enable unlimited swap
- `memory_swappiness`: Tune container memory swappiness (0 to 100, default -1)
-- `cap_add`: Linux capabilities to add to the container (e.g., ["NET_ADMIN", "SYS_PTRACE"])
+- `cap_add`: Linux capabilities to add to the container (e.g., ["NET_ADMIN",
+ "SYS_PTRACE"])
- `cap_drop`: Linux capabilities to drop from the container (e.g., ["ALL"])
- `dns`: Custom DNS servers for the container (e.g., ["8.8.8.8", "1.1.1.1"])
-- `dns_search`: Custom DNS search domains for the container (e.g., ["example.com", "mydomain.local"])
-- `platform`: Set platform if server is multi-platform capable (e.g., "linux/amd64", "linux/arm64")
+- `dns_search`: Custom DNS search domains for the container (e.g., ["example.com",
+ "mydomain.local"])
+- `platform`: Set platform if server is multi-platform capable (e.g., "linux/amd64",
+ "linux/arm64")
#### Security Benefits
The Docker runner provides several security advantages:
-1. **Complete process isolation**: Processes inside the container are isolated from the host
+1. **Complete process isolation**: Processes inside the container are isolated from the
+ host
1. **Configurable resource limits**: Can limit CPU, memory, and other resources
1. **Control over capabilities**: Docker restricts Linux capabilities by default
1. **Filesystem isolation**: Only mounted volumes are accessible
@@ -313,11 +342,11 @@ runners:
- name: docker
options:
image: "node:16-alpine"
- memory: "1g" # Hard memory limit
- memory_reservation: "512m" # Soft memory limit (container will try to release memory if below this value)
- memory_swap: "1.5g" # Total memory+swap limit
- memory_swappiness: 10 # Low swappiness value to prefer using RAM over swap
- docker_run_opts: "--cpus 2" # Limit to 2 CPU cores
+ memory: "1g" # Hard memory limit
+ memory_reservation: "512m" # Soft memory limit (container will try to release memory if below this value)
+ memory_swap: "1.5g" # Total memory+swap limit
+ memory_swappiness: 10 # Low swappiness value to prefer using RAM over swap
+ docker_run_opts: "--cpus 2" # Limit to 2 CPU cores
workdir: "/app"
```
@@ -328,8 +357,8 @@ runners:
- name: docker
options:
image: "ubuntu:22.04"
- cap_drop: ["ALL"] # Drop all capabilities by default
- cap_add: ["NET_ADMIN", "NET_RAW"] # Add specific capabilities for network tools
+ cap_drop: ["ALL"] # Drop all capabilities by default
+ cap_add: ["NET_ADMIN", "NET_RAW"] # Add specific capabilities for network tools
allow_networking: true
prepare_command: |
apt-get update
@@ -343,12 +372,12 @@ runners:
- name: docker
options:
image: "alpine:latest"
- dns: ["8.8.8.8", "8.8.4.4"] # Use Google's public DNS servers
+ dns: ["8.8.8.8", "8.8.4.4"] # Use Google's public DNS servers
dns_search: ["example.com", "internal.mycompany.net"]
prepare_command: |
# Install networking tools
apk add --no-cache curl bind-tools
-
+
# Test DNS resolution
echo "Testing DNS resolution..."
nslookup api.example.com
@@ -361,14 +390,14 @@ runners:
- name: docker
options:
image: "node:16"
- platform: "linux/amd64" # Force x86_64 architecture even on ARM systems
+ platform: "linux/amd64" # Force x86_64 architecture even on ARM systems
workdir: "/app"
mounts:
- "./app:/app"
prepare_command: |
# Install dependencies for x86_64 architecture
npm install
-
+
# Run tests to ensure platform compatibility
npm test
```
@@ -380,14 +409,14 @@ runners:
- name: docker
options:
image: "ubuntu:latest"
- network: "host" # Use host network mode for full network access
+ network: "host" # Use host network mode for full network access
prepare_command: |
# Update package list
apt-get update
-
+
# Install networking tools
apt-get install -y net-tools iputils-ping
-
+
# Test network connectivity with host network
netstat -tuln
```
@@ -402,20 +431,23 @@ runners:
prepare_command: |
# Update package lists
apt-get update -y
-
+
# Install required packages
apt-get install -y --no-install-recommends \
curl \
jq \
ca-certificates
-
+
# Clean up to reduce container size
apt-get clean
rm -rf /var/lib/apt/lists/*
allow_networking: true
```
-The `prepare_command` is executed before the main command and can be used to install dependencies, configure the environment, or perform any setup tasks needed for the command to run successfully. This is especially useful for lightweight base images where you need to install additional tools.
+The `prepare_command` is executed before the main command and can be used to install
+dependencies, configure the environment, or perform any setup tasks needed for the
+command to run successfully. This is especially useful for lightweight base images where
+you need to install additional tools.
## Cross-Platform Example
@@ -430,11 +462,11 @@ Here's a complete example of a tool that uses different runners based on the pla
description: "Path to the file to read"
required: true
constraints:
- - "filename.size() > 0" # Filename must not be empty
- - "!filename.contains('../')" # Prevent directory traversal
- - "['.txt', '.log', '.md'].exists(ext, filename.endsWith(ext))" # Only allow certain file extensions
+ - "filename.size() > 0" # Filename must not be empty
+ - "!filename.contains('../')" # Prevent directory traversal
+ - "['.txt', '.log', '.md'].exists(ext, filename.endsWith(ext))" # Only allow certain file extensions
run:
- timeout: "10s" # Timeout after 10 seconds
+ timeout: "10s" # Timeout after 10 seconds
command: "cat {{ .filename }}"
runners:
- name: sandbox-exec
@@ -444,7 +476,7 @@ Here's a complete example of a tool that uses different runners based on the pla
allow_read_folders:
- "/tmp"
allow_read_files:
- - "{{ .filename }}" # Specific file access
+ - "{{ .filename }}" # Specific file access
- name: firejail
options:
allow_networking: false
@@ -452,7 +484,7 @@ Here's a complete example of a tool that uses different runners based on the pla
allow_read_folders:
- "/tmp"
allow_read_files:
- - "{{ .filename }}" # Specific file access
+ - "{{ .filename }}" # Specific file access
- name: exec
output:
prefix: "Contents of {{ .filename }}:"
@@ -471,7 +503,7 @@ Here's an example showing how to properly configure file and folder access for k
description: "Resource type (pods, deployments, etc.)"
required: true
run:
- timeout: "30s" # Timeout after 30 seconds
+ timeout: "30s" # Timeout after 30 seconds
command: "kubectl get {{ .resource }}"
env:
- KUBECONFIG
@@ -483,9 +515,9 @@ Here's an example showing how to properly configure file and folder access for k
allow_read_folders:
- "/usr/bin"
- "/bin"
- - "{{ env \"HOME\" }}/.kube" # Directory with multiple kubeconfig files
+ - '{{ env "HOME" }}/.kube' # Directory with multiple kubeconfig files
allow_read_files:
- - "{{ env \"KUBECONFIG\" }}" # Specific kubeconfig file
+ - '{{ env "KUBECONFIG" }}' # Specific kubeconfig file
- name: firejail
options:
allow_networking: true
@@ -493,8 +525,8 @@ Here's an example showing how to properly configure file and folder access for k
allow_read_folders:
- "/usr/bin"
- "/bin"
- - "{{ env \"HOME\" }}/.kube"
+ - '{{ env "HOME" }}/.kube'
allow_read_files:
- - "{{ env \"KUBECONFIG\" }}"
+ - '{{ env "KUBECONFIG" }}'
- name: exec
```
diff --git a/docs/config.md b/docs/config.md
index d05cd43..e013256 100644
--- a/docs/config.md
+++ b/docs/config.md
@@ -1,6 +1,7 @@
# Configuration File
-The MCPShell can be configured using a YAML configuration file to define the tools that the MCP server provides.
+The MCPShell can be configured using a YAML configuration file to define the tools that
+the MCP server provides.
## Basic Structure
@@ -32,8 +33,7 @@ mcp:
os: ""
executables:
- ""
- options:
-
-Some other examples are just for demonstrating the configuration file format and paramters
+Some other examples are just for demonstrating the configuration file format and parameters
(like all the `config*yaml`).
-## Using STDIN with the Agent
-
-The agent command supports reading from STDIN as part of the prompt. This is useful when you want to
-pipe log files, error messages, or other text content for the LLM to analyze.
-
-Use `-` as a placeholder in the arguments to represent STDIN content:
-
-```bash
-# Analyze a log file
-cat error.log | mcpshell agent --tools log-analysis-ro.yaml \
- "I'm seeing errors in this log file:" - "Please help me understand what went wrong."
-
-# Debug Kubernetes issues
-kubectl logs my-pod | mcpshell agent --tools kubectl-ro.yaml \
- "Here are the logs from a failing pod:" - "What's causing the failure?"
-
-# Examine system performance
-ps aux | mcpshell agent --tools system-performance-ro.yaml \
- "Current process list:" - "Which processes are using the most resources?"
-```
-
-**Note:** When STDIN is used, the agent automatically runs in `--once` mode (single interaction)
-since STDIN is no longer available for interactive input.
-
## Creating your own scripts with Cursor
Most of the examples in this directory have been generated automatically
From b23ba221e6ed214c9159ef653f9c7aa713e59fae Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:45:56 +0100
Subject: [PATCH 10/22] chore: update license year and readme
---
LICENSE | 2 +-
README.md | 17 +++++++----------
2 files changed, 8 insertions(+), 11 deletions(-)
diff --git a/LICENSE b/LICENSE
index 0725f55..a7dad8e 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2024 Alvaro
+Copyright (c) 2025 Alvaro Saurin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/README.md b/README.md
index 2607ddd..1b68891 100644
--- a/README.md
+++ b/README.md
@@ -121,20 +121,17 @@ Take a look at all the command in [this document](docs/usage.md).
Configuration files use a YAML format defined [here](docs/config.md).
See the [this directory](examples) for some examples.
-For deploying MCPShell in containers and Kubernetes, see the [Container Deployment Guide](docs/mcp-containers.md).
+For deploying MCPShell in containers and Kubernetes, see the [Container Deployment Guide](docs/usage-containers.md).
## Agent Mode
-MCPShell can also be run in agent mode, providing direct connectivity between Large Language Models
-(LLMs) and your command-line tools without requiring a separate MCP client. In this mode,
-MCPShell connects to an OpenAI-compatible API (including local LLMs like Ollama), makes your
-tools available to the model, executes requested tool operations, and manages the conversation flow.
-This enables the creation of specialized AI assistants that can autonomously perform system tasks
-using the tools you define in your configuration. The agent mode supports both interactive
-conversations and one-shot executions, and allows you to define system and user prompts directly
-in your configuration files.
+For AI agent functionality that connects LLMs directly to tools, see the
+[**Don**](https://github.com/inercia/don) project. Don provides:
-For detailed information on using agent mode, see the [Agent Mode documentation](docs/usage-agent.md).
+- Direct LLM connectivity without requiring a separate MCP client
+- RAG (Retrieval-Augmented Generation) support
+- Multi-agent architecture
+- Uses MCPShell's tool configuration format
## Security Considerations
From 6e01df03971f2aab4b177e8fce30a2baedfd5ae3 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:46:01 +0100
Subject: [PATCH 11/22] chore: add VS Code workspace file
---
mcpshell.code-workspace | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 mcpshell.code-workspace
diff --git a/mcpshell.code-workspace b/mcpshell.code-workspace
new file mode 100644
index 0000000..5f30e54
--- /dev/null
+++ b/mcpshell.code-workspace
@@ -0,0 +1,20 @@
+{
+ "folders": [
+ {
+ "path": "."
+ },
+ {
+ "path": "../don"
+ }
+ ],
+ "settings": {
+ "[go]": {
+ "editor.insertSpaces": false,
+ "editor.formatOnSave": true,
+ "editor.codeActionsOnSave": {
+ "source.organizeImports": "never"
+ },
+ "editor.suggest.snippetsPreventQuickSuggestions": false
+ }
+ }
+}
\ No newline at end of file
From c6da923652ffaf3cddadd2e9a1a1d0e510a1b494 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 17:46:06 +0100
Subject: [PATCH 12/22] chore: add Augment AI rules and configuration
---
.augment/rules/architecture.md | 259 ++++++++++++++++++++++++++++++++
.augment/rules/configuration.md | 66 ++++++++
.augment/rules/documentation.md | 27 ++++
.augment/rules/go.md | 28 ++++
.augment/rules/mcp-protocol.md | 256 +++++++++++++++++++++++++++++++
.augment/rules/security.md | 56 +++++++
.augment/rules/testing.md | 33 ++++
7 files changed, 725 insertions(+)
create mode 100644 .augment/rules/architecture.md
create mode 100644 .augment/rules/configuration.md
create mode 100644 .augment/rules/documentation.md
create mode 100644 .augment/rules/go.md
create mode 100644 .augment/rules/mcp-protocol.md
create mode 100644 .augment/rules/security.md
create mode 100644 .augment/rules/testing.md
diff --git a/.augment/rules/architecture.md b/.augment/rules/architecture.md
new file mode 100644
index 0000000..9a771b7
--- /dev/null
+++ b/.augment/rules/architecture.md
@@ -0,0 +1,259 @@
+---
+type: "always_apply"
+---
+
+# Architecture and Design Patterns for MCPShell
+
+## Project Structure
+
+### Directory Organization
+```
+mcpshell/
+├── cmd/ # Command-line interface implementation
+│ ├── root.go # Root command and global flags
+│ ├── mcp.go # MCP server command
+│ ├── exe.go # Direct tool execution command
+│ ├── validate.go # Configuration validation command
+│ └── daemon.go # Daemon mode command
+├── pkg/ # Core application packages
+│ ├── server/ # MCP server implementation
+│ ├── command/ # Command execution and runners
+│ ├── config/ # Configuration loading and validation
+│ ├── common/ # Shared utilities and types
+│ └── utils/ # Helper functions
+├── docs/ # Documentation
+├── examples/ # Example configurations
+├── tests/ # Integration and E2E tests
+├── build/ # Build output directory
+└── main.go # Application entry point
+```
+
+### Package Responsibilities
+
+#### `cmd/` Package
+- Command-line interface implementation using Cobra
+- Command definitions and flag parsing
+- User interaction and output formatting
+- Delegates business logic to `pkg/` packages
+
+#### `pkg/server/` Package
+- MCP server lifecycle management
+- Tool registration and discovery
+- Request handling and routing
+- Integration with MCP protocol library
+
+#### `pkg/command/` Package
+- Command handler creation and execution
+- Runner implementations (exec, firejail, sandbox-exec, docker)
+- Template processing and parameter substitution
+- Constraint evaluation and validation
+
+#### `pkg/config/` Package
+- YAML configuration loading and parsing
+- Configuration validation
+- Tool definition structures
+- Configuration merging for multiple files
+
+#### `pkg/common/` Package
+- Shared types and interfaces
+- Logging infrastructure
+- Constraint compilation and evaluation (CEL)
+- Template utilities
+- Panic recovery
+- Prerequisite checking
+
+#### `pkg/utils/` Package
+- Helper functions for file operations
+- Path resolution and normalization
+- Home directory detection
+- Tool file discovery
+
+## Design Patterns
+
+### Dependency Injection
+- Pass dependencies (logger, config) as parameters to constructors
+- Use constructor functions (New*) for complex types
+- Avoid global state except for the global logger
+- Example:
+ ```go
+ func New(cfg Config, logger *common.Logger) *Server {
+ return &Server{
+ config: cfg,
+ logger: logger,
+ }
+ }
+ ```
+
+### Interface-Based Design
+- Define interfaces for pluggable components (Runner, ModelProvider)
+- Use interfaces to enable testing with mocks
+- Keep interfaces small and focused (Interface Segregation Principle)
+- Example:
+ ```go
+ type Runner interface {
+ Run(ctx context.Context, shell string, command string, env []string, params map[string]interface{}, tmpfile bool) (string, error)
+ CheckImplicitRequirements() error
+ }
+ ```
+
+### Factory Pattern
+- Use factory functions for creating handlers and runners
+- Factory functions handle initialization and validation
+- Example:
+ ```go
+ func NewCommandHandler(tool config.Tool, shell string, logger *common.Logger) (*CommandHandler, error)
+ ```
+
+### Strategy Pattern
+- Multiple runner implementations (ExecRunner, FirejailRunner, SandboxRunner, DockerRunner)
+- Runner selection based on requirements and availability
+- Fallback to default runner when specific runner unavailable
+
+### Builder Pattern
+- Configuration structs with optional fields
+- Use functional options for complex initialization when needed
+- Example:
+ ```go
+ type Config struct {
+ ConfigFile string
+ Shell string
+ Logger *common.Logger
+ Version string
+ Descriptions []string
+ DescriptionFiles []string
+ DescriptionOverride bool
+ }
+ ```
+
+## Architectural Principles
+
+### Separation of Concerns
+- Clear separation between CLI, business logic, and infrastructure
+- Each package has a single, well-defined responsibility
+- Avoid circular dependencies between packages
+
+### Error Handling
+- Errors are wrapped with context at each layer
+- Use `fmt.Errorf` with `%w` for error wrapping
+- Log errors at the point where they can be handled
+- Return errors to callers for decision-making
+
+### Logging Strategy
+- Structured logging with levels (Debug, Info, Warn, Error)
+- Logger passed as dependency, not accessed globally (except via GetLogger)
+- Debug logging for detailed diagnostics
+- Info logging for important events
+- Error logging for failures
+
+### Context Propagation
+- Pass `context.Context` as first parameter for I/O operations
+- Use context for cancellation and timeouts
+- Respect context cancellation in long-running operations
+
+### Configuration Management
+- YAML-based configuration files
+- Support for multiple configuration files with merging
+- Validation at load time
+- Default values for optional settings
+
+## Security Architecture
+
+### Defense in Depth
+- Multiple layers of security (constraints, runners, validation)
+- Fail-safe defaults (deny by default)
+- Explicit whitelisting over blacklisting
+
+### Constraint System
+- CEL-based constraint evaluation
+- Constraints compiled at startup for early error detection
+- Constraint failures block command execution
+- Detailed logging of constraint evaluation
+
+### Runner Isolation
+- Sandboxed execution environments (firejail, sandbox-exec, docker)
+- Minimal permissions by default
+- Network isolation when possible
+- Filesystem restrictions
+
+### Input Validation
+- Type checking for all parameters
+- Constraint validation before execution
+- Template validation at load time
+- Path normalization and validation
+
+## Testing Architecture
+
+### Test Organization
+- Unit tests in same package as source code (`*_test.go`)
+- Integration tests in `tests/` directory
+- Shell scripts for E2E testing
+- Test utilities in `tests/common/`
+
+### Test Patterns
+- Table-driven tests for multiple scenarios
+- Test logger that discards output
+- Mock implementations of interfaces
+- Separate test fixtures and data
+
+### Test Coverage
+- Unit tests for business logic
+- Integration tests for command execution
+- E2E tests for full workflows
+- Security tests for constraint validation
+
+## Extension Points
+
+### Adding New Runners
+1. Implement the `Runner` interface
+2. Add runner-specific options and requirements
+3. Register runner in runner factory
+4. Add tests for new runner
+5. Document runner capabilities and limitations
+
+### Adding New Commands
+1. Create command file in `cmd/` package
+2. Define command structure with Cobra
+3. Implement command logic
+4. Add command to root command in `init()`
+5. Add tests and documentation
+
+### Adding New Model Providers
+1. Implement the `ModelProvider` interface
+2. Add provider-specific configuration
+3. Register provider in model factory
+4. Add tests for provider integration
+5. Document provider setup and usage
+
+## Performance Considerations
+
+### Constraint Compilation
+- Constraints compiled once at startup
+- Compiled constraints reused for all executions
+- Reduces overhead for repeated tool calls
+
+### Template Caching
+- Templates parsed once during handler creation
+- Reused for all executions of the same tool
+- Reduces parsing overhead
+
+### Concurrent Execution
+- Tools can be executed concurrently
+- Context-based cancellation for timeouts
+- Proper cleanup of resources
+
+## Scalability Considerations
+
+### Multiple Configuration Files
+- Support for loading multiple configuration files
+- Configuration merging for combining tool sets
+- Efficient tool registration and lookup
+
+### Large Tool Sets
+- Efficient tool registration
+- Fast tool lookup by name
+- Minimal memory overhead per tool
+
+### Long-Running Operations
+- Context-based timeouts
+- Graceful cancellation
+- Resource cleanup on timeout or cancellation
diff --git a/.augment/rules/configuration.md b/.augment/rules/configuration.md
new file mode 100644
index 0000000..33d5f85
--- /dev/null
+++ b/.augment/rules/configuration.md
@@ -0,0 +1,66 @@
+# Configuration Standards for MCPShell
+
+## YAML Structure
+```yaml
+mcp:
+ description: "What this tool collection does"
+ run:
+ shell: bash
+ tools:
+ - name: "tool_name"
+ description: "What the tool does"
+ run:
+ command: "echo {{ .param }}"
+ params:
+ param:
+ type: string
+ description: "Parameter description"
+ required: true
+```
+
+## Required Fields
+- MCP server: `description`
+- Each tool: `name`, `description`, `run.command`
+- Each parameter: `description`
+
+## Tool Naming
+- Lowercase with underscores: `disk_usage`, `file_reader`
+- Descriptive and concise
+
+## Parameters
+- Types: `string`, `number`, `integer`, `boolean`
+- Mark as `required: true` or provide `default` values
+- Write detailed descriptions for LLM understanding
+
+## Constraints
+- **ALWAYS** include constraints for user input
+- Add inline comments explaining each constraint
+- Common patterns: command injection prevention, path traversal, length limits, whitelisting
+
+## Templates
+- Go template syntax: `{{ .param_name }}`
+- Quote variables: `"{{ .param }}"`
+- Supports Sprig functions
+
+## Runners
+- Order by preference (most restrictive first)
+- Include fallback (usually `exec`)
+- Disable networking when not needed: `allow_networking: false`
+- Specify OS requirements for platform-specific runners
+
+## Environment Variables
+- **ONLY** pass explicitly whitelisted variables
+- Document why each is needed
+
+## Timeouts
+- **ALWAYS** specify timeout for commands that may hang
+- Format: `"30s"`, `"5m"`, `"1h30m"`
+
+## Validation
+- Use `mcpshell validate --tools `
+- Run `make validate-examples` in CI/CD
+
+## Agent Mode
+
+For AI agent functionality (LLM connectivity, RAG support), see the
+[Don](https://github.com/inercia/don) project which uses MCPShell's tool configuration.
diff --git a/.augment/rules/documentation.md b/.augment/rules/documentation.md
new file mode 100644
index 0000000..7857aef
--- /dev/null
+++ b/.augment/rules/documentation.md
@@ -0,0 +1,27 @@
+# Documentation Standards for MCPShell
+
+## Code Documentation
+- **ALWAYS** include package-level documentation: `// Package .`
+- Document all exported functions, types, and important fields
+- Start comments with the name of what's being documented
+- Use complete sentences with proper punctuation
+
+## Configuration Documentation
+- Document all options in `docs/config.md`
+- Document environment variables in `docs/config-env.md`
+- Provide well-commented examples in `examples/`
+- Include security rationale for constraints
+- Show both simple and advanced patterns
+- Cross-link related documentation (config, env vars, usage guides)
+
+## Security Documentation
+- Maintain comprehensive `docs/security.md`
+- Include prominent security warnings
+- Explain risks of LLM command execution
+- Provide secure configuration examples
+
+## Markdown Standards
+- Use ATX-style headers (`#`, `##`, `###`)
+- Specify language for code blocks (`yaml`, `go`, `bash`)
+- Use descriptive link text
+- Use relative links for internal docs
diff --git a/.augment/rules/go.md b/.augment/rules/go.md
new file mode 100644
index 0000000..8f84781
--- /dev/null
+++ b/.augment/rules/go.md
@@ -0,0 +1,28 @@
+# Go Coding Standards for MCPShell
+
+## Package Documentation
+- **ALWAYS** include package-level documentation: `// Package .`
+- Explain package purpose and responsibilities
+
+## Error Handling & Logging
+- Wrap errors with `fmt.Errorf` and `%w`: `fmt.Errorf("failed to compile constraint '%s': %w", expr, err)`
+- Error messages: lowercase, no punctuation
+- **ALWAYS** use `common.Logger` (never `fmt.Println` or `log.Println`)
+- Logger passed as parameter to functions
+- Levels: Debug (diagnostics), Info (events), Warn (non-critical), Error (failures)
+
+## Panic Recovery
+- Use `defer common.RecoverPanic()` at entry points and goroutines
+
+## Project-Specific Patterns
+- YAML config with tags: `yaml:"field_name,omitempty"`
+- Templates: Go `text/template` + Sprig functions, variables as `{{ .param_name }}`
+- Context: First parameter for I/O operations, use `context.WithTimeout`
+- Constructors: Provide `New*` functions for complex types
+- Type assertions: Check success, handle failures gracefully
+
+## Code Quality
+- Run `make format` before commits (runs `go fmt ./...` and `go mod tidy`)
+- Pass `golangci-lint` checks
+- Godoc-style comments for exported APIs
+- Never manually edit `go.mod`
diff --git a/.augment/rules/mcp-protocol.md b/.augment/rules/mcp-protocol.md
new file mode 100644
index 0000000..d311ea2
--- /dev/null
+++ b/.augment/rules/mcp-protocol.md
@@ -0,0 +1,256 @@
+---
+type: "agent_requested"
+description: "MCP: What is MCP? Protocol Communication, Server Implementation, Tool Registration, Request Handling"
+---
+
+# MCP Protocol Implementation Guidelines for MCPShell
+
+## MCP Protocol Overview
+
+### What is MCP?
+- Model Context Protocol (MCP) is a standard protocol for connecting LLMs to external tools and data sources
+- MCPShell implements the MCP server side, exposing command-line tools as MCP tools
+- MCP clients (Cursor, VSCode, Claude Desktop) connect to MCPShell to access these tools
+
+### Protocol Communication
+- MCPShell uses the `github.com/mark3labs/mcp-go` library for MCP protocol implementation
+- Supports stdio transport (standard input/output) for communication
+- JSON-RPC 2.0 message format for requests and responses
+
+## Server Implementation
+
+### Server Lifecycle
+1. **Initialization**: Load configuration and create server instance
+2. **Tool Registration**: Register all tools from configuration
+3. **Server Start**: Begin listening for MCP requests
+4. **Request Processing**: Handle tool calls and return results
+5. **Shutdown**: Clean up resources and close connections
+
+### Server Creation
+- Use `server.New()` to create a server instance with configuration
+- Call `CreateServer()` to initialize the MCP server and register tools
+- Call `Start()` to begin processing requests
+- Example:
+ ```go
+ srv := server.New(server.Config{
+ ConfigFile: configPath,
+ Logger: logger,
+ Version: version,
+ })
+
+ if err := srv.CreateServer(); err != nil {
+ return fmt.Errorf("failed to create server: %w", err)
+ }
+
+ if err := srv.Start(); err != nil {
+ return fmt.Errorf("failed to start server: %w", err)
+ }
+ ```
+
+## Tool Registration
+
+### Tool Definition
+- Each tool is defined in YAML configuration
+- Tools are converted to MCP tool format during registration
+- Tool schema is generated from parameter definitions
+- Example MCP tool structure:
+ ```go
+ mcp.Tool{
+ Name: "tool_name",
+ Description: "Tool description",
+ InputSchema: mcp.ToolInputSchema{
+ Type: "object",
+ Properties: map[string]interface{}{
+ "param_name": map[string]interface{}{
+ "type": "string",
+ "description": "Parameter description",
+ },
+ },
+ Required: []string{"param_name"},
+ },
+ }
+ ```
+
+### Handler Registration
+- Each tool has an associated handler function
+- Handlers implement the `mcpserver.ToolHandlerFunc` signature
+- Handlers are wrapped with panic recovery
+- Example:
+ ```go
+ type ToolHandlerFunc func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error)
+ ```
+
+### Tool Validation
+- Tools are validated during registration
+- Constraint compilation happens at registration time
+- Invalid tools are rejected with clear error messages
+- Prerequisites (OS, executables) are checked before registration
+
+## Request Handling
+
+### Request Flow
+1. MCP client sends `tools/call` request
+2. Server routes request to appropriate tool handler
+3. Handler validates parameters and constraints
+4. Handler executes command via runner
+5. Handler formats output and returns result
+6. Server sends response back to client
+
+### Parameter Handling
+- Parameters are extracted from `request.Params.Arguments`
+- Type assertions are performed to ensure correct types
+- Default values are applied for optional parameters
+- Parameters are validated against constraints before execution
+
+### Error Handling
+- Errors are returned as `mcp.CallToolResult` with error content
+- Use `mcp.NewToolResultError()` for error results
+- Use `mcp.NewToolResultText()` for success results
+- Example:
+ ```go
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ return mcp.NewToolResultText(output), nil
+ ```
+
+## Tool Execution
+
+### Execution Flow
+1. Extract and validate parameters
+2. Apply default values for optional parameters
+3. Evaluate constraints
+4. Render command template with parameters
+5. Select and configure runner
+6. Execute command via runner
+7. Format output with prefix if configured
+8. Return result to client
+
+### Constraint Evaluation
+- Constraints are evaluated before command execution
+- All constraints must pass for execution to proceed
+- Failed constraints block execution and return error
+- Constraint failures are logged with details
+
+### Command Execution
+- Commands are executed via runner implementations
+- Runners provide isolation and security
+- Timeouts are enforced via context
+- Output is captured and returned to client
+
+## Response Formatting
+
+### Success Responses
+- Return command output as text content
+- Apply output prefix if configured
+- Trim whitespace from output
+- Example:
+ ```go
+ return mcp.NewToolResultText(output), nil
+ ```
+
+### Error Responses
+- Return error message as error content
+- Include context about what failed
+- Don't leak sensitive information in errors
+- Example:
+ ```go
+ return mcp.NewToolResultError("command execution failed: invalid parameter"), nil
+ ```
+
+## Agent Mode Integration
+
+For AI agent functionality that uses MCPShell tools, see the
+[Don](https://github.com/inercia/don) project. Don spawns MCPShell as a
+subprocess to execute MCP tools while handling LLM connectivity and
+conversation management.
+
+## Protocol Extensions
+
+### Custom Descriptions
+- Support for custom server descriptions via flags
+- Descriptions can be loaded from files or URLs
+- Multiple descriptions can be concatenated
+- Descriptions help LLMs understand tool capabilities
+
+### Prompts Configuration
+- Support for custom prompts in configuration
+- Prompts provide additional context to LLMs
+- Prompts are exposed via MCP protocol
+- Example:
+ ```yaml
+ prompts:
+ - name: "example_prompt"
+ description: "Example prompt description"
+ arguments:
+ - name: "arg1"
+ description: "Argument description"
+ required: true
+ ```
+
+## Best Practices
+
+### Tool Design
+- Keep tools focused on single tasks
+- Provide clear, descriptive tool names
+- Write comprehensive tool descriptions
+- Include examples in descriptions when helpful
+
+### Parameter Design
+- Use descriptive parameter names
+- Provide detailed parameter descriptions
+- Set appropriate default values
+- Mark required parameters explicitly
+
+### Error Messages
+- Provide actionable error messages
+- Include context about what failed
+- Suggest how to fix the problem
+- Don't leak sensitive information
+
+### Performance
+- Compile constraints at registration time
+- Parse templates once during handler creation
+- Use context for timeouts and cancellation
+- Clean up resources properly
+
+## Testing MCP Integration
+
+### Unit Testing
+- Test tool registration logic
+- Test parameter extraction and validation
+- Test constraint evaluation
+- Test error handling
+
+### Integration Testing
+- Test full request/response cycle
+- Test with actual MCP clients when possible
+- Test error scenarios
+- Test timeout handling
+
+### Manual Testing
+- Use `mcpshell exe` command for direct tool testing
+- Test with MCP clients (Cursor, VSCode)
+- Verify tool descriptions are clear
+- Test with various parameter combinations
+
+## Debugging MCP Issues
+
+### Logging
+- Enable debug logging with `--log-level debug`
+- Log all tool registrations
+- Log all tool executions
+- Log constraint evaluations
+
+### Common Issues
+- **Tool not appearing in client**: Check tool registration logs
+- **Parameter validation failing**: Check constraint definitions
+- **Command execution failing**: Check runner configuration
+- **Timeout errors**: Adjust timeout values in configuration
+
+### Troubleshooting Steps
+1. Check server logs for errors
+2. Verify configuration file syntax
+3. Test tool directly with `mcpshell exe`
+4. Verify MCP client configuration
+5. Check network connectivity (if using HTTP transport)
diff --git a/.augment/rules/security.md b/.augment/rules/security.md
new file mode 100644
index 0000000..246d782
--- /dev/null
+++ b/.augment/rules/security.md
@@ -0,0 +1,56 @@
+# Security Rules for MCPShell
+
+## Core Principles
+- **NEVER** allow arbitrary command execution without strict constraints
+- **ALWAYS** prefer read-only operations
+- **ALWAYS** validate inputs with CEL constraints
+- **ALWAYS** use sandboxed runners when possible
+
+## Common Constraints
+
+### Command Injection Prevention
+```yaml
+constraints:
+ - "!param.contains(';')" # Prevent command chaining
+ - "!param.contains('&&')" # Prevent command chaining
+ - "!param.contains('|')" # Prevent piping
+ - "!param.contains('`')" # Prevent command substitution
+ - "!param.contains('$(')" # Prevent command substitution
+```
+
+### Path Traversal Prevention
+```yaml
+constraints:
+ - "!path.contains('../')" # Prevent directory traversal
+ - "path.startsWith('/allowed/directory/')" # Restrict to specific directory
+ - "path.matches('^[a-zA-Z0-9_\\-./]+$')" # Only safe characters
+```
+
+### Input Validation
+```yaml
+constraints:
+ - "param.size() > 0 && param.size() <= 1000" # Length limits
+ - "['ls', 'cat', 'echo'].exists(cmd, cmd == command)" # Command whitelist
+ - "['.txt', '.log', '.md'].exists(ext, filepath.endsWith(ext))" # File extensions
+```
+
+## Runner Security
+- Use most restrictive runner available
+- Disable networking: `allow_networking: false`
+- Restrict filesystem access
+- Specify OS requirements
+
+## Environment Variables
+- **ONLY** pass explicitly whitelisted variables
+- **NEVER** log sensitive data
+- Document why each variable is needed
+
+## Security Checklist for New Tools
+- [ ] All parameters have constraints
+- [ ] Command injection blocked
+- [ ] Path traversal prevented
+- [ ] Input length limits enforced
+- [ ] Appropriate runner selected
+- [ ] Environment variables whitelisted
+- [ ] Tool is read-only or justified
+- [ ] Tested with malicious inputs
diff --git a/.augment/rules/testing.md b/.augment/rules/testing.md
new file mode 100644
index 0000000..5f6a2fd
--- /dev/null
+++ b/.augment/rules/testing.md
@@ -0,0 +1,33 @@
+# Testing Standards for MCPShell
+
+## Test Organization
+- Test files: `*_test.go` in same package as source
+- Integration tests: `tests/` directory
+- E2E tests: `tests/test_*.sh` shell scripts
+
+## Unit Testing
+- Use table-driven tests for multiple scenarios
+- Test logger: `var testLogger, _ = common.NewLogger("", "", common.LogLevelNone, false)`
+- Test both success and failure cases
+- **ALWAYS** test constraint validation logic
+- **ALWAYS** test error handling paths
+- **ALWAYS** test parameter type conversions
+- **ALWAYS** test template rendering
+- **ALWAYS** test runner selection
+
+## Integration Testing
+- Shell scripts in `tests/` directory
+- Use utilities from `tests/common/common.sh`: `info()`, `success()`, `fail()`, `skip()`
+- Run with `make test-e2e`
+
+## Constraint Testing
+- Test constraint compilation (valid/invalid expressions)
+- Test constraint evaluation with various values
+- **ALWAYS** test security constraints block malicious inputs
+- Test command injection, path traversal, input limits
+
+## Test Execution
+- Unit tests: `make test`
+- Integration tests: `make test-e2e`
+- Race detection: `go test -race ./...`
+- Coverage: `go test -cover ./...`
From bb114769be3551224bda6d4d287f76c18c5fbef9 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 18:10:04 +0100
Subject: [PATCH 13/22] fix: remove .code-workspace
Signed-off-by: Alvaro Saurin
---
.gitignore | 1 +
mcpshell.code-workspace | 20 --------------------
2 files changed, 1 insertion(+), 20 deletions(-)
delete mode 100644 mcpshell.code-workspace
diff --git a/.gitignore b/.gitignore
index 0cc43c1..1514b1e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,6 +31,7 @@ coverage.txt
*.swp
*.swo
*~
+*.code-workspace
# Binary distribution files
mcpshell-*
diff --git a/mcpshell.code-workspace b/mcpshell.code-workspace
deleted file mode 100644
index 5f30e54..0000000
--- a/mcpshell.code-workspace
+++ /dev/null
@@ -1,20 +0,0 @@
-{
- "folders": [
- {
- "path": "."
- },
- {
- "path": "../don"
- }
- ],
- "settings": {
- "[go]": {
- "editor.insertSpaces": false,
- "editor.formatOnSave": true,
- "editor.codeActionsOnSave": {
- "source.organizeImports": "never"
- },
- "editor.suggest.snippetsPreventQuickSuggestions": false
- }
- }
-}
\ No newline at end of file
From 1ebf260c94a6496341c645fc72f32eba736c70e8 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 18:19:46 +0100
Subject: [PATCH 14/22] fix(security): prevent MCP clients from overriding
runner options
Runner options (Docker image, user, network settings, etc.) are now
only taken from the server-side tool configuration. External callers
(MCP clients, CLI users) cannot override these options to prevent
privilege escalation attacks.
This addresses the security concern raised in PR review where a
malicious client could specify a different Docker image or user.
---
pkg/command/command.go | 13 +++++--------
pkg/command/command_exec.go | 37 ++++++++++++-------------------------
2 files changed, 17 insertions(+), 33 deletions(-)
diff --git a/pkg/command/command.go b/pkg/command/command.go
index 992c622..76216db 100644
--- a/pkg/command/command.go
+++ b/pkg/command/command.go
@@ -124,13 +124,10 @@ func (h *CommandHandler) GetMCPHandler() func(ctx context.Context, request mcp.C
}
}
- // Extract runner options if present
- var runnerOpts map[string]interface{}
- if args != nil {
- if opts, ok := args["options"].(map[string]interface{}); ok {
- runnerOpts = opts
- }
- }
+ // NOTE: Runner options are NOT extracted from client arguments for security reasons.
+ // Allowing MCP clients to override runner options (like Docker image, user, network
+ // settings) could lead to privilege escalation or arbitrary code execution.
+ // Runner options must be defined server-side in the tool configuration only.
// Apply timeout if configured
executionCtx := ctx
@@ -145,7 +142,7 @@ func (h *CommandHandler) GetMCPHandler() func(ctx context.Context, request mcp.C
}
// Execute the command using the common implementation
- output, _, err := h.executeToolCommand(executionCtx, args, runnerOpts)
+ output, _, err := h.executeToolCommand(executionCtx, args)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff --git a/pkg/command/command_exec.go b/pkg/command/command_exec.go
index f5f3ef7..aa53d88 100644
--- a/pkg/command/command_exec.go
+++ b/pkg/command/command_exec.go
@@ -17,13 +17,16 @@ import (
// Parameters:
// - ctx: Context for command execution
// - params: Map of parameter names to their values
-// - extraRunnerOpts: Additional runner options to apply
//
// Returns:
// - The command output as a string
// - A slice of failed constraint messages
// - An error if command execution fails
-func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[string]interface{}, extraRunnerOpts map[string]interface{}) (string, []string, error) {
+//
+// Security note: Runner options are only taken from the server-side tool configuration.
+// External callers (MCP clients, CLI users) cannot override runner options to prevent
+// privilege escalation attacks (e.g., specifying a different Docker image or user).
+func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[string]interface{}) (string, []string, error) {
// Log the tool execution
h.logger.Debug("Tool execution requested for '%s'", h.toolName)
h.logger.Debug("Arguments: %v", params)
@@ -141,20 +144,13 @@ func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[stri
}
}
- // Start with the configured runner options from the tool definition
+ // Use the configured runner options from the tool definition only
+ // (external callers cannot override these for security reasons)
runnerOptions := runner.Options{}
for k, v := range h.runnerOpts {
runnerOptions[k] = v
}
- // Add or override with any options from the parameters if present
- if extraRunnerOpts != nil {
- h.logger.Debug("Found runner options in parameters: %v", extraRunnerOpts)
- for k, v := range extraRunnerOpts {
- runnerOptions[k] = v
- }
- }
-
// Create the appropriate runner with options
h.logger.Debug("Creating runner of type %s and checking implicit requirements", runnerType)
r, err := runner.New(runnerType, runnerOptions, h.logger)
@@ -203,19 +199,10 @@ func (h *CommandHandler) executeToolCommand(ctx context.Context, params map[stri
// - The command output as a string
// - An error if command execution fails
func (h *CommandHandler) ExecuteCommand(params map[string]interface{}) (string, error) {
- // Extract runner options if present
- var runnerOpts map[string]interface{}
- if opts, ok := params["options"].(map[string]interface{}); ok {
- runnerOpts = opts
- // Remove options from params to avoid processing them as command parameters
- tmpParams := make(map[string]interface{})
- for k, v := range params {
- if k != "options" {
- tmpParams[k] = v
- }
- }
- params = tmpParams
- }
+ // NOTE: Runner options are NOT extracted from params for security reasons.
+ // Runner options must be defined in the tool configuration only.
+ // This prevents users from overriding security-sensitive settings like
+ // Docker image, user, or network configuration through command-line parameters.
// Create context with timeout for command execution
// Use configured timeout if available, otherwise use a default of 60 seconds
@@ -231,7 +218,7 @@ func (h *CommandHandler) ExecuteCommand(params map[string]interface{}) (string,
defer cancel()
// Use the common implementation
- output, failedConstraints, err := h.executeToolCommand(ctx, params, runnerOpts)
+ output, failedConstraints, err := h.executeToolCommand(ctx, params)
// If constraints failed, format the error message
if err != nil && len(failedConstraints) > 0 {
From 31c171adc301d5d83c3680d783f68d3240b7684b Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 18:19:53 +0100
Subject: [PATCH 15/22] fix: remove unused MCPShellAgentConfigEnv constant
The agent functionality was removed, so this constant is no longer needed.
---
pkg/utils/home.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/pkg/utils/home.go b/pkg/utils/home.go
index 614fdd1..6058c70 100644
--- a/pkg/utils/home.go
+++ b/pkg/utils/home.go
@@ -13,8 +13,6 @@ const (
MCPShellDirEnv = "MCPSHELL_DIR"
// MCPShellToolsDirEnv is the environment variable that specifies the tools directory for MCPShell
MCPShellToolsDirEnv = "MCPSHELL_TOOLS_DIR"
- // MCPShellAgentConfigEnv is the environment variable that specifies the agent configuration file path
- MCPShellAgentConfigEnv = "MCPSHELL_AGENT_CONFIG"
// MCPShellHome is the name of the configuration directory for MCPShell
MCPShellHome = ".mcpshell"
// MCPShellToolsDir is the name of the tools directory within MCPShell home
From 45a28fa7e360caf1178306d75cff6e6cb6f4fe39 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 18:19:59 +0100
Subject: [PATCH 16/22] fix: rename ResolveMultipleConfigPath to
ResolveMultipleConfigPaths
The function handles multiple config paths, so the plural form is
more semantically correct.
---
cmd/exe.go | 2 +-
cmd/mcp.go | 2 +-
cmd/validate.go | 2 +-
pkg/config/resolve.go | 4 ++--
4 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/cmd/exe.go b/cmd/exe.go
index 3e2c236..aa981aa 100644
--- a/cmd/exe.go
+++ b/cmd/exe.go
@@ -70,7 +70,7 @@ will be reported.
logger.Debug("Executing tool: %s", toolName)
// Load the configuration file(s) (local or remote)
- localConfigPath, cleanup, err := config.ResolveMultipleConfigPath(toolsFiles, logger)
+ localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
if err != nil {
logger.Error("Failed to load configuration: %v", err)
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/cmd/mcp.go b/cmd/mcp.go
index 326e9b1..3d8a979 100644
--- a/cmd/mcp.go
+++ b/cmd/mcp.go
@@ -80,7 +80,7 @@ and ignore SIGHUP signals.
}
// Load the configuration file(s) (local or remote)
- localConfigPath, cleanup, err := config.ResolveMultipleConfigPath(toolsFiles, logger)
+ localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
if err != nil {
logger.Error("Failed to load configuration: %v", err)
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/cmd/validate.go b/cmd/validate.go
index 85e33f7..7e199a8 100644
--- a/cmd/validate.go
+++ b/cmd/validate.go
@@ -57,7 +57,7 @@ This command checks the configuration file for errors including:
}()
// Load the configuration file(s) (local or remote)
- localConfigPath, cleanup, err := config.ResolveMultipleConfigPath(toolsFiles, logger)
+ localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
if err != nil {
logger.Error("Failed to load configuration: %v", err)
return fmt.Errorf("failed to load configuration: %w", err)
diff --git a/pkg/config/resolve.go b/pkg/config/resolve.go
index 43fb5ad..9b5f070 100644
--- a/pkg/config/resolve.go
+++ b/pkg/config/resolve.go
@@ -215,11 +215,11 @@ func createMergedConfigFile(yamlFiles []string, logger *common.Logger) (string,
return tmpFilePath, cleanup, nil
}
-// ResolveMultipleConfigPath tries to resolve multiple tools file paths.
+// ResolveMultipleConfigPaths tries to resolve multiple tools file paths.
// It handles each path individually (URLs, directories, local files) and then merges
// all configurations into a single temporary file.
// Returns the local path to the merged configuration file and a cleanup function.
-func ResolveMultipleConfigPath(configs []string, logger *common.Logger) (string, func(), error) {
+func ResolveMultipleConfigPaths(configs []string, logger *common.Logger) (string, func(), error) {
// Default no-op cleanup function
noopCleanup := func() {}
From e62037067ebd53afffd271c20358ecd8b69c35e6 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 18:20:08 +0100
Subject: [PATCH 17/22] test: restore missing runner tests
Restore tests that were removed during the runner package extraction:
- TestDocker_Run_Networking: tests network isolation
- TestDocker_Run_PrepareCommand: tests prepare_command option
- TestExec_Optimization_SingleExecutable: tests single executable optimization
- Expanded TestNewDockerOptions assertions for all fields
- Expanded TestSandboxExec_Run with template and env variable tests
---
pkg/runner/docker_test.go | 130 +++++++++++++++++++++++++++++++++++++
pkg/runner/exec_test.go | 30 +++++++++
pkg/runner/sandbox_test.go | 76 ++++++++++++++++++++++
3 files changed, 236 insertions(+)
diff --git a/pkg/runner/docker_test.go b/pkg/runner/docker_test.go
index dece40a..f4e518a 100644
--- a/pkg/runner/docker_test.go
+++ b/pkg/runner/docker_test.go
@@ -2,6 +2,7 @@ package runner
import (
"context"
+ "os"
"os/exec"
"runtime"
"strings"
@@ -159,6 +160,103 @@ func TestDocker_Run_EnvironmentVariables(t *testing.T) {
}
}
+func TestDocker_Run_Networking(t *testing.T) {
+ // Skip on Windows - Alpine Linux doesn't support Windows containers
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
+ }
+
+ // Skip if docker is not available or not running
+ if !checkDockerRunning() {
+ t.Skip("Docker not installed or not running, skipping test")
+ }
+
+ // Check if running in GitHub Actions
+ inGitHubActions := os.Getenv("GITHUB_ACTIONS") == "true"
+ if inGitHubActions {
+ t.Skip("Skipping network test in GitHub Actions environment")
+ }
+
+ logger, _ := common.NewLogger("test-docker: ", "", common.LogLevelInfo, false)
+
+ testCases := []struct {
+ name string
+ allowNetworking bool
+ expectSuccess bool
+ }{
+ {
+ name: "With networking",
+ allowNetworking: true,
+ expectSuccess: true,
+ },
+ {
+ name: "Without networking",
+ allowNetworking: false,
+ expectSuccess: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Create a runner with specified networking
+ r, err := NewDocker(Options{
+ "image": "alpine:latest",
+ "allow_networking": tc.allowNetworking,
+ }, logger)
+
+ if err != nil {
+ t.Fatalf("Failed to create Docker runner: %v", err)
+ }
+
+ // Try to ping google.com (will fail if networking is disabled)
+ _, err = r.Run(context.Background(), "", "ping -c 1 -W 1 google.com", nil, nil, false)
+
+ if tc.expectSuccess && err != nil {
+ t.Errorf("Expected network ping to succeed but got error: %v", err)
+ }
+
+ if !tc.expectSuccess && err == nil {
+ t.Errorf("Expected network ping to fail but it succeeded")
+ }
+ })
+ }
+}
+
+func TestDocker_Run_PrepareCommand(t *testing.T) {
+ // Skip on Windows - Alpine Linux doesn't support Windows containers
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping Docker test on Windows - Alpine Linux image not compatible with Windows containers")
+ }
+
+ // Skip if docker is not available or not running
+ if !checkDockerRunning() {
+ t.Skip("Docker not installed or not running, skipping test")
+ }
+
+ logger, _ := common.NewLogger("test-docker: ", "", common.LogLevelInfo, false)
+
+ // Create a runner with alpine image and prepare command to install grep
+ r, err := NewDocker(Options{
+ "image": "alpine:latest",
+ "prepare_command": "apk add --no-cache grep",
+ }, logger)
+
+ if err != nil {
+ t.Fatalf("Failed to create Docker runner: %v", err)
+ }
+
+ // Run grep command that should only work if the prepare_command executed properly
+ output, err := r.Run(context.Background(), "", "grep --version | head -n 1", nil, nil, false)
+ if err != nil {
+ t.Errorf("Failed to run command that requires prepare_command: %v", err)
+ }
+
+ // Check the output contains grep version information
+ if !strings.Contains(output, "grep") {
+ t.Errorf("Expected output to contain grep version information, got: %q", output)
+ }
+}
+
func TestDocker_Optimization_SingleExecutable(t *testing.T) {
// Skip on Windows - Alpine Linux doesn't support Windows containers
if runtime.GOOS == "windows" {
@@ -282,9 +380,41 @@ func TestNewDockerOptions(t *testing.T) {
if result.Image != tc.expected.Image {
t.Errorf("Image: expected %q, got %q", tc.expected.Image, result.Image)
}
+ if result.DockerRunOpts != tc.expected.DockerRunOpts {
+ t.Errorf("DockerRunOpts: expected %q, got %q", tc.expected.DockerRunOpts, result.DockerRunOpts)
+ }
if result.AllowNetworking != tc.expected.AllowNetworking {
t.Errorf("AllowNetworking: expected %v, got %v", tc.expected.AllowNetworking, result.AllowNetworking)
}
+ if result.Network != tc.expected.Network {
+ t.Errorf("Network: expected %q, got %q", tc.expected.Network, result.Network)
+ }
+ if result.User != tc.expected.User {
+ t.Errorf("User: expected %q, got %q", tc.expected.User, result.User)
+ }
+ if result.WorkDir != tc.expected.WorkDir {
+ t.Errorf("WorkDir: expected %q, got %q", tc.expected.WorkDir, result.WorkDir)
+ }
+ if result.PrepareCommand != tc.expected.PrepareCommand {
+ t.Errorf("PrepareCommand: expected %q, got %q", tc.expected.PrepareCommand, result.PrepareCommand)
+ }
+
+ // Check slice fields
+ if !compareStringSlices(result.Mounts, tc.expected.Mounts) {
+ t.Errorf("Mounts: expected %v, got %v", tc.expected.Mounts, result.Mounts)
+ }
+ if !compareStringSlices(result.CapAdd, tc.expected.CapAdd) {
+ t.Errorf("CapAdd: expected %v, got %v", tc.expected.CapAdd, result.CapAdd)
+ }
+ if !compareStringSlices(result.CapDrop, tc.expected.CapDrop) {
+ t.Errorf("CapDrop: expected %v, got %v", tc.expected.CapDrop, result.CapDrop)
+ }
+ if !compareStringSlices(result.DNS, tc.expected.DNS) {
+ t.Errorf("DNS: expected %v, got %v", tc.expected.DNS, result.DNS)
+ }
+ if !compareStringSlices(result.DNSSearch, tc.expected.DNSSearch) {
+ t.Errorf("DNSSearch: expected %v, got %v", tc.expected.DNSSearch, result.DNSSearch)
+ }
})
}
}
diff --git a/pkg/runner/exec_test.go b/pkg/runner/exec_test.go
index 39ea5d4..5c9eb71 100644
--- a/pkg/runner/exec_test.go
+++ b/pkg/runner/exec_test.go
@@ -163,3 +163,33 @@ func TestExec_RunWithEnvExpansion(t *testing.T) {
t.Errorf("Environment variable expansion failed: got %q, want %q", output, expected)
}
}
+
+func TestExec_Optimization_SingleExecutable(t *testing.T) {
+ logger, _ := common.NewLogger("test-runner-exec-opt: ", "", common.LogLevelInfo, false)
+ r, err := NewExec(Options{}, logger)
+ if err != nil {
+ t.Fatalf("Failed to create Exec: %v", err)
+ }
+
+ // This command should be a single executable and run directly
+ command := "whoami"
+ output, err := r.Run(context.Background(), "", command, nil, nil, false)
+ if err != nil {
+ t.Errorf("Expected '%s' to run without error, got: %v", command, err)
+ }
+ if len(strings.TrimSpace(output)) == 0 {
+ t.Errorf("Expected output from '%s', got empty string", command)
+ }
+
+ // This command has arguments and should be run via a shell, not directly.
+ // isSingleExecutableCommand should return false.
+ // The command itself should succeed when run through the shell.
+ commandWithArgs := "echo hello"
+ output, err = r.Run(context.Background(), "", commandWithArgs, nil, nil, false)
+ if err != nil {
+ t.Errorf("Expected '%s' to run without error, got: %v", commandWithArgs, err)
+ }
+ if strings.TrimSpace(output) != "hello" {
+ t.Errorf("Expected output from '%s' to be 'hello', got %q", commandWithArgs, output)
+ }
+}
diff --git a/pkg/runner/sandbox_test.go b/pkg/runner/sandbox_test.go
index 6c9b5a8..a1cd239 100644
--- a/pkg/runner/sandbox_test.go
+++ b/pkg/runner/sandbox_test.go
@@ -2,6 +2,7 @@ package runner
import (
"context"
+ "os"
"reflect"
"runtime"
"strings"
@@ -84,6 +85,20 @@ func TestSandboxExec_Run(t *testing.T) {
t.Skip("skipping test in short mode")
}
+ // Set environment variables for the test
+ if err := os.Setenv("ALLOWED_FROM_ENV", "/tmp"); err != nil {
+ t.Fatalf("Failed to set environment variable: %v", err)
+ }
+ if err := os.Setenv("USR_DIR", "/usr"); err != nil {
+ t.Fatalf("Failed to set environment variable: %v", err)
+ }
+
+ // Ensure cleanup
+ defer func() {
+ _ = os.Unsetenv("ALLOWED_FROM_ENV")
+ _ = os.Unsetenv("USR_DIR")
+ }()
+
// Create a logger for the test
logger, _ := common.NewLogger("test-runner-sandbox: ", "", common.LogLevelInfo, false)
ctx := context.Background()
@@ -127,6 +142,67 @@ func TestSandboxExec_Run(t *testing.T) {
shouldSucceed: true,
expectedOut: "Restricted",
},
+ {
+ name: "read /tmp with folder restrictions",
+ command: "ls -la /tmp | grep -q . && echo 'success'",
+ options: Options{
+ "allow_networking": false,
+ "allow_user_folders": false,
+ },
+ shouldSucceed: true,
+ expectedOut: "success",
+ },
+ {
+ name: "custom profile allowing only /tmp",
+ command: "ls -la /tmp | grep -q . && echo 'success'",
+ options: Options{
+ "custom_profile": `(version 1)
+(allow default)
+(deny file-read* (subpath "/Users"))
+(allow file-read* (regex "^/tmp"))`,
+ },
+ shouldSucceed: true,
+ expectedOut: "success",
+ },
+ {
+ name: "read from allowed folder using env variable",
+ command: "ls -la /tmp > /dev/null && echo 'can read /tmp'",
+ options: Options{
+ "allow_networking": false,
+ "allow_user_folders": false,
+ "allow_read_folders": []string{"{{ env ALLOWED_FROM_ENV }}"},
+ "custom_profile": "",
+ },
+ shouldSucceed: true,
+ expectedOut: "can read /tmp",
+ },
+ {
+ name: "template variables in allow_read_folders",
+ command: "ls -la /var > /dev/null && echo 'can read templated folder'",
+ options: Options{
+ "allow_networking": false,
+ "allow_user_folders": false,
+ "allow_read_folders": []string{"{{.test_folder}}"},
+ "custom_profile": "",
+ },
+ params: map[string]interface{}{
+ "test_folder": "/var",
+ },
+ shouldSucceed: true,
+ expectedOut: "can read templated folder",
+ },
+ {
+ name: "complex env variable template in allow_read_folders",
+ command: "ls -la /usr/bin > /dev/null && echo 'can read /usr/bin'",
+ options: Options{
+ "allow_networking": false,
+ "allow_user_folders": false,
+ "allow_read_folders": []string{"{{ env USR_DIR }}/bin"},
+ "custom_profile": "",
+ },
+ shouldSucceed: true,
+ expectedOut: "can read /usr/bin",
+ },
}
for _, tt := range tests {
From 8ca12b636b9658ef05658e085ab72b47055a118f Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 22:56:21 +0100
Subject: [PATCH 18/22] fix: use sh instead of bash for Docker Alpine container
tests
Alpine Linux uses ash (via /bin/sh) and doesn't have bash installed.
The Docker runner test was failing because it tried to execute 'bash -c'
inside the Alpine container.
---
tests/runners/test_runner_docker.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/runners/test_runner_docker.yaml b/tests/runners/test_runner_docker.yaml
index 98ba18e..a2ce07c 100644
--- a/tests/runners/test_runner_docker.yaml
+++ b/tests/runners/test_runner_docker.yaml
@@ -1,7 +1,7 @@
mcp:
description: "Docker runner test configuration"
run:
- shell: bash
+ shell: sh
tools:
- name: "docker_hello"
description: "Simple hello world command running in Alpine container"
From 61d5b08458147d17e7091891e94442d66893c528 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 22:59:45 +0100
Subject: [PATCH 19/22] fix: quote environment variables in Docker runner to
handle spaces
Environment variables with spaces in their values were causing the docker
command to be parsed incorrectly. For example, 'TEST_MESSAGE=Hello from Docker'
was being split, causing 'from' to be interpreted as the image name.
Using %q format specifier ensures proper quoting.
---
pkg/runner/docker.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/runner/docker.go b/pkg/runner/docker.go
index cefcca4..2c05575 100644
--- a/pkg/runner/docker.go
+++ b/pkg/runner/docker.go
@@ -147,9 +147,9 @@ func (o *DockerOptions) GetBaseDockerCommand(env []string) []string {
parts = append(parts, fmt.Sprintf("-v %s", mount))
}
- // Add environment variables
+ // Add environment variables (quoted to handle values with spaces)
for _, e := range env {
- parts = append(parts, fmt.Sprintf("-e %s", e))
+ parts = append(parts, fmt.Sprintf("-e %q", e))
}
return parts
From 0b4cb2613b5b7d98cfb72b8c08186adb1c87a993 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Tue, 3 Feb 2026 23:05:14 +0100
Subject: [PATCH 20/22] fix: use shell-safe quoting for Docker environment
variables
The previous fix using Go's %q format didn't work correctly when the
docker command was passed through sh -c, as the nested quoting caused
issues.
Added shellQuote() helper function that uses single quotes with proper
escaping for shell-safe quoting of values containing spaces or special
characters.
---
pkg/runner/docker.go | 4 ++--
pkg/runner/util.go | 12 ++++++++++++
2 files changed, 14 insertions(+), 2 deletions(-)
diff --git a/pkg/runner/docker.go b/pkg/runner/docker.go
index 2c05575..33991bf 100644
--- a/pkg/runner/docker.go
+++ b/pkg/runner/docker.go
@@ -147,9 +147,9 @@ func (o *DockerOptions) GetBaseDockerCommand(env []string) []string {
parts = append(parts, fmt.Sprintf("-v %s", mount))
}
- // Add environment variables (quoted to handle values with spaces)
+ // Add environment variables (shell-quoted to handle values with spaces)
for _, e := range env {
- parts = append(parts, fmt.Sprintf("-e %q", e))
+ parts = append(parts, fmt.Sprintf("-e %s", shellQuote(e)))
}
return parts
diff --git a/pkg/runner/util.go b/pkg/runner/util.go
index 444fca7..8fcb985 100644
--- a/pkg/runner/util.go
+++ b/pkg/runner/util.go
@@ -40,3 +40,15 @@ func contains(slice []string, item string) bool {
}
return false
}
+
+// shellQuote returns a shell-safe quoted string.
+// It uses single quotes and escapes any single quotes within the string.
+func shellQuote(s string) string {
+ // If the string contains no special characters, return as-is
+ if !strings.ContainsAny(s, " \t\n'\"\\$`!*?[]{}();<>&|") {
+ return s
+ }
+ // Use single quotes and escape any single quotes by ending the quoted string,
+ // adding an escaped single quote, and starting a new quoted string
+ return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'"
+}
From 77dccdd1a003a9719e4534765cbb8d962cace00a Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 4 Feb 2026 00:11:26 +0100
Subject: [PATCH 21/22] fix: quote environment variable values in Docker script
to handle spaces
When environment variables with spaces (e.g., 'Hello from Docker container')
were passed to the Docker runner, the script was generating unquoted export
statements like:
export TEST_MESSAGE=Hello from Docker container
This caused the shell to only assign 'Hello' to TEST_MESSAGE. Now using
shellQuote() to properly quote values:
export TEST_MESSAGE='Hello from Docker container'
Fixes CI failure in test_runner_docker.sh environment variable test.
---
pkg/runner/docker.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/runner/docker.go b/pkg/runner/docker.go
index 33991bf..7f8b85b 100644
--- a/pkg/runner/docker.go
+++ b/pkg/runner/docker.go
@@ -407,11 +407,11 @@ func (r *Docker) createScriptFile(shell string, cmd string, env []string) (strin
var content strings.Builder
content.WriteString("#!/bin/sh\n\n")
- // Add environment variables
+ // Add environment variables (shell-quoted to handle values with spaces)
for _, e := range env {
parts := strings.SplitN(e, "=", 2)
if len(parts) == 2 {
- fmt.Fprintf(&content, "export %s=%s\n", parts[0], parts[1])
+ fmt.Fprintf(&content, "export %s=%s\n", parts[0], shellQuote(parts[1]))
}
}
From ebebdd0fcb3f6d6a8dbba17d4fb16b16b78fdd50 Mon Sep 17 00:00:00 2001
From: Alvaro Saurin
Date: Wed, 4 Feb 2026 00:14:34 +0100
Subject: [PATCH 22/22] fix: trim command whitespace in Docker script to avoid
trailing newline issues
YAML literal blocks (|) include trailing newlines, causing commands like
'grep --version\n' to be passed to the shell. When quoted with %q, this
becomes 'grep --version\n' which the shell interprets as '--versionn'.
Now trimming whitespace from commands before embedding in the script.
---
pkg/runner/docker.go | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/pkg/runner/docker.go b/pkg/runner/docker.go
index 7f8b85b..b5fd890 100644
--- a/pkg/runner/docker.go
+++ b/pkg/runner/docker.go
@@ -423,12 +423,13 @@ func (r *Docker) createScriptFile(shell string, cmd string, env []string) (strin
r.logger.Debug("Added preparation command to script: %s", r.opts.PrepareCommand)
}
- // Add the main command
+ // Add the main command (trim whitespace to avoid issues with trailing newlines from YAML literal blocks)
content.WriteString("# Main command to execute\n")
+ trimmedCmd := strings.TrimSpace(cmd)
if shell != "" {
- fmt.Fprintf(&content, "exec %s -c %q\n", shell, cmd)
+ fmt.Fprintf(&content, "exec %s -c %q\n", shell, trimmedCmd)
} else {
- fmt.Fprintf(&content, "exec sh -c %q\n", cmd)
+ fmt.Fprintf(&content, "exec sh -c %q\n", trimmedCmd)
}
// Write the content to the file