From 10972d54ea7c93a0527c19dc5a75b7f40bef128a Mon Sep 17 00:00:00 2001 From: Alvaro Saurin Date: Tue, 12 Aug 2025 10:56:21 +0200 Subject: [PATCH] Rename --config to --tools * Rename --config to --tools * Ability to load a from a tools directory (defined in MCPSHELL_TOOLS_DIR). Signed-off-by: Alvaro Saurin --- .github/workflows/test.yml | 2 +- Makefile | 6 +- README.md | 21 +- cmd/agent.go | 240 ++++---- cmd/agent_config.go | 167 ++++++ cmd/exe.go | 12 +- cmd/mcp.go | 17 +- cmd/root.go | 18 +- cmd/validate.go | 10 +- docs/config.md | 9 +- docs/development.md | 7 +- docs/security.md | 9 +- docs/troubleshooting.md | 6 +- docs/usage-agent-conf.md | 175 ++++++ docs/usage-agent.md | 99 ++-- docs/usage-cursor.md | 10 +- docs/usage-vscode.md | 10 +- docs/usage.md | 44 +- examples/README.md | 2 +- examples/aws-ro.yaml | 60 +- examples/aws-route53-ro.yaml | 5 +- examples/container-diagnostics-ro.yaml | 446 +++++++-------- examples/disk-diagnostics-ro.yaml | 249 ++++++-- examples/github-cli-ro.yaml | 539 ++++++++++++++---- examples/kubectl-ro.yaml | 157 ++--- examples/prompts-example.yaml | 45 ++ examples/system-performance-ro.yaml | 278 ++++++--- pkg/agent/agent.go | 399 ++++++------- pkg/agent/agent_test.go | 413 ++++++++++++++ pkg/agent/config_file.go | 159 ++++++ pkg/agent/config_file_test.go | 181 ++++++ pkg/agent/config_sample.yaml | 20 + pkg/common/prompts.go | 49 ++ pkg/config/resolve.go | 24 +- pkg/config/{config.go => tools_config.go} | 43 +- .../{config_test.go => tools_config_test.go} | 2 +- pkg/server/description_test.go | 44 +- pkg/server/server.go | 2 +- pkg/utils/home.go | 72 +++ pkg/utils/home_test.go | 51 ++ pkg/utils/tests.go | 201 +++++++ pkg/utils/tests_test.go | 82 +++ pkg/utils/tools.go | 72 +++ pkg/utils/tools_test.go | 194 +++++++ test_file.txt | 1 - tests/README.md | 105 ++++ tests/{ => agent}/test_agent.sh | 38 +- tests/{ => agent/tools}/test_agent.yaml | 2 +- tests/{ => common}/common.sh | 6 +- tests/{ => common}/test_prompt.json | 0 tests/{ => common}/test_response.json | 0 tests/{ => exe}/test_exe.sh | 9 +- tests/{ => exe}/test_exe_config.yaml | 0 tests/{ => exe}/test_exe_constraints.sh | 7 +- tests/{ => exe}/test_exe_empty_file.sh | 5 +- tests/run_tests.sh | 16 +- tests/{ => runners}/test_runner_docker.sh | 9 +- tests/{ => runners}/test_runner_docker.yaml | 0 58 files changed, 3785 insertions(+), 1064 deletions(-) create mode 100644 cmd/agent_config.go create mode 100644 docs/usage-agent-conf.md create mode 100644 examples/prompts-example.yaml create mode 100644 pkg/agent/agent_test.go create mode 100644 pkg/agent/config_file.go create mode 100644 pkg/agent/config_file_test.go create mode 100644 pkg/agent/config_sample.yaml create mode 100644 pkg/common/prompts.go rename pkg/config/{config.go => tools_config.go} (83%) rename pkg/config/{config_test.go => tools_config_test.go} (99%) create mode 100644 pkg/utils/home.go create mode 100644 pkg/utils/home_test.go create mode 100644 pkg/utils/tests.go create mode 100644 pkg/utils/tests_test.go create mode 100644 pkg/utils/tools.go create mode 100644 pkg/utils/tools_test.go delete mode 100644 test_file.txt create mode 100644 tests/README.md rename tests/{ => agent}/test_agent.sh (74%) rename tests/{ => agent/tools}/test_agent.yaml (99%) rename tests/{ => common}/common.sh (92%) rename tests/{ => common}/test_prompt.json (100%) rename tests/{ => common}/test_response.json (100%) rename tests/{ => exe}/test_exe.sh (86%) rename tests/{ => exe}/test_exe_config.yaml (100%) rename tests/{ => exe}/test_exe_constraints.sh (86%) rename tests/{ => exe}/test_exe_empty_file.sh (91%) rename tests/{ => runners}/test_runner_docker.sh (88%) rename tests/{ => runners}/test_runner_docker.yaml (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9f9e7bf..cb6e449 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: echo "Validating example YAML configurations..." find examples -name "*.yaml" -type f | while read file; do echo "Validating $file..." - ./mcpshell validate --config "$file" || exit 1 + ./mcpshell validate --tools "$file" || exit 1 done echo "All example configurations validated successfully" diff --git a/Makefile b/Makefile index e64262b..094995d 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,10 @@ test: # Run the exe command tests test-e2e: @echo ">>> Running exe command tests..." + @if [ ! -x "$(GOBIN)/$(BINARY_NAME)" ]; then \ + echo ">>> $(GOBIN)/$(BINARY_NAME) not found. Building..."; \ + $(MAKE) build; \ + fi @chmod +x tests/*.sh @tests/run_tests.sh @echo ">>> ... exe command tests completed" @@ -81,7 +85,7 @@ validate-examples: build @find examples -name "*.yaml" -type f | while read file; do \ echo "--------------------------------------------------------------"; \ echo ">>> Validating $$file..."; \ - $(GOBIN)/$(BINARY_NAME) validate --config $$file || exit 1; \ + $(GOBIN)/$(BINARY_NAME) validate --tools $$file || exit 1; \ done @echo ">>>" @echo ">>> ... all example configurations validated SUCCESSFULLY !!!" diff --git a/README.md b/README.md index fcdc4a1..d54254b 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ space problems in your hard disk. "command": "go", "args": [ "run", "github.com/inercia/MCPShell@v0.1.5", - "mcp", "--config", "/my/example.yaml", + "mcp", "--tools", "/my/example.yaml", "--logfile", "/some/path/mcpshell/example.log" ] } @@ -86,6 +86,25 @@ space problems in your hard disk. } ``` + You can also use relative paths and omit the `.yaml` extension: + + ```json + { + "mcpServers": { + "mcp-cli-examples": { + "command": "go", + "args": [ + "run", "github.com/inercia/MCPShell@v0.1.5", + "mcp", "--tools", "example", + "--logfile", "/some/path/mcpshell/example.log" + ] + } + } + } + ``` + + This will look for `example.yaml` in the tools directory (`~/.mcpshell/tools/` by default). + See more details on how to configure [Cursor](docs/usage-cursor.md) or [Visual Studio Code](docs/usage-vscode.md). Other LLMs with support for MCPs should be configured in a similar way. diff --git a/cmd/agent.go b/cmd/agent.go index cc0eb4d..896a1e7 100644 --- a/cmd/agent.go +++ b/cmd/agent.go @@ -12,9 +12,79 @@ import ( "github.com/spf13/cobra" "github.com/inercia/MCPShell/pkg/agent" - "github.com/inercia/MCPShell/pkg/common" ) +// 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 + } + + // Override with command-line flags if provided + if agentModel != "" { + // Check if the specified model exists in config + if configModel := config.GetModelByName(agentModel); configModel != nil { + modelConfig = *configModel + } else { + // Use command-line model name if not found in config + 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 prompts config with merged system prompts + modelConfig.Prompts.System = allSystemPrompts + // Clear user prompts as they should be ignored from config + modelConfig.Prompts.User = nil + } else { + // No command-line system prompt provided, but still clear user prompts from config + modelConfig.Prompts.User = nil + } + if agentOpenAIApiKey != "" { + modelConfig.APIKey = agentOpenAIApiKey + } + if agentOpenAIApiURL != "" { + modelConfig.APIURL = agentOpenAIApiURL + } + + // If no API key is set, try environment variable or handle template value + switch modelConfig.APIKey { + case "": + modelConfig.APIKey = os.Getenv("OPENAI_API_KEY") + case "${OPENAI_API_KEY}": + // Handle environment variable substitution + modelConfig.APIKey = os.Getenv("OPENAI_API_KEY") + } + + return agent.AgentConfig{ + ToolsFile: toolsFile, + 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", @@ -22,14 +92,23 @@ var agentCommand = &cobra.Command{ Long: ` The agent command will execute the MCPShell as an agent, connecting to a remote LLM. -For example, you can do -$ mcpshell agent --configfile=examples/config.yaml \ +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." + --user-prompt "I am having trouble with my computer. It is slow and I think it is due to the CPU usage." -and the agent will try to debug the issue with the given tools. +If a model is configured as default in the agent configuration file, you can omit the --model flag: + +$ mcpshell agent --tools=examples/config.yaml \ + --user-prompt "I am having trouble with my computer. It is slow and I think it is due to the CPU usage." + +The agent will try to debug the issue with the given tools. `, Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -39,136 +118,96 @@ and the agent will try to debug the issue with the given tools. return err } - // Setup panic handler - defer common.RecoverPanic() - - logger.Info("Starting MCPShell agent") - - // Check if config file is provided - if configFile == "" { - logger.Error("Configuration file is required") - return fmt.Errorf("configuration file is required. Use --config or -c flag to specify the path") - } - - // Check if model is provided - if agentModel == "" { - logger.Error("LLM model is required") - return fmt.Errorf("LLM model is required. Use --model flag to specify the model") + // Build agent configuration + agentConfig, err := buildAgentConfig() + if err != nil { + return err } - // Check if API key is provided or in environment - if agentOpenAIApiKey == "" { - agentOpenAIApiKey = os.Getenv("OPENAI_API_KEY") - if agentOpenAIApiKey == "" { - logger.Error("OpenAI API key is required") - return fmt.Errorf("OpenAI API key is required. Use --api-key flag or set OPENAI_API_KEY environment variable") - } + // Validate agent configuration + agentInstance := agent.New(agentConfig, logger) + if err := agentInstance.Validate(); err != nil { + return err } return nil }, RunE: func(cmd *cobra.Command, args []string) error { - logger := common.GetLogger() - - agentConfig := agent.AgentConfig{ - ConfigFile: configFile, - Model: agentModel, - SystemPrompt: agentSystemPrompt, - UserPrompt: agentUserPrompt, - OpenAIApiKey: agentOpenAIApiKey, - OpenAIApiURL: agentOpenAIApiURL, - Once: agentOnce, - Version: version, + // Initialize logger + logger, err := initLogger() + if err != nil { + return err } - a := agent.New(agentConfig, logger) - - if err := a.Validate(); err != nil { - return fmt.Errorf("agent validation failed: %w", err) + // Build agent configuration + agentConfig, err := buildAgentConfig() + if err != nil { + return err } + // 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() - // Handle Ctrl+C (SIGINT) and SIGTERM to gracefully shut down - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - go func() { - <-sigChan - logger.Info("Received interrupt signal, cancelling agent context...") - cancel() - }() - - userInputChan := make(chan string) - agentOutputChan := make(chan string) + // 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(): + } + }() - // Goroutine to read from stdin and send to userInputChan - if !agentOnce { + // 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(userInputChan) scanner := bufio.NewScanner(os.Stdin) - for scanner.Scan() { + for { select { - case userInputChan <- scanner.Text(): case <-ctx.Done(): - logger.Info("Context cancelled, stopping stdin reader.") return + default: + if scanner.Scan() { + userInput <- scanner.Text() + } else { + close(userInput) + return + } } } - if err := scanner.Err(); err != nil { - logger.Error("Error reading from stdin: %v", err) - } - logger.Info("Stdin scanner finished.") }() - } else { - // In one-shot mode, we'll close the channel when Run completes - logger.Info("One-shot mode, skipping stdin reader.") } - // Goroutine to read from agentOutputChan and print to stdout + // Start the agent wg.Add(1) go func() { defer wg.Done() - for { - select { - case output, ok := <-agentOutputChan: - if !ok { - logger.Info("Agent output channel closed, stdout writer finishing.") - return - } - fmt.Print(output) - case <-ctx.Done(): - logger.Info("Context cancelled, stopping stdout writer.") - for output := range agentOutputChan { - fmt.Print(output) - } - return - } + if err := agentInstance.Run(ctx, userInput, agentOutput); err != nil { + logger.Error("Agent encountered an error: %v", err) } }() - err := a.Run(ctx, userInputChan, agentOutputChan) - - cancel() - - logger.Info("Waiting for I/O goroutines to finish...") - wg.Wait() - logger.Info("All goroutines finished.") - - if err != nil { - if err == context.Canceled || err == context.DeadlineExceeded { - logger.Info("Agent run was cancelled: %v", err) - return nil - } - logger.Error("Agent execution failed: %v", err) - return fmt.Errorf("agent execution failed: %w", err) + // Print agent output + for output := range agentOutput { + fmt.Println(output) } - logger.Info("Agent finished successfully.") + wg.Wait() return nil }, } @@ -186,7 +225,6 @@ func init() { agentCommand.Flags().StringVarP(&agentOpenAIApiURL, "openai-api-url", "b", "", "Base URL for the OpenAI API (optional)") agentCommand.Flags().BoolVarP(&agentOnce, "once", "o", false, "Exit after receiving a final response from the LLM (one-shot mode)") - // Mark required flags - _ = agentCommand.MarkFlagRequired("config") - _ = agentCommand.MarkFlagRequired("model") + // Add config subcommand + agentCommand.AddCommand(agentConfigCommand) } diff --git a/cmd/agent_config.go b/cmd/agent_config.go new file mode 100644 index 0000000..85859d2 --- /dev/null +++ b/cmd/agent_config.go @@ -0,0 +1,167 @@ +package root + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/inercia/MCPShell/pkg/agent" + "github.com/inercia/MCPShell/pkg/utils" +) + +// 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 + }, +} + +// 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. + +Example: +$ mcpshell agent config show +`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + logger, err := initLogger() + if err != nil { + return err + } + + // 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 { + fmt.Println("No agent configuration found.") + fmt.Println("Run 'mcpshell agent config create' to create a default configuration.") + return nil + } + + // Pretty print the configuration + 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 + }, +} + +// 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) +} diff --git a/cmd/exe.go b/cmd/exe.go index e4d4981..9ae73cd 100644 --- a/cmd/exe.go +++ b/cmd/exe.go @@ -25,7 +25,7 @@ evaluation, tool selection and tool execution. For example, you can run: -$ mcpshell exe --configfile=examples/config.yaml "hello_world" "name=John" +$ mcpshell exe --tools=examples/config.yaml "hello_world" "name=John" and it will run the "hello_world" tool with the parameter "name" set to "John". Any error in the constraint evaluation, tool selection or tool execution @@ -46,9 +46,9 @@ will be reported. logger.Info("Executing MCP tool directly") // Check if config file is provided - if configFile == "" { - logger.Error("Configuration file is required") - return fmt.Errorf("configuration file is required. Use --config or -c flag to specify the path") + if toolsFile == "" { + logger.Error("Tools configuration file is required") + return fmt.Errorf("tools configuration file is required. Use --tools flag to specify the path") } return nil @@ -65,7 +65,7 @@ will be reported. logger.Info("Executing tool: %s", toolName) // Load the configuration file (local or remote) - localConfigPath, cleanup, err := config.ResolveConfigPath(configFile, logger) + localConfigPath, cleanup, err := config.ResolveConfigPath(toolsFile, logger) if err != nil { logger.Error("Failed to load configuration: %v", err) return fmt.Errorf("failed to load configuration: %w", err) @@ -176,5 +176,5 @@ func init() { rootCmd.AddCommand(exeCommand) // Mark required flags - _ = exeCommand.MarkFlagRequired("config") + _ = exeCommand.MarkFlagRequired("tools") } diff --git a/cmd/mcp.go b/cmd/mcp.go index 367ca9d..cdfcb53 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -6,6 +6,7 @@ import ( "github.com/inercia/MCPShell/pkg/common" "github.com/inercia/MCPShell/pkg/config" "github.com/inercia/MCPShell/pkg/server" + "github.com/inercia/MCPShell/pkg/utils" "github.com/spf13/cobra" ) @@ -39,9 +40,15 @@ available to AI applications via the MCP protocol. logger.Info("Starting MCPShell") // Check if config file is provided - if configFile == "" { - logger.Error("Configuration file is required") - return fmt.Errorf("configuration file is required. Use --config or -c flag to specify the path") + if toolsFile == "" { + logger.Error("Tools configuration file is required") + return fmt.Errorf("tools configuration file is required. Use --tools flag to specify the path") + } + + // Ensure tools directory exists + if err := utils.EnsureToolsDir(); err != nil { + logger.Error("Failed to ensure tools directory: %v", err) + return fmt.Errorf("failed to ensure tools directory: %w", err) } return nil @@ -51,7 +58,7 @@ available to AI applications via the MCP protocol. defer common.RecoverPanic() // Load the configuration file (local or remote) - localConfigPath, cleanup, err := config.ResolveConfigPath(configFile, logger) + localConfigPath, cleanup, err := config.ResolveConfigPath(toolsFile, logger) if err != nil { logger.Error("Failed to load configuration: %v", err) return fmt.Errorf("failed to load configuration: %w", err) @@ -91,5 +98,5 @@ func init() { mcpCommand.Flags().IntVar(&httpPort, "port", 8080, "Port for HTTP server (default: 8080, only used with --http)") // Mark required flags - _ = mcpCommand.MarkFlagRequired("config") + _ = mcpCommand.MarkFlagRequired("tools") } diff --git a/cmd/root.go b/cmd/root.go index f910493..4240f47 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,9 +18,9 @@ const ApplicationName = "mcpshell" // Common command-line flags var ( // Common flags - configFile string - logFile string - logLevel string + toolsFile string + logFile string + logLevel string // MCP server flags description []string @@ -45,7 +45,15 @@ var rootCmd = &cobra.Command{ Short: "MCPShell", Long: `MCPShell is a command line interface for the MCP platform. This CLI application enables AI systems to securely execute commands through -the Model Context Protocol (MCP).`, +the Model Context Protocol (MCP). + +Specify your tools configuration using the --tools flag: + mcpshell --tools /path/to/tools.yaml (absolute path) + mcpshell --tools mytools (looks in tools directory, adds .yaml) + mcpshell --tools mytools.yaml (looks in tools directory) + +The tools directory defaults to ~/.mcpshell/tools but can be overridden +with the MCPSHELL_TOOLS_DIR environment variable.`, Run: func(cmd *cobra.Command, args []string) { // If no subcommand is specified, show the help _ = cmd.Help() @@ -67,7 +75,7 @@ func Execute() { // init registers all subcommands and sets up global flags func init() { // Add common persistent flags - rootCmd.PersistentFlags().StringVarP(&configFile, "config", "c", "", "Path to the YAML configuration file, URL, or directory containing YAML files") + rootCmd.PersistentFlags().StringVar(&toolsFile, "tools", "", "Path to the tools configuration file (supports relative paths and auto .yaml extension)") rootCmd.PersistentFlags().StringVarP(&logFile, "logfile", "l", "", "Path to the log file (optional)") rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "", "info", "Log level: none, error, info, debug") diff --git a/cmd/validate.go b/cmd/validate.go index 7b167e6..36e1840 100644 --- a/cmd/validate.go +++ b/cmd/validate.go @@ -36,9 +36,9 @@ This command checks the configuration file for errors including: logger.Info("Validating MCP configuration") // Check if config file is provided - if configFile == "" { - logger.Error("Configuration file is required") - return fmt.Errorf("configuration file is required. Use --config or -c flag to specify the path") + if toolsFile == "" { + logger.Error("Tools configuration file is required") + return fmt.Errorf("tools configuration file is required. Use --tools flag to specify the path") } return nil @@ -55,7 +55,7 @@ This command checks the configuration file for errors including: }() // Load the configuration file (local or remote) - localConfigPath, cleanup, err := config.ResolveConfigPath(configFile, logger) + localConfigPath, cleanup, err := config.ResolveConfigPath(toolsFile, logger) if err != nil { logger.Error("Failed to load configuration: %v", err) return fmt.Errorf("failed to load configuration: %w", err) @@ -89,5 +89,5 @@ func init() { rootCmd.AddCommand(validateCommand) // Mark required flags - _ = validateCommand.MarkFlagRequired("config") + _ = validateCommand.MarkFlagRequired("tools") } diff --git a/docs/config.md b/docs/config.md index 91ba39e..fb64e8e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -233,19 +233,12 @@ Here's a simple example with multiple runners: ```yaml runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: false - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: false - - name: exec - requirements: {} # Fallback runner + - name: exec # Fallback runner ``` For detailed information about runners, including options, selection process, and supported types, see [Runner Configuration](config-runners.md). diff --git a/docs/development.md b/docs/development.md index 8273690..9cdc901 100644 --- a/docs/development.md +++ b/docs/development.md @@ -10,9 +10,8 @@ ```console . ├── cmd/ # Command definitions -│ └── root/ # Root command -│ └── run/ # Run command for MCP server ├── docs/ # Documentation +├── pkg/ # Source code ├── main.go # Entry point ├── Makefile # Build scripts └── README.md # This file @@ -64,8 +63,8 @@ This project uses GitHub Actions to automatically build and release binaries. Wh To create a new release: ```bash -# Tag the commit -git tag v0.1.0 +# Create a new release, by tagging the code +make release # Push the tag to trigger the release workflow git push origin v0.1.0 diff --git a/docs/security.md b/docs/security.md index c684330..9614a33 100644 --- a/docs/security.md +++ b/docs/security.md @@ -73,13 +73,18 @@ Example constraint approach: } ``` -### 4. Run with Minimal Privileges +### 4. Use the Restricted _runners_ + +- Use one of the restricted [runners](config-runners.md) +- Limit the directories and files the runner can access. + +### 5. Run with Minimal Privileges - Run the adapter with the least privileges necessary - Create a dedicated user account with limited permissions - Use containerization when possible to isolate execution -### 5. Audit and Monitor +### 6. Audit and Monitor - Log all commands executed by the LLM - Regularly review logs for suspicious activity diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 4054389..f61fc8c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -7,7 +7,7 @@ Some problems can arise when using this adapter: You should first check your configuration: ```bash -mcpshell validate --config /path/to/config.yaml +mcpshell validate --tools /path/to/config.yaml ``` Successful validation will show each tool found and verified: @@ -76,7 +76,7 @@ You should also try to execute the tool directly from the command line. In gener you can run something like: ```bash -mcpshell exe --config /path/to/config.yaml "tool_name" "param1=value1" "param2=value2" +mcpshell exe --tools /path/to/config.yaml "tool_name" "param1=value1" "param2=value2" ``` This executes a specific tool directly from the command line without starting the server. It follows the @@ -92,7 +92,7 @@ Use this command to: Example: ```bash -$ mcpshell exe --config examples/config.yaml "hello_world" "name=Claude" +$ mcpshell exe --tools examples/config.yaml "hello_world" "name=Claude" Hello Claude! ``` diff --git a/docs/usage-agent-conf.md b/docs/usage-agent-conf.md new file mode 100644 index 0000000..fb2d64b --- /dev/null +++ b/docs/usage-agent-conf.md @@ -0,0 +1,175 @@ +# 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 +2. Configuration file settings +3. Environment variables +4. Default values + +## 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 +``` + +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 + +Example output: + +```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 index 5551ccd..e9f4174 100644 --- a/docs/usage-agent.md +++ b/docs/usage-agent.md @@ -8,7 +8,7 @@ In agent mode, MCPShell: 1. Connects directly to an LLM API (currently OpenAI-compatible APIs) 2. Makes your tools available to the LLM -3. Manages the conversation flow +3. Manages the conversation flow. 4. Handles tool execution requests 5. Provides the tool results back to the LLM @@ -24,64 +24,42 @@ mcpshell agent [flags] ### Required Flags -- `--config`, `-c`: Path to the YAML configuration file (required) -- `--model`, `-m`: LLM model to use (e.g., "gpt-4o", "llama3", etc.) +- `--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) ### 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 +- `--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) -- `--openai-api-url`, `-b`: Base URL for the OpenAI API (for non-OpenAI services) +- `--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 Extensions for Agent Mode +## Configuration File for Agent Mode -The standard MCPShell configuration file format has been extended to support agent-specific features, particularly pre-defined prompts: +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. -```yaml -# New "prompts" section for agent mode -prompts: - - system: - - "You are a system administrator assistant." - - "Use the available tools to help diagnose and solve problems." - user: - - "Help me analyze disk usage on my system." - -# Standard MCPShell configuration -mcp: - description: "Tools for system administration tasks" - run: - shell: "bash" - tools: - # Tool definitions... -``` - -### Prompts Section +**📖 For complete details on agent configuration, including:** -The `prompts` section lets you define system and user prompts directly in the configuration file: +- Configuration file structure and syntax +- Model configuration fields +- Environment variable substitution +- Configuration management commands +- Example configurations -- **System Prompts**: Define the role and capabilities of the assistant -- **User Prompts**: Initial questions or instructions for the assistant - -When you run without the `--system-prompt` or `--user-prompt` flags, MCPShell will use these prompts from the configuration file. Multiple prompts in each category will be joined with newlines. +**See the [Agent Configuration Guide](usage-agent-conf.md)** ## Creating an Agent Script You can create a shell script to run MCPShell in agent mode with a specific configuration. This is useful for creating specialized agents for different tasks. -Here's a practical example of a configuration file for a disk space analyzer agent: +Here's a practical example showing both agent configuration and tools configuration for a disk space analyzer agent: -```yaml -prompts: - - system: - - "You are a disk space analyzer assistant that helps users identify what's consuming disk space." - - "Always provide clear, step-by-step analysis of disk usage patterns." - - "Suggest practical ways to free up space when appropriate." +**Tools Configuration** (`disk-analyzer.yaml`): -mcp: +```yaml description: "Tools for analyzing disk space usage and system performance" run: shell: "bash" @@ -150,22 +128,28 @@ mcp: ## Running the Agent -With the configuration above saved as `disk-analyzer.yaml`, you can run: +With the tools configuration saved as `disk-analyzer.yaml`, you can run: ```bash mcpshell agent \ - --config disk-analyzer.yaml \ - --model "gpt-4o" \ - --openai-api-key "your-api-key" + --tools disk-analyzer.yaml \ --user-prompt "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?" ``` +If you have a default model configured in your agent config file, +you don't need to specify the model or API key: + +```bash +mcpshell agent --tools disk-analyzer.yaml +``` + The agent will: -1. Connect to OpenAI's API -2. Send the system and user prompts from the configuration -3. Make the `disk_usage`, `filesystem_info`, and `large_files` tools available -4. Process the LLM's responses and execute tool calls as requested +1. Load model and API settings from `~/.mcpshell/agent.yaml` (see [Agent Configuration Guide](usage-agent-conf.md)) +2. Load system prompts from the agent configuration +3. Load tools from `disk-analyzer.yaml` +4. Connect to the configured LLM API +5. Process the LLM's responses and execute tool calls as requested ## Interacting with the Agent @@ -183,20 +167,6 @@ In one-shot mode (with the `--once` flag), the agent will: - Display the final response - Exit automatically -## Using with Local LLMs - -MCPShell agent mode works well with local LLMs through services like Ollama: - -```bash -mcpshell agent \ - --config disk-analyzer.yaml \ - --model "llama3" \ - --openai-api-key "ollama" \ - --openai-api-url "http://localhost:11434/v1" -``` - -NOTE: make sure you choose a model that accepts "tools". - ## Testing and Debugging When developing agents, you can: @@ -204,10 +174,11 @@ When developing agents, you can: 1. Enable debug logging with `--log-level debug` 2. Examine the log file for detailed information 3. Test with the `exe` command to verify individual tools: + ```bash - mcpshell exe --config disk-analyzer.yaml disk_usage directory="/" max_depth=2 + mcpshell exe --tools disk-analyzer.yaml disk_usage directory="/" max_depth=2 ``` ## Conclusion -The agent mode provides a powerful way to create specialized AI assistants that can perform specific tasks on your system using the tools you define. By combining well-defined tools with appropriate system and user prompts, you can create agents that solve real-world problems in a secure and controlled manner. \ No newline at end of file +The agent mode provides a powerful way to create specialized AI assistants that can perform specific tasks on your system using the tools you define. By combining well-defined tools with appropriate system and user prompts, you can create agents that solve real-world problems in a secure and controlled manner. diff --git a/docs/usage-cursor.md b/docs/usage-cursor.md index a9d06d8..1c9eac6 100644 --- a/docs/usage-cursor.md +++ b/docs/usage-cursor.md @@ -48,7 +48,7 @@ The MCPShell uses the "stdio" transport type, which runs locally on your machine "command": "go", "args": [ "run", "github.com/inercia/MCPShell@v0.1.5", - "mcp", "--config", "/absolute/path/to/mcp-cli.yaml" + "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" ] } } @@ -63,7 +63,7 @@ The MCPShell uses the "stdio" transport type, which runs locally on your machine "mcpshell": { "command": "/absolute/path/to/mcpshell", "args": [ - "mcp", "--config", "/absolute/path/to/mcp-cli.yaml" + "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" ] } } @@ -85,7 +85,7 @@ You can configure multiple instances of the MCPShell, each with different tool s "command": "/some/path/mcpshell/build/mcpshell", "args": [ "mcp", - "--config", "/some/path/mcpshell/examples/config.yaml", + "--tools", "/some/path/mcpshell/examples/config.yaml", "--logfile", "/some/path/mcpshell/debug.log" ], "env": { @@ -95,7 +95,7 @@ You can configure multiple instances of the MCPShell, each with different tool s "command": "/some/path/mcpshell/build/mcpshell", "args": [ "mcp", - "--config", "/some/path/mcpshell/examples/kubectl-ro.yaml", + "--tools", "/some/path/mcpshell/examples/kubectl-ro.yaml", "--logfile", "/some/path/mcpshell/debug.kubernetes-ro.log" ], "env": { @@ -120,7 +120,7 @@ You can provide authentication credentials and other sensitive information using "mcpshell": { "command": "/absolute/path/to/mcpshell", "args": [ - "mcp", "--config", "/absolute/path/to/mcp-cli.yaml" + "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" ], "env": { "API_KEY": "your-api-key-here", diff --git a/docs/usage-vscode.md b/docs/usage-vscode.md index 8a40673..93c0dfd 100644 --- a/docs/usage-vscode.md +++ b/docs/usage-vscode.md @@ -40,7 +40,7 @@ To use MCPShell with Visual Studio Code, follow these steps: "type": "stdio", "command": "/absolute/path/to/mcpshell", "args": [ - "mcp", "--config", "/absolute/path/to/mcp-cli.yaml" + "mcp", "--tools", "/absolute/path/to/mcp-cli.yaml" ] } } @@ -57,7 +57,7 @@ To use MCPShell with Visual Studio Code, follow these steps: "command": "go", "args": [ "run", "github.com/inercia/MCPShell", - "mcp", "--config", "${workspaceFolder}/mcp-cli.yaml" + "mcp", "--tools", "${workspaceFolder}/mcp-cli.yaml" ] } } @@ -81,7 +81,7 @@ each with different tool configurations: "command": "/absolute/path/to/mcpshell", "args": [ "mcp", - "--config", "${workspaceFolder}/examples/config.yaml", + "--tools", "${workspaceFolder}/examples/config.yaml", "--logfile", "${workspaceFolder}/debug.log" ] }, @@ -90,7 +90,7 @@ each with different tool configurations: "command": "/absolute/path/to/mcpshell", "args": [ "mcp", - "--config", "${workspaceFolder}/examples/kubectl-ro.yaml", + "--tools", "${workspaceFolder}/examples/kubectl-ro.yaml", "--logfile", "${workspaceFolder}/debug.kubernetes-ro.log" ], "env": { @@ -120,7 +120,7 @@ If your tools require API keys or other sensitive information, you can use input "type": "stdio", "command": "/absolute/path/to/mcpshell", "args": [ - "mcp", "--config", "${workspaceFolder}/mcp-cli.yaml" + "mcp", "--tools", "${workspaceFolder}/mcp-cli.yaml" ], "env": { "API_KEY": "${input:api-key}" diff --git a/docs/usage.md b/docs/usage.md index 5d1694c..92b4b95 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -21,7 +21,11 @@ MCPShell provides the following commands: This is the list of argument that are common to all the commands: -- `--config`, `-c` (required): Path to the YAML configuration file, URL, or directory containing YAML files +- `--tools` (required): Path to the tools configuration input. It can be: + - an absolute or relative filename to a YAML config + - a directory (all `.yaml`/`.yml` files will be merged) + - an `http(s)://` URL to a YAML config + - a bare name found under the tools directory (auto-appends `.yaml`) - `--logfile`, `-l`: Path to the log file (optional) - `--log-level`: Log level: none, error, info, debug (default: "info") - `--description-override`: override the description found in the config file. @@ -34,20 +38,26 @@ This is the list of argument that are common to all the commands: `--description-file https://example.com/description.txt`). It follows the same behaviour of `--description`, where the final description is the result of the concatenation of all of them -**Configuration Loading**: +### Tools Directory -The `--config` flag supports three types of inputs: +MCPShell looks for tools files in a dedicated directory: +- Default: `~/.mcpshell/tools/` +- Override with: `MCPSHELL_TOOLS_DIR` environment variable -1. **Single YAML file**: `--config=examples/config.yaml` -2. **URL**: `--config=https://example.com/config.yaml` -3. **Directory**: `--config=./configs/` - loads all `.yaml` and `.yml` files in the directory +When using relative paths with `--tools`, the file is searched in this directory. +If no extension is provided, `.yaml` is automatically appended. -When loading from a directory, MCPShell will: -- Scan the directory recursively for YAML files -- Merge all found configurations into a single configuration -- Use the description and run settings from the first file -- Combine all tools from all files -- Concatenate all prompts from all files +Examples: +```console +# Using a file in the tools directory (adds .yaml automatically) +mcpshell mcp --tools mytools + +# Using a file in the tools directory with extension +mcpshell mcp --tools mytools.yaml + +# Using an absolute path +mcpshell mcp --tools /path/to/your/tools.yaml +``` For example, imagine you want to use the [kubectl](../examples/kubectl-ro.yaml) toolkit, but you want to add some additional instructions specific to your infrastructure, @@ -55,7 +65,7 @@ you could do: ```console mcpshell mcp \ - --configfile example/kubectl-ro.yaml \ + --tools example/kubectl-ro.yaml \ --description "Monitoring namespace is called 'monitoring'" --description "Envoy is running in namespace 'envoy'" ``` @@ -65,7 +75,7 @@ like: ```console # Combine multiple descriptions from different sources -mcpshell mcp --config=examples/config.yaml \ +mcpshell mcp --tools=examples/config.yaml \ --description "Primary server description" \ --description "Additional information" \ --description-file docs/intro.txt \ @@ -97,7 +107,7 @@ Runs an MCP server that communicates using the Model Context Protocol and expose **Example**: ```console -mcpshell mcp --config=examples/config.yaml --http --port=9090 --log-level=debug +mcpshell mcp --tools=examples/config.yaml --log-level=debug ``` ### EXE Command @@ -116,7 +126,7 @@ Directly executes a MCP tool with the specified parameters. This command is usef **Example**: ```console -mcpshell exe --config=examples/config.yaml "hello_world" "name=John" +mcpshell exe --tools=examples/config.yaml "hello_world" "name=John" ``` ### Validate Command @@ -136,7 +146,7 @@ Validates an MCP configuration file without starting the server. It checks for e **Example**: ```console -mcpshell validate --config=examples/config.yaml +mcpshell validate --tools=examples/config.yaml ``` ### Agent Command diff --git a/examples/README.md b/examples/README.md index af1f232..16d09d9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -50,7 +50,7 @@ checking paramters and so on. Provide only read-only commands, do not allow the execution of code with side effects. Validate the example generated with -"go run github.com/inercia/MCPShell@v0.1.5 validate --config FILENAME" +"go run github.com/inercia/MCPShell@v0.1.5 validate --tools FILENAME" where FILENAME is the configuration file you have created. If some errors are detected by the validation process, please try to fix them until the validation is successful. diff --git a/examples/aws-ro.yaml b/examples/aws-ro.yaml index ae71743..5bb316f 100644 --- a/examples/aws-ro.yaml +++ b/examples/aws-ro.yaml @@ -106,6 +106,35 @@ mcp: echo "To use a specific profile with AWS tools, add 'profile=PROFILE_NAME' to your command." output: prefix: "AWS Profiles:" + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: [sandbox-exec] + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - "/usr/bin" + - "/usr/local/bin" + - "/bin" + - "/etc" + - "{{ env \"HOME\" }}/.aws" + - name: firejail + requirements: + os: linux + executables: [firejail] + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - "/usr/bin" + - "/usr/local/bin" + - "/bin" + - "/etc" + - "{{ env \"HOME\" }}/.aws" + - name: exec + requirements: {} - name: "aws_regions_list" description: "List all available AWS regions" @@ -175,4 +204,33 @@ mcp: echo "$OUTPUT" fi output: - prefix: "AWS Regions:" \ No newline at end of file + prefix: "AWS Regions:" + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: [sandbox-exec] + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - "/usr/bin" + - "/usr/local/bin" + - "/bin" + - "/etc" + - "{{ env \"HOME\" }}/.aws" + - name: firejail + requirements: + os: linux + executables: [firejail] + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - "/usr/bin" + - "/usr/local/bin" + - "/bin" + - "/etc" + - "{{ env \"HOME\" }}/.aws" + - name: exec + requirements: {} \ No newline at end of file diff --git a/examples/aws-route53-ro.yaml b/examples/aws-route53-ro.yaml index 3006c77..24a2fd6 100644 --- a/examples/aws-route53-ro.yaml +++ b/examples/aws-route53-ro.yaml @@ -876,10 +876,7 @@ mcp: # Run a more comprehensive dig query echo "Basic DNS query:" - dig +noall +answer $NS_PARAM {{ .domain }} $TYPE_PARAM - - echo -e "\nDetailed query with trace information:" - dig +trace $NS_PARAM {{ .domain }} $TYPE_PARAM + dig +noall +answer $NS_PARAM {{ .domain }} $TYPE_PARAM output: prefix: "DNS lookup for {{ .domain }} (type {{ if .record_type }}{{ .record_type }}{{ else }}A{{ end }}){{ if .nameserver }} using nameserver {{ .nameserver }}{{ end }}:" diff --git a/examples/container-diagnostics-ro.yaml b/examples/container-diagnostics-ro.yaml index e41d337..83337a8 100644 --- a/examples/container-diagnostics-ro.yaml +++ b/examples/container-diagnostics-ro.yaml @@ -17,32 +17,62 @@ mcp: echo "Error: Docker is not installed or not in the PATH." exit 1 fi - + # Check if Docker daemon is running if ! docker info &> /dev/null; then echo "Error: Docker daemon is not running or you don't have permission to connect." echo "Try running 'sudo systemctl start docker' or check Docker Desktop on macOS/Windows." exit 1 fi - + echo "Docker Version:" docker version - + echo -e "\nDocker Info:" docker info | grep -v "WARNING" - + echo -e "\nRunning Containers:" docker ps --format "table {{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}\t{{.Ports}}" - + echo -e "\nContainer Count:" echo "Running: $(docker ps -q | wc -l)" echo "All: $(docker ps -a -q | wc -l)" - + echo -e "\nImages:" docker images --format "table {{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.Size}}" | head -15 output: prefix: "Docker Environment Overview:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: exec + requirements: {} - name: "container_stats" description: "Show resource usage statistics for running containers" params: @@ -53,72 +83,44 @@ mcp: type: number description: "Number of stats snapshots to collect (1-5)" constraints: - - "container == '' || container.matches('^[a-zA-Z0-9_.-]+$')" # Safe container name/ID chars - - "container.size() <= 64" # Max container ID length - - "int(stats_count) == 0 || (int(stats_count) >= 1 && int(stats_count) <= 5)" # Reasonable stats count + - "container == '' || container.matches('^[a-zA-Z0-9_.-]+$')" # Safe container name/ID chars + - "container.size() <= 64" # Max container ID length + - "int(stats_count) == 0 || (int(stats_count) >= 1 && int(stats_count) <= 5)" # Reasonable stats count run: - command: | - # Check if Docker is installed - if ! command -v docker &> /dev/null; then - echo "Error: Docker is not installed or not in the PATH." - exit 1 - fi - - # Set defaults - STATS_COUNT=1 - CONTAINER_FILTER="" - - if [ -n "{{ .container }}" ]; then - CONTAINER_FILTER="{{ .container }}" - - # Verify container exists - if ! docker ps -a --format "{{.Names}}:{{.ID}}" | grep -q "$CONTAINER_FILTER"; then - echo "Error: Container '$CONTAINER_FILTER' not found." - echo "Available containers:" - docker ps -a --format "table {{.Names}}\t{{.ID}}\t{{.Status}}" - exit 1 - fi - fi - - if [ {{ .stats_count }} -gt 0 ]; then - STATS_COUNT={{ .stats_count }} - fi - - if [ -n "$CONTAINER_FILTER" ]; then - echo "Stats for container: $CONTAINER_FILTER (taking $STATS_COUNT samples)" - # Collect stats for specific container - docker stats --no-stream "$CONTAINER_FILTER" - - # If multiple stats samples requested - if [ $STATS_COUNT -gt 1 ]; then - for i in $(seq 2 $STATS_COUNT); do - echo -e "\nSample $i:" - sleep 2 - docker stats --no-stream "$CONTAINER_FILTER" - done - fi - - echo -e "\nContainer details:" - docker inspect --format "{{.State.Status}}: {{.Config.Image}} (Created: {{.Created}})" "$CONTAINER_FILTER" - echo "Network mode: $(docker inspect --format '{{.HostConfig.NetworkMode}}' "$CONTAINER_FILTER")" - echo "Restart policy: $(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER_FILTER")" - else - echo "Stats for all running containers (taking $STATS_COUNT samples)" - # Collect stats for all containers - docker stats --no-stream - - # If multiple stats samples requested - if [ $STATS_COUNT -gt 1 ]; then - for i in $(seq 2 $STATS_COUNT); do - echo -e "\nSample $i:" - sleep 2 - docker stats --no-stream - done - fi - fi + command: "# Check if Docker is installed\nif ! command -v docker &> /dev/null; then\n echo \"Error: Docker is not installed or not in the PATH.\"\n exit 1\nfi\n\n# Set defaults\nSTATS_COUNT=1\nCONTAINER_FILTER=\"\"\n\nif [ -n \"{{ .container }}\" ]; then\n CONTAINER_FILTER=\"{{ .container }}\"\n \n # Verify container exists\n if ! docker ps -a --format \"{{.Names}}:{{.ID}}\" | grep -q \"$CONTAINER_FILTER\"; then\n echo \"Error: Container '$CONTAINER_FILTER' not found.\"\n echo \"Available containers:\"\n docker ps -a --format \"table {{.Names}}\\t{{.ID}}\\t{{.Status}}\"\n exit 1\n fi\nfi\n\nif [ {{ .stats_count }} -gt 0 ]; then\n STATS_COUNT={{ .stats_count }}\nfi\n\nif [ -n \"$CONTAINER_FILTER\" ]; then\n echo \"Stats for container: $CONTAINER_FILTER (taking $STATS_COUNT samples)\"\n # Collect stats for specific container\n docker stats --no-stream \"$CONTAINER_FILTER\"\n \n # If multiple stats samples requested\n if [ $STATS_COUNT -gt 1 ]; then\n for i in $(seq 2 $STATS_COUNT); do\n echo -e \"\\nSample $i:\"\n sleep 2\n docker stats --no-stream \"$CONTAINER_FILTER\"\n done\n fi\n \n echo -e \"\\nContainer details:\"\n docker inspect --format \"{{.State.Status}}: {{.Config.Image}} (Created: {{.Created}})\" \"$CONTAINER_FILTER\"\n echo \"Network mode: $(docker inspect --format '{{.HostConfig.NetworkMode}}' \"$CONTAINER_FILTER\")\"\n echo \"Restart policy: $(docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' \"$CONTAINER_FILTER\")\"\nelse\n echo \"Stats for all running containers (taking $STATS_COUNT samples)\"\n # Collect stats for all containers\n docker stats --no-stream\n \n # If multiple stats samples requested\n if [ $STATS_COUNT -gt 1 ]; then\n for i in $(seq 2 $STATS_COUNT); do\n echo -e \"\\nSample $i:\"\n sleep 2\n docker stats --no-stream\n done\n fi\nfi\n" output: prefix: "Container Resource Usage:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: exec + requirements: {} - name: "container_logs" description: "Show logs from a container" params: @@ -136,10 +138,10 @@ mcp: type: string description: "Show logs since timestamp (e.g., '10m', '1h', '2h30m')" constraints: - - "container.matches('^[a-zA-Z0-9_.-]+$')" # Safe container name/ID chars - - "container.size() <= 64" # Max container ID length - - "int(lines) == 0 || (int(lines) >= 1 && int(lines) <= 1000)" # Reasonable line count - - "since == '' || since.matches('^[0-9]+[smhd]$|^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$')" # Valid time format + - "container.matches('^[a-zA-Z0-9_.-]+$')" # Safe container name/ID chars + - "container.size() <= 64" # Max container ID length + - "int(lines) == 0 || (int(lines) >= 1 && int(lines) <= 1000)" # Reasonable line count + - "since == '' || since.matches('^[0-9]+[smhd]$|^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$')" # Valid time format run: command: | # Check if Docker is installed @@ -147,16 +149,16 @@ mcp: echo "Error: Docker is not installed or not in the PATH." exit 1 fi - + # Set defaults LINES_PARAM="--tail=50" FOLLOW_PARAM="" SINCE_PARAM="" - + if [ {{ .lines }} -gt 0 ]; then LINES_PARAM="--tail={{ .lines }}" fi - + if [ "{{ .follow }}" = "true" ]; then # Limit follow to 30 seconds max to prevent hanging FOLLOW_PARAM="--follow" @@ -172,11 +174,11 @@ mcp: fi fi fi - + if [ -n "{{ .since }}" ]; then SINCE_PARAM="--since={{ .since }}" fi - + # Verify container exists if ! docker ps -a --format "{{.Names}}:{{.ID}}" | grep -q "{{ .container }}"; then echo "Error: Container '{{ .container }}' not found." @@ -184,22 +186,51 @@ mcp: docker ps -a --format "table {{.Names}}\t{{.ID}}\t{{.Status}}" exit 1 fi - + echo "Container: {{ .container }}" echo "Status: $(docker inspect --format '{{.State.Status}}' {{ .container }})" echo "Created: $(docker inspect --format '{{.Created}}' {{ .container }})" echo "Image: $(docker inspect --format '{{.Config.Image}}' {{ .container }})" echo -e "Displaying logs with params: $LINES_PARAM $FOLLOW_PARAM $SINCE_PARAM\n" - + if [ -n "$FOLLOW_PARAM" ] && [ -n "$timeout_cmd" ]; then $timeout_cmd docker logs $LINES_PARAM $FOLLOW_PARAM $SINCE_PARAM {{ .container }} else docker logs $LINES_PARAM $FOLLOW_PARAM $SINCE_PARAM {{ .container }} fi - output: prefix: "Container Logs for {{ .container }}:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: exec + requirements: {} - name: "container_inspect" description: "Show detailed information about a container" params: @@ -214,71 +245,44 @@ mcp: type: string description: "JQ filter to apply to the output" constraints: - - "container.matches('^[a-zA-Z0-9_.-]+$')" # Safe container name/ID chars - - "container.size() <= 64" # Max container ID length - - "format == '' || ['full', 'network', 'mounts', 'env', 'config'].exists(f, f == format)" # Valid formats + - "container.matches('^[a-zA-Z0-9_.-]+$')" # Safe container name/ID chars + - "container.size() <= 64" # Max container ID length + - "format == '' || ['full', 'network', 'mounts', 'env', 'config'].exists(f, f == format)" # Valid formats run: - command: | - # Check if Docker is installed - if ! command -v docker &> /dev/null; then - echo "Error: Docker is not installed or not in the PATH." - exit 1 - fi - - # Set default format - FORMAT="{{ .format }}" - if [ -z "$FORMAT" ]; then - FORMAT="full" - fi - - # Verify container exists - if ! docker ps -a --format "{{.Names}}:{{.ID}}" | grep -q "{{ .container }}"; then - echo "Error: Container '{{ .container }}' not found." - echo "Available containers:" - docker ps -a --format "table {{.Names}}\t{{.ID}}\t{{.Status}}" - exit 1 - fi - - echo "Container: {{ .container }}" - - case "$FORMAT" in - "network") - echo -e "\nNetwork Configuration:" - docker inspect --format '{{json .NetworkSettings}}' {{ .container }} | jq '{{ .jq_filter }}' - - echo -e "\nNetwork Mode:" - docker inspect --format '{{.HostConfig.NetworkMode}}' {{ .container }} - - echo -e "\nPorts:" - docker inspect --format '{{json .NetworkSettings.Ports}}' {{ .container }} | jq '{{ .jq_filter }}' - ;; - - "mounts") - echo -e "\nVolumes and Mounts:" - docker inspect --format '{{json .Mounts}}' {{ .container }} | jq '{{ .jq_filter }}' - - echo -e "\nVolume Configuration:" - docker inspect --format '{{json .Config.Volumes}}' {{ .container }} | jq '{{ .jq_filter }}' - ;; - - "env") - echo -e "\nEnvironment Variables:" - docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' {{ .container }} - ;; - - "config") - echo -e "\nContainer Configuration:" - docker inspect --format '{{json .Config}}' {{ .container }} | jq '{{ .jq_filter }}' - ;; - - "full"|*) - echo -e "\nFull Container Inspection (may be lengthy):" - docker inspect {{ .container }} | jq '{{ .jq_filter }}' - ;; - esac + command: "# Check if Docker is installed\nif ! command -v docker &> /dev/null; then\n echo \"Error: Docker is not installed or not in the PATH.\"\n exit 1\nfi\n\n# Set default format\nFORMAT=\"{{ .format }}\"\nif [ -z \"$FORMAT\" ]; then\n FORMAT=\"full\"\nfi\n\n# Verify container exists\nif ! docker ps -a --format \"{{.Names}}:{{.ID}}\" | grep -q \"{{ .container }}\"; then\n echo \"Error: Container '{{ .container }}' not found.\"\n echo \"Available containers:\"\n docker ps -a --format \"table {{.Names}}\\t{{.ID}}\\t{{.Status}}\"\n exit 1\nfi\n\necho \"Container: {{ .container }}\"\n\ncase \"$FORMAT\" in\n \"network\")\n echo -e \"\\nNetwork Configuration:\"\n docker inspect --format '{{json .NetworkSettings}}' {{ .container }} | jq '{{ .jq_filter }}'\n \n echo -e \"\\nNetwork Mode:\"\n docker inspect --format '{{.HostConfig.NetworkMode}}' {{ .container }}\n \n echo -e \"\\nPorts:\"\n docker inspect --format '{{json .NetworkSettings.Ports}}' {{ .container }} | jq '{{ .jq_filter }}'\n ;;\n \n \"mounts\")\n echo -e \"\\nVolumes and Mounts:\"\n docker inspect --format '{{json .Mounts}}' {{ .container }} | jq '{{ .jq_filter }}'\n \n echo -e \"\\nVolume Configuration:\"\n docker inspect --format '{{json .Config.Volumes}}' {{ .container }} | jq '{{ .jq_filter }}'\n ;;\n \n \"env\")\n echo -e \"\\nEnvironment Variables:\"\n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' {{ .container }}\n ;;\n \n \"config\")\n echo -e \"\\nContainer Configuration:\"\n docker inspect --format '{{json .Config}}' {{ .container }} | jq '{{ .jq_filter }}'\n ;;\n \n \"full\"|*)\n echo -e \"\\nFull Container Inspection (may be lengthy):\"\n docker inspect {{ .container }} | jq '{{ .jq_filter }}'\n ;;\nesac\n" output: prefix: "Container Inspection for {{ .container }}:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: exec + requirements: {} - name: "docker_networks" description: "Show Docker network configuration and connected containers" params: @@ -286,43 +290,43 @@ mcp: type: string description: "Network name or ID (optional, shows all if not specified)" constraints: - - "network == '' || network.matches('^[a-zA-Z0-9_.-]+$')" # Safe network name/ID chars - - "network.size() <= 64" # Reasonable network name length + - "network == '' || network.matches('^[a-zA-Z0-9_.-]+$')" # Safe network name/ID chars + - "network.size() <= 64" # Reasonable network name length run: - command: | - # Check if Docker is installed - if ! command -v docker &> /dev/null; then - echo "Error: Docker is not installed or not in the PATH." - exit 1 - fi - - if [ -n "{{ .network }}" ]; then - # Verify network exists - if ! docker network ls --format "{{.Name}}:{{.ID}}" | grep -q "{{ .network }}"; then - echo "Error: Network '{{ .network }}' not found." - echo "Available networks:" - docker network ls - exit 1 - fi - - echo "Network details for: {{ .network }}" - docker network inspect {{ .network }} - else - echo "Available Docker networks:" - docker network ls - - echo -e "\nNetworks with connected containers:" - for net in $(docker network ls --format "{{.Name}}"); do - container_count=$(docker network inspect $net --format '{{len .Containers}}') - if [ "$container_count" -gt 0 ]; then - echo -e "\nNetwork: $net (Containers: $container_count)" - docker network inspect $net --format '{{range $id, $container := .Containers}}{{printf "- %s (%s)\n" $container.Name $id}}{{end}}' - fi - done - fi + command: "# Check if Docker is installed\nif ! command -v docker &> /dev/null; then\n echo \"Error: Docker is not installed or not in the PATH.\"\n exit 1\nfi\n\nif [ -n \"{{ .network }}\" ]; then\n # Verify network exists\n if ! docker network ls --format \"{{.Name}}:{{.ID}}\" | grep -q \"{{ .network }}\"; then\n echo \"Error: Network '{{ .network }}' not found.\"\n echo \"Available networks:\"\n docker network ls\n exit 1\n fi\n \n echo \"Network details for: {{ .network }}\"\n docker network inspect {{ .network }}\nelse\n echo \"Available Docker networks:\"\n docker network ls\n \n echo -e \"\\nNetworks with connected containers:\"\n for net in $(docker network ls --format \"{{.Name}}\"); do\n container_count=$(docker network inspect $net --format '{{len .Containers}}')\n if [ \"$container_count\" -gt 0 ]; then\n echo -e \"\\nNetwork: $net (Containers: $container_count)\"\n docker network inspect $net --format '{{range $id, $container := .Containers}}{{printf \"- %s (%s)\\n\" $container.Name $id}}{{end}}'\n fi\n done\nfi\n" output: prefix: "Docker Network Configuration:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: exec + requirements: {} - name: "docker_volumes" description: "Show Docker volume information and usage" params: @@ -330,54 +334,40 @@ mcp: type: string description: "Volume name or ID (optional, shows all if not specified)" constraints: - - "volume == '' || volume.matches('^[a-zA-Z0-9_.-]+$')" # Safe volume name/ID chars - - "volume.size() <= 64" # Reasonable volume name length + - "volume == '' || volume.matches('^[a-zA-Z0-9_.-]+$')" # Safe volume name/ID chars + - "volume.size() <= 64" # Reasonable volume name length run: - command: | - # Check if Docker is installed - if ! command -v docker &> /dev/null; then - echo "Error: Docker is not installed or not in the PATH." - exit 1 - fi - - if [ -n "{{ .volume }}" ]; then - # Verify volume exists - if ! docker volume ls --format "{{.Name}}:{{.Driver}}" | grep -q "{{ .volume }}"; then - echo "Error: Volume '{{ .volume }}' not found." - echo "Available volumes:" - docker volume ls - exit 1 - fi - - echo "Volume details for: {{ .volume }}" - docker volume inspect {{ .volume }} - - # Find containers using this volume - echo -e "\nContainers using this volume:" - found=false - for container in $(docker ps -a --format "{{.Names}}"); do - if docker inspect --format '{{range .Mounts}}{{if and (eq .Type "volume") (eq .Name "{{ .volume }}")}}{{$.Name}}{{end}}{{end}}' "$container" | grep -q .; then - echo "- $container" - found=true - fi - done - - if ! $found; then - echo "No containers currently using this volume." - fi - else - echo "Available Docker volumes:" - docker volume ls - - echo -e "\nVolume details:" - for vol in $(docker volume ls --format "{{.Name}}" | head -5); do - echo -e "\nVolume: $vol" - docker volume inspect $vol - done - - if [ "$(docker volume ls -q | wc -l)" -gt 5 ]; then - echo -e "\n(Only showing first 5 volumes. Specify a volume name for details on a specific volume.)" - fi - fi + command: "# Check if Docker is installed\nif ! command -v docker &> /dev/null; then\n echo \"Error: Docker is not installed or not in the PATH.\"\n exit 1\nfi\n\nif [ -n \"{{ .volume }}\" ]; then\n # Verify volume exists\n if ! docker volume ls --format \"{{.Name}}:{{.Driver}}\" | grep -q \"{{ .volume }}\"; then\n echo \"Error: Volume '{{ .volume }}' not found.\"\n echo \"Available volumes:\"\n docker volume ls\n exit 1\n fi\n \n echo \"Volume details for: {{ .volume }}\"\n docker volume inspect {{ .volume }}\n \n # Find containers using this volume\n echo -e \"\\nContainers using this volume:\"\n found=false\n for container in $(docker ps -a --format \"{{.Names}}\"); do\n if docker inspect --format '{{range .Mounts}}{{if and (eq .Type \"volume\") (eq .Name \"{{ .volume }}\")}}{{$.Name}}{{end}}{{end}}' \"$container\" | grep -q .; then\n echo \"- $container\"\n found=true\n fi\n done\n \n if ! $found; then\n echo \"No containers currently using this volume.\"\n fi\nelse\n echo \"Available Docker volumes:\"\n docker volume ls\n \n echo -e \"\\nVolume details:\"\n for vol in $(docker volume ls --format \"{{.Name}}\" | head -5); do\n echo -e \"\\nVolume: $vol\"\n docker volume inspect $vol\n done\n \n if [ \"$(docker volume ls -q | wc -l)\" -gt 5 ]; then\n echo -e \"\\n(Only showing first 5 volumes. Specify a volume name for details on a specific volume.)\"\n fi\nfi\n" output: - prefix: "Docker Volume Information:" \ No newline at end of file + prefix: "Docker Volume Information:" + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /var/run/docker.sock + - name: exec + requirements: {} diff --git a/examples/disk-diagnostics-ro.yaml b/examples/disk-diagnostics-ro.yaml index 726d6f9..1ea6898 100644 --- a/examples/disk-diagnostics-ro.yaml +++ b/examples/disk-diagnostics-ro.yaml @@ -38,7 +38,35 @@ mcp: fi output: prefix: "Storage Overview:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /Volumes + - /System/Volumes + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /proc + - /sys + - /mnt + - /media + - name: exec + requirements: {} - name: "filesystem_usage" description: "Show detailed filesystem usage by directories" params: @@ -49,34 +77,34 @@ mcp: type: number description: "Depth for directory analysis (1-3)" constraints: - - "path == '' || !path.contains('../')" # Prevent directory traversal - - "path == '' || !path.contains('~')" # Prevent home directory references - - "path == '' || path.startsWith('/')" # Only absolute paths - - "path == '' || path.matches('^[a-zA-Z0-9/._\\\\-]+$')" # Safe path characters - - "depth == 0.0 || (depth >= 1.0 && depth <= 3.0)" # Reasonable depth values + - "path == '' || !path.contains('../')" # Prevent directory traversal + - "path == '' || !path.contains('~')" # Prevent home directory references + - "path == '' || path.startsWith('/')" # Only absolute paths + - "path == '' || path.matches('^[a-zA-Z0-9/._\\\\-]+$')" # Safe path characters + - "depth == 0.0 || (depth >= 1.0 && depth <= 3.0)" # Reasonable depth values run: command: | # Set default values DEPTH=2 DIR_PATH="." - + if [[ "{{ .path }}" != "" ]]; then DIR_PATH="{{ .path }}" fi - + if [ {{ .depth }} -gt 0 ]; then DEPTH={{ .depth }} fi - + echo "Analyzing: $DIR_PATH (depth: $DEPTH)" echo "" - + # Check if path exists and is accessible if [ ! -d "$DIR_PATH" ]; then echo "Error: '$DIR_PATH' is not a valid directory or is not accessible." exit 1 fi - + echo "Directory sizes:" if [[ "$(uname)" == "Darwin" ]]; then # MacOS @@ -85,7 +113,7 @@ mcp: # Linux du -h --max-depth=$DEPTH "$DIR_PATH" 2>/dev/null | sort -hr | head -20 fi - + echo "" echo "Largest files:" if [[ "$(uname)" == "Darwin" ]]; then @@ -95,13 +123,49 @@ mcp: # Linux find "$DIR_PATH" -type f -not -path "*/\.*" -printf "%s %p\n" 2>/dev/null | sort -nr | head -15 | awk '{ printf "%.2f MB %s\n", $1/1024/1024, $2 }' fi - + echo "" echo "File types summary:" find "$DIR_PATH" -type f -not -path "*/\.*" | grep -o "\\.[^./]*$" | sort | uniq -c | sort -nr | head -10 output: prefix: "Filesystem Usage Analysis:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /Volumes + - /System/Volumes + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /proc + - /sys + - /mnt + - /media + - name: exec + requirements: {} - name: "inodes_check" description: "Check inode usage on filesystems" run: @@ -122,7 +186,43 @@ mcp: fi output: prefix: "Inode Usage Analysis:" - + runners: + - name: sandbox-exec + requirements: + os: darwin + executables: + - sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /Volumes + - /System/Volumes + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /proc + - /sys + - /mnt + - /media + - name: exec + requirements: {} - name: "disk_space_hogs" description: "Find largest files and directories consuming disk space" params: @@ -133,36 +233,36 @@ mcp: type: number description: "Minimum file size in MB to report (default: 100)" constraints: - - "path == '' || !path.contains('../')" # Prevent directory traversal - - "path == '' || !path.contains('~')" # Prevent home directory references - - "path == '' || path.startsWith('/')" # Only absolute paths - - "path == '' || path.matches('^[a-zA-Z0-9/._\\\\-]+$')" # Safe path characters - - "min_size_mb == 0.0 || (min_size_mb >= 10.0 && min_size_mb <= 1000.0)" # Reasonable size + - "path == '' || !path.contains('../')" # Prevent directory traversal + - "path == '' || !path.contains('~')" # Prevent home directory references + - "path == '' || path.startsWith('/')" # Only absolute paths + - "path == '' || path.matches('^[a-zA-Z0-9/._\\\\-]+$')" # Safe path characters + - "min_size_mb == 0.0 || (min_size_mb >= 10.0 && min_size_mb <= 1000.0)" # Reasonable size run: command: | # Set default values MIN_SIZE=100 DIR_PATH="." - + if [[ "{{ .path }}" != "" ]]; then DIR_PATH="{{ .path }}" fi - + if [ {{ .min_size_mb }} -gt 0 ]; then MIN_SIZE={{ .min_size_mb }} fi - + SIZE_BYTES=$((MIN_SIZE * 1024 * 1024)) - + echo "Searching for files larger than ${MIN_SIZE}MB in: $DIR_PATH" echo "" - + # Check if path exists and is accessible if [ ! -d "$DIR_PATH" ]; then echo "Error: '$DIR_PATH' is not a valid directory or is not accessible." exit 1 fi - + echo "Top disk space consumers (files larger than ${MIN_SIZE}MB):" if [[ "$(uname)" == "Darwin" ]]; then # MacOS @@ -171,7 +271,7 @@ mcp: # Linux find "$DIR_PATH" -type f -size +${SIZE_BYTES}c -not -path "*/\.*" -printf "%s %p\n" 2>/dev/null | sort -nr | head -20 | awk '{ printf "%.2f MB %s\n", $1/1024/1024, $2 }' fi - + echo "" echo "Top directories by size:" if [[ "$(uname)" == "Darwin" ]]; then @@ -183,7 +283,35 @@ mcp: fi output: prefix: "Disk Space Usage Report:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /Volumes + - /System/Volumes + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /proc + - /sys + - /mnt + - /media + - name: exec + requirements: {} - name: "fs_type_summary" description: "Show summary of filesystem types and mount options" run: @@ -220,7 +348,35 @@ mcp: fi output: prefix: "Filesystem Types and Mount Options:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /Volumes + - /System/Volumes + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /proc + - /sys + - /mnt + - /media + - name: exec + requirements: {} - name: "disk_health_check" description: "Check disk health status (read-only, non-destructive)" params: @@ -228,8 +384,8 @@ mcp: type: string description: "Disk to check (e.g., 'sda', 'nvme0n1', 'disk0')" constraints: - - "disk == '' || disk.matches('^[a-zA-Z0-9]+$')" # Only alphanumeric disk names - - "disk == '' || disk.size() <= 20" # Reasonable disk name length + - "disk == '' || disk.matches('^[a-zA-Z0-9]+$')" # Only alphanumeric disk names + - "disk == '' || disk.size() <= 20" # Reasonable disk name length run: command: | DISK="{{ .disk }}" @@ -278,4 +434,33 @@ mcp: dmesg | grep -i "$DISK\\|error\\|ata\\|failed" | tail -10 fi output: - prefix: "Disk Health Check:" \ No newline at end of file + prefix: "Disk Health Check:" + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /Volumes + - /System/Volumes + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /dev + - /proc + - /sys + - /mnt + - /media + - name: exec + requirements: {} diff --git a/examples/github-cli-ro.yaml b/examples/github-cli-ro.yaml index c41204b..50f7208 100644 --- a/examples/github-cli-ro.yaml +++ b/examples/github-cli-ro.yaml @@ -21,12 +21,12 @@ mcp: type: string description: "Optional JQ filter to extract specific fields from the JSON response" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "jq_filter == '' || jq_filter.size() <= 200" # Reasonable JQ filter length - - "!jq_filter.contains(';')" # Prevent command injection - - "!jq_filter.matches('.*[&|><`$].*')" # Block shell special characters + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "jq_filter == '' || jq_filter.size() <= 200" # Reasonable JQ filter length + - "!jq_filter.contains(';')" # Prevent command injection + - "!jq_filter.matches('.*[&|><`$].*')" # Block shell special characters run: env: - GITHUB_TOKEN @@ -38,7 +38,29 @@ mcp: {{ end }} output: prefix: "Repository information for {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_issue_list" description: "List issues in a GitHub repository" params: @@ -62,17 +84,17 @@ mcp: type: string description: "Filter issues by assignee" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "state == '' || ['open', 'closed', 'all'].exists(s, s == state)" # Valid state values - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 100)" # Reasonable limit - - "label == '' || label.size() <= 50" # Reasonable label length - - "!label.contains(';')" # Prevent command injection - - "author == '' || author.size() <= 50" # Reasonable author length - - "!author.contains(';')" # Prevent command injection - - "assignee == '' || assignee.size() <= 50" # Reasonable assignee length - - "!assignee.contains(';')" # Prevent command injection + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "state == '' || ['open', 'closed', 'all'].exists(s, s == state)" # Valid state values + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 100)" # Reasonable limit + - "label == '' || label.size() <= 50" # Reasonable label length + - "!label.contains(';')" # Prevent command injection + - "author == '' || author.size() <= 50" # Reasonable author length + - "!author.contains(';')" # Prevent command injection + - "assignee == '' || assignee.size() <= 50" # Reasonable assignee length + - "!assignee.contains(';')" # Prevent command injection run: env: - GITHUB_TOKEN @@ -83,11 +105,33 @@ mcp: ARGS="$ARGS {{ if .label }}--label {{ .label }}{{ end }}" ARGS="$ARGS {{ if .author }}--author {{ .author }}{{ end }}" ARGS="$ARGS {{ if .assignee }}--assignee {{ .assignee }}{{ end }}" - + gh issue list --repo "{{ .repo }}" $ARGS output: prefix: "Issues for {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_issue_view" description: "View a specific issue in a GitHub repository" requirements: @@ -106,21 +150,43 @@ mcp: type: boolean description: "Include comments in the output" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "int(issue_number) > 0" # Valid issue number + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "int(issue_number) > 0" # Valid issue number run: env: - GITHUB_TOKEN command: | ARGS="" ARGS="$ARGS {{ if .comments }}--comments{{ end }}" - + gh issue view {{ .issue_number }} --repo "{{ .repo }}" $ARGS output: prefix: "Issue #{{ .issue_number }} in {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_pr_list" description: "List pull requests in a GitHub repository" requirements: @@ -150,19 +216,19 @@ mcp: type: string description: "Filter PRs by base branch" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "state == '' || ['open', 'closed', 'merged', 'all'].exists(s, s == state)" # Valid state values - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 100)" # Reasonable limit - - "label == '' || label.size() <= 50" # Reasonable label length - - "!label.contains(';')" # Prevent command injection - - "author == '' || author.size() <= 50" # Reasonable author length - - "!author.contains(';')" # Prevent command injection - - "assignee == '' || assignee.size() <= 50" # Reasonable assignee length - - "!assignee.contains(';')" # Prevent command injection - - "base == '' || base.size() <= 50" # Reasonable base branch length - - "!base.contains(';')" # Prevent command injection + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "state == '' || ['open', 'closed', 'merged', 'all'].exists(s, s == state)" # Valid state values + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 100)" # Reasonable limit + - "label == '' || label.size() <= 50" # Reasonable label length + - "!label.contains(';')" # Prevent command injection + - "author == '' || author.size() <= 50" # Reasonable author length + - "!author.contains(';')" # Prevent command injection + - "assignee == '' || assignee.size() <= 50" # Reasonable assignee length + - "!assignee.contains(';')" # Prevent command injection + - "base == '' || base.size() <= 50" # Reasonable base branch length + - "!base.contains(';')" # Prevent command injection run: env: - GITHUB_TOKEN @@ -174,11 +240,33 @@ mcp: ARGS="$ARGS {{ if .author }}--author {{ .author }}{{ end }}" ARGS="$ARGS {{ if .assignee }}--assignee {{ .assignee }}{{ end }}" ARGS="$ARGS {{ if .base }}--base {{ .base }}{{ end }}" - + gh pr list --repo "{{ .repo }}" $ARGS output: prefix: "Pull requests for {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_pr_view" description: "View a specific pull request in a GitHub repository" requirements: @@ -197,21 +285,43 @@ mcp: type: boolean description: "Include comments in the output" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "int(pr_number) > 0" # Valid PR number + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "int(pr_number) > 0" # Valid PR number run: env: - GITHUB_TOKEN command: | ARGS="" ARGS="$ARGS {{ if .comments }}--comments{{ end }}" - + gh pr view {{ .pr_number }} --repo "{{ .repo }}" $ARGS output: prefix: "PR #{{ .pr_number }} in {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_release_list" description: "List releases in a GitHub repository" requirements: @@ -226,21 +336,43 @@ mcp: type: number description: "Maximum number of releases to display" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit run: env: - GITHUB_TOKEN command: | ARGS="" ARGS="$ARGS {{ if .limit }}--limit {{ .limit }}{{ end }}" - + gh release list --repo "{{ .repo }}" $ARGS output: prefix: "Releases for {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_release_view" description: "View a specific release in a GitHub repository" requirements: @@ -256,12 +388,12 @@ mcp: description: "Release tag name" required: true constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "tag.size() > 0 && tag.size() <= 50" # Reasonable tag length - - "!tag.contains(';')" # Prevent command injection - - "tag.matches('^[a-zA-Z0-9_.-]+$')" # Valid tag characters + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "tag.size() > 0 && tag.size() <= 50" # Reasonable tag length + - "!tag.contains(';')" # Prevent command injection + - "tag.matches('^[a-zA-Z0-9_.-]+$')" # Valid tag characters run: env: - GITHUB_TOKEN @@ -269,7 +401,29 @@ mcp: gh release view {{ .tag }} --repo "{{ .repo }}" output: prefix: "Release {{ .tag }} in {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_repo_list" description: "List repositories for a user or organization" requirements: @@ -290,13 +444,13 @@ mcp: type: string description: "Filter by primary language" constraints: - - "owner.size() > 0 && owner.size() <= 50" # Reasonable owner length - - "owner.matches('^[a-zA-Z0-9_.-]+$')" # Valid GitHub username characters - - "!owner.contains(';')" # Prevent command injection - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit - - "visibility == '' || ['public', 'private', 'internal'].exists(v, v == visibility)" # Valid visibility - - "language == '' || language.size() <= 30" # Reasonable language length - - "!language.contains(';')" # Prevent command injection + - "owner.size() > 0 && owner.size() <= 50" # Reasonable owner length + - "owner.matches('^[a-zA-Z0-9_.-]+$')" # Valid GitHub username characters + - "!owner.contains(';')" # Prevent command injection + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit + - "visibility == '' || ['public', 'private', 'internal'].exists(v, v == visibility)" # Valid visibility + - "language == '' || language.size() <= 30" # Reasonable language length + - "!language.contains(';')" # Prevent command injection run: env: - GITHUB_TOKEN @@ -305,11 +459,33 @@ mcp: ARGS="$ARGS {{ if .limit }}--limit {{ .limit }}{{ end }}" ARGS="$ARGS {{ if .visibility }}--visibility {{ .visibility }}{{ end }}" ARGS="$ARGS {{ if .language }}--language {{ .language }}{{ end }}" - + gh repo list {{ .owner }} $ARGS output: prefix: "Repositories for {{ .owner }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_search_repos" description: "Search for GitHub repositories" requirements: @@ -330,22 +506,48 @@ mcp: type: string description: "Sort order (asc, desc)" constraints: - - "query.size() > 0 && query.size() <= 200" # Reasonable query length - - "!query.contains(';')" # Prevent command injection - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit - - "sort == '' || ['stars', 'forks', 'updated'].exists(s, s == sort)" # Valid sort options - - "order == '' || ['asc', 'desc'].exists(o, o == order)" # Valid order options + - "query.size() > 0 && query.size() <= 200" # Reasonable query length + - "!query.contains(';')" # Prevent command injection + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit + - "sort == '' || ['stars', 'forks', 'updated'].exists(s, s == sort)" # Valid sort options + - "order == '' || ['asc', 'desc'].exists(o, o == order)" # Valid order options run: command: | ARGS="" ARGS="$ARGS {{ if .limit }}--limit {{ .limit }}{{ end }}" ARGS="$ARGS {{ if .sort }}--sort {{ .sort }}{{ end }}" ARGS="$ARGS {{ if .order }}--order {{ .order }}{{ end }}" - + gh search repos "{{ .query }}" $ARGS output: prefix: "Repository search results for '{{ .query }}':" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_search_issues" description: "Search for issues and pull requests" requirements: @@ -369,11 +571,11 @@ mcp: type: boolean description: "Show search results on the web instead" constraints: - - "query.size() > 0 && query.size() <= 200" # Reasonable query length - - "!query.contains(';')" # Prevent command injection - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit - - "sort == '' || ['comments', 'created', 'updated'].exists(s, s == sort)" # Valid sort options - - "order == '' || ['asc', 'desc'].exists(o, o == order)" # Valid order options + - "query.size() > 0 && query.size() <= 200" # Reasonable query length + - "!query.contains(';')" # Prevent command injection + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit + - "sort == '' || ['comments', 'created', 'updated'].exists(s, s == sort)" # Valid sort options + - "order == '' || ['asc', 'desc'].exists(o, o == order)" # Valid order options run: env: - GITHUB_TOKEN @@ -383,11 +585,33 @@ mcp: ARGS="$ARGS {{ if .sort }}--sort {{ .sort }}{{ end }}" ARGS="$ARGS {{ if .order }}--order {{ .order }}{{ end }}" ARGS="$ARGS {{ if .web }}--web{{ end }}" - + gh search issues "{{ .query }}" $ARGS output: prefix: "Issue search results for '{{ .query }}':" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_gist_list" description: "List gists for a user" requirements: @@ -404,11 +628,11 @@ mcp: type: string description: "Filter by visibility (all, public, secret)" constraints: - - "username.size() <= 50" # Reasonable username length - - "username == '' || username.matches('^[a-zA-Z0-9_.-]+$')" # Valid GitHub username characters - - "!username.contains(';')" # Prevent command injection - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit - - "visibility == '' || ['all', 'public', 'secret'].exists(v, v == visibility)" # Valid visibility + - "username.size() <= 50" # Reasonable username length + - "username == '' || username.matches('^[a-zA-Z0-9_.-]+$')" # Valid GitHub username characters + - "!username.contains(';')" # Prevent command injection + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit + - "visibility == '' || ['all', 'public', 'secret'].exists(v, v == visibility)" # Valid visibility run: env: - GITHUB_TOKEN @@ -416,11 +640,37 @@ mcp: ARGS="" ARGS="$ARGS {{ if .limit }}--limit {{ .limit }}{{ end }}" ARGS="$ARGS {{ if .visibility }}--visibility {{ .visibility }}{{ end }}" - + gh gist list {{ if .username }}--user {{ .username }}{{ end }} $ARGS output: prefix: "{{ if .username }}Gists for user {{ .username }}{{ else }}Your gists{{ end }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + requirements: + os: linux + executables: + - firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_workflow_list" description: "List workflows in a GitHub repository" requirements: @@ -432,9 +682,9 @@ mcp: description: "Repository name in the format owner/repo" required: true constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection run: env: - GITHUB_TOKEN @@ -442,7 +692,29 @@ mcp: gh workflow list --repo "{{ .repo }}" output: prefix: "Workflows for {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_workflow_view" description: "View a specific workflow in a GitHub repository" requirements: @@ -458,11 +730,11 @@ mcp: description: "Workflow name or ID" required: true constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "workflow.size() > 0 && workflow.size() <= 100" # Reasonable workflow name/ID length - - "!workflow.contains(';')" # Prevent command injection + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "workflow.size() > 0 && workflow.size() <= 100" # Reasonable workflow name/ID length + - "!workflow.contains(';')" # Prevent command injection run: env: - GITHUB_TOKEN @@ -470,7 +742,29 @@ mcp: gh workflow view "{{ .workflow }}" --repo "{{ .repo }}" output: prefix: "Workflow {{ .workflow }} in {{ .repo }}:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} - name: "gh_run_list" description: "List workflow runs in a GitHub repository" requirements: @@ -494,15 +788,15 @@ mcp: type: string description: "Filter by branch" constraints: - - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length - - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) - - "!repo.contains(';')" # Prevent command injection - - "workflow == '' || workflow.size() <= 100" # Reasonable workflow name/ID length - - "!workflow.contains(';')" # Prevent command injection - - "status == '' || ['success', 'failure', 'cancelled', 'skipped', 'in_progress'].exists(s, s == status)" # Valid status - - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit - - "branch == '' || branch.size() <= 50" # Reasonable branch length - - "!branch.contains(';')" # Prevent command injection + - "repo.size() > 0 && repo.size() <= 100" # Reasonable repo name length + - "repo.matches('^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$')" # Valid repo format (owner/repo) + - "!repo.contains(';')" # Prevent command injection + - "workflow == '' || workflow.size() <= 100" # Reasonable workflow name/ID length + - "!workflow.contains(';')" # Prevent command injection + - "status == '' || ['success', 'failure', 'cancelled', 'skipped', 'in_progress'].exists(s, s == status)" # Valid status + - "int(limit) == 0 || (int(limit) > 0 && int(limit) <= 50)" # Reasonable limit + - "branch == '' || branch.size() <= 50" # Reasonable branch length + - "!branch.contains(';')" # Prevent command injection run: env: - GITHUB_TOKEN @@ -512,7 +806,30 @@ mcp: ARGS="$ARGS {{ if .status }}--status {{ .status }}{{ end }}" ARGS="$ARGS {{ if .limit }}--limit {{ .limit }}{{ end }}" ARGS="$ARGS {{ if .branch }}--branch {{ .branch }}{{ end }}" - + gh run list --repo "{{ .repo }}" $ARGS output: - prefix: "Workflow runs for {{ .repo }}:" \ No newline at end of file + prefix: "Workflow runs for {{ .repo }}:" + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - '{{ env "HOME" }}/.config/gh' + - name: exec + requirements: {} diff --git a/examples/kubectl-ro.yaml b/examples/kubectl-ro.yaml index 5f692fe..f82e049 100644 --- a/examples/kubectl-ro.yaml +++ b/examples/kubectl-ro.yaml @@ -1,5 +1,7 @@ mcp: description: | + # Kubernetes Read-Only tools + Kubernetes Read-Only tools for enabling secure access to Kubernetes cluster information, allowing users to list resources, view pod logs, check cluster contexts, monitor resource usage, and inspect cluster @@ -8,7 +10,10 @@ mcp: shell: bash tools: - name: "kubectl_get" - description: "List Kubernetes resources (pods, deployments, services, etc.)" + description: | + List Kubernetes resources (pods, deployments, services, etc.) + Use the `kubectl_get_namespaces` command to list all namespaces. + Try to use some filtering for better performance and less noise. requirements: executables: ["kubectl"] params: @@ -27,7 +32,24 @@ mcp: description: "Label selector to filter resources (e.g. 'app=nginx')" output_format: type: string - description: "Output format (wide, json, yaml)" + description: | + Output format (wide, json, yaml). + Try to use some formatting for better readability. + When looking for some specific resource, try to use the `wide` format. + filter_grep: + type: string + description: | + Filter logs with grep for the given string (literal text match). + filter_grep_regex: + type: string + description: | + Filter logs with grep using a regular expression pattern. + The pattern is an extended regular expression. + filter_jq: + type: string + description: | + Filter with jq for the given expression. + Make sure you use `output_format`` == 'json' to use this. constraints: - "resource.size() > 0" # Resource must not be empty - "resource.size() <= 30" # Reasonable length limit @@ -44,14 +66,19 @@ mcp: LABEL_PARAM="{{ if .labels }}-l {{ .labels }}{{ end }}" FORMAT_PARAM="{{ if .output_format }}-o {{ .output_format }}{{ end }}" + {{ if .filter_grep }} + kubectl $CONTEXT_PARAM get {{ .resource }} $NAMESPACE_PARAM $LABEL_PARAM $FORMAT_PARAM | grep -F "{{ .filter_grep }}" || /bin/true + {{ else if .filter_grep_regex }} + kubectl $CONTEXT_PARAM get {{ .resource }} $NAMESPACE_PARAM $LABEL_PARAM $FORMAT_PARAM | grep -E "{{ .filter_grep_regex }}" || /bin/true + {{ else if .filter_jq }} + kubectl $CONTEXT_PARAM get {{ .resource }} $NAMESPACE_PARAM $LABEL_PARAM $FORMAT_PARAM | jq "{{ .filter_jq }}" || /bin/true + {{ else }} kubectl $CONTEXT_PARAM get {{ .resource }} $NAMESPACE_PARAM $LABEL_PARAM $FORMAT_PARAM + {{ end }} env: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -62,9 +89,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -87,11 +111,11 @@ mcp: params: resource: type: string - description: "Resource type (pods, deployments, services, etc.)" + description: "Kubernetes resource type (pods, deployments, services, etc.)" required: true name: type: string - description: "Resource name (optional, describes all resources of the type if not specified)" + description: "Kubernetes resource name (optional, describes all resources of the type if not specified)" ns: type: string description: "Kubernetes namespace (optional, uses current context's namespace if not specified)" @@ -118,9 +142,6 @@ mcp: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -131,9 +152,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -150,7 +168,9 @@ mcp: Details for {{ .resource }}{{ if .name }} "{{ .name }}"{{ end }}: - name: "kubectl_logs" - description: "Get logs from a pod" + description: | + Get logs from a pod. + Try to use some filtering for better performance and less noise. requirements: executables: ["kubectl"] params: @@ -176,9 +196,14 @@ mcp: since: type: string description: "Show logs since relative time (e.g. '5s', '2m', or '3h')" - grep: + filter_grep: + type: string + description: | + Filter logs with grep for the given string (literal text match). + filter_grep_regex: type: string - description: "Filter logs with grep for the given string" + description: | + Filter logs with grep using a regular expression pattern. constraints: - "pod.size() > 0" # Pod name must not be empty - "pod.size() <= 253" # Maximum DNS label length @@ -189,33 +214,33 @@ mcp: - "context == '' || context.size() <= 253" # Valid context name - "tail >= 0.0 && tail <= 10000.0" # Reasonable tail lines limit - "since == '' || since.matches('^[0-9]+(s|m|h|d)$')" # Valid duration format - - "grep == '' || (grep.size() <= 100 && !grep.contains(';'))" # Safe grep pattern + - "filter_grep == '' || (filter_grep.size() <= 100 && !filter_grep.contains(';'))" # Safe grep pattern + - "filter_grep_regex == '' || (filter_grep_regex.size() <= 100 && !filter_grep_regex.contains(';'))" # Safe grep regex pattern + - "(filter_grep == '' || filter_grep_regex == '')" # Only one of filter_grep or filter_grep_regex can be used run: command: | CONTEXT_PARAM="{{ if .context }}--context={{ .context }}{{ end }}" NAMESPACE_PARAM="{{ if .ns }}-n {{ .ns }}{{ end }}" CONTAINER_PARAM="{{ if .container }}-c {{ .container }}{{ end }}" - TAIL_PARAM="{{ if .tail }}-t {{ .tail }}{{ end }}" + TAIL_PARAM="" PREVIOUS_PARAM="{{ if .previous }}-p {{ end }}" - SINCE_PARAM="{{ if .since }}-s {{ .since }}{{ end }}" - GREP_PARAM="{{ if .grep }}-g {{ .grep }}{{ end }}" + SINCE_PARAM="{{ if .since }}--since={{ .since }}{{ end }}" # Base kubectl logs command CMD="kubectl $CONTEXT_PARAM logs {{ .pod }} $NAMESPACE_PARAM $CONTAINER_PARAM $TAIL_PARAM $PREVIOUS_PARAM $SINCE_PARAM" # Add grep filtering if specified - if [ -n "{{ .grep }}" ]; then - $CMD | grep -F "{{ .grep }}" - else - $CMD - fi + {{ if .filter_grep }} + $CMD | grep -F "{{ .filter_grep }}" || /bin/true + {{ else if .filter_grep_regex }} + $CMD | grep -E "{{ .filter_grep_regex }}" || /bin/true + {{ else }} + $CMD || /bin/true + {{ end }} env: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -226,9 +251,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -242,7 +264,7 @@ mcp: requirements: {} output: prefix: | - Logs for pod {{ .pod }}{{ if .container }} (container: {{ .container }}){{ end }}{{ if .grep }} (filtered for: '{{ .grep }}'){{ end }}: + Logs for pod {{ .pod }}{{ if .container }} (container: {{ .container }}){{ end }}{{ if .grep }} (filtered for literal: '{{ .grep }}'){{ end }}{{ if .grep_regex }} (filtered for regex: '{{ .grep_regex }}'){{ end }}: - name: "kubectl_get_contexts" description: "List available Kubernetes contexts" @@ -261,9 +283,6 @@ mcp: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -274,9 +293,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -309,9 +325,6 @@ mcp: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -322,9 +335,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -349,17 +359,12 @@ mcp: type: string description: "Kubernetes context to use (optional, uses current context if not specified)" run: - command: | - CONTEXT_PARAM="{{ if .context }}--context={{ .context }}{{ end }}" - - kubectl $CONTEXT_PARAM get namespaces + command: | + kubectl {{ if .context }}--context={{ .context }}{{ end }} get namespaces env: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -370,9 +375,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -426,14 +428,19 @@ mcp: description: "jq expression to apply to the response (optional)" filter_grep: type: string - description: "grep expression to apply to the response (optional)" + description: "grep expression to apply to the response (optional, literal text match)" + filter_grep_regex: + type: string + description: "grep regex pattern to apply to the response (optional)" constraints: - "ns == '' || (ns.size() <= 63 && ns.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'))" # Valid k8s namespace - "context == '' || context.size() <= 253" # Valid context name - - "pod_port.size() > 0" # Port must not be empty - - "pod_port.matches('^[0-9]+$')" # Port must be a number + - "pod_port.size() > 0" # Port must not be empty + - "pod_port.matches('^[0-9]+$')" # Port must be a number - "path.size() > 0" # Path must not be empty - "path.matches('^/[^ ]+$')" # Path must start with a '/' and contain no spaces + - "filter_grep == '' || filter_grep_regex == ''" # Only one of filter_grep or filter_grep_regex can be used + - "filter_jq == '' || (filter_grep == '' && filter_grep_regex == '')" # Can't use both jq and grep filters run: command: | @@ -461,7 +468,9 @@ mcp: {{ if .filter_jq }} curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | jq '{{ .filter_jq }}' {{ else if .filter_grep }} - curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | grep '{{ .filter_grep }}' + curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | grep -F "{{ .filter_grep }}" || /bin/true + {{ else if .filter_grep_regex }} + curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | grep -E "{{ .filter_grep_regex }}" || /bin/true {{ else }} curl $CURL_ARGS "http://localhost:$localport{{ .path }}" {{ end }} @@ -471,9 +480,6 @@ mcp: - KUBECONFIG runners: - name: sandbox-exec - requirements: - os: darwin - executables: [sandbox-exec] options: allow_networking: true allow_user_folders: false @@ -484,9 +490,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -500,7 +503,7 @@ mcp: requirements: {} output: prefix: | - Port forwarding to pod {{ .pod }}:{{ .pod_port }}: + Port forwarding to pod {{ .pod }}:{{ .pod_port }}{{ if .filter_grep }} (filtered for literal: '{{ .filter_grep }}'){{ end }}{{ if .filter_grep_regex }} (filtered for regex: '{{ .filter_grep_regex }}'){{ end }}: - name: "kubectl_envoy" description: | @@ -569,7 +572,10 @@ mcp: description: "jq expression to apply to the response (optional)" filter_grep: type: string - description: "grep expression to apply to the response (optional)" + description: "grep expression to apply to the response (optional, literal text match)" + filter_grep_regex: + type: string + description: "grep regex pattern to apply to the response (optional)" constraints: - "ns == '' || (ns.size() <= 63 && ns.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$'))" # Valid k8s namespace - "context == '' || context.size() <= 253" # Valid context name @@ -577,6 +583,8 @@ mcp: - "pod_port.matches('^[0-9]+$')" # Port must be a number - "path.size() > 0" # Path must not be empty - "path.matches('^/[^ ]+$')" # Path must start with a '/' and contain no spaces + - "filter_grep == '' || filter_grep_regex == ''" # Only one of filter_grep or filter_grep_regex can be used + - "filter_jq == '' || (filter_grep == '' && filter_grep_regex == '')" # Can't use both jq and grep filters run: command: | @@ -602,9 +610,11 @@ mcp: # nmap -sT -p $localport localhost # This would show that the port is open {{ if .filter_jq }} - curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | jq '{{ .filter_jq }}' + curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | jq '{{ .filter_jq }}' || /bin/true {{ else if .filter_grep }} - curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | grep '{{ .filter_grep }}' + curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | grep -F '{{ .filter_grep }}' || /bin/true + {{ else if .filter_grep_regex }} + curl $CURL_ARGS "http://localhost:$localport{{ .path }}" | grep -E '{{ .filter_grep_regex }}' || /bin/true {{ else }} curl $CURL_ARGS "http://localhost:$localport{{ .path }}" {{ end }} @@ -627,9 +637,6 @@ mcp: - "{{ env \"HOME\" }}/.kube" - "{{ env \"KUBECONFIG\" }}" - name: firejail - requirements: - os: linux - executables: [firejail] options: allow_networking: true allow_user_folders: false @@ -643,4 +650,4 @@ mcp: requirements: {} output: prefix: | - Envoy configuration from pod {{ .pod }}:{{ .pod_port }}{{ .path }}: + Envoy configuration from pod {{ .pod }}:{{ .pod_port }}{{ .path }}{{ if .filter_grep }} (filtered for literal: '{{ .filter_grep }}'){{ end }}{{ if .filter_grep_regex }} (filtered for regex: '{{ .filter_grep_regex }}'){{ end }}: diff --git a/examples/prompts-example.yaml b/examples/prompts-example.yaml new file mode 100644 index 0000000..bc4301f --- /dev/null +++ b/examples/prompts-example.yaml @@ -0,0 +1,45 @@ +# Example configuration showing how to use prompts in ToolsConfig +# This example demonstrates the new single prompts configuration format + +# Prompts configuration for the tools - this will be provided to clients +prompts: + system: + - "You are a helpful assistant that can create and manage files safely." + - "Always double-check file paths before creating files." + - "Use the available tools to help users with their file management tasks." + user: + - "Please assist me with file operations." + +# MCP server configuration +mcp: + description: | + Example configuration showing how to use prompts with MCPShell tools. + This server provides safe file creation capabilities with proper prompts. + + run: + shell: zsh + + tools: + - name: "create_safe_file" + description: "Create a file in a safe location with the given content" + params: + filename: + type: string + description: "Name of the file to create (must be in /tmp or current directory)" + required: true + content: + type: string + description: "Content to write to the file" + required: true + constraints: + - "filename.startsWith('/tmp/') || !filename.contains('/')" # Only allow /tmp or current dir + - "!filename.contains('..')" # Prevent directory traversal + - "filename.size() <= 100" # Limit filename length + - "content.size() <= 1000" # Limit content size + run: + command: | + echo "Creating file: {{ .filename }}" + echo "{{ .content }}" > "{{ .filename }}" + echo "File created successfully: {{ .filename }}" + output: + prefix: "File creation result:" diff --git a/examples/system-performance-ro.yaml b/examples/system-performance-ro.yaml index fa4ec68..1fd984e 100644 --- a/examples/system-performance-ro.yaml +++ b/examples/system-performance-ro.yaml @@ -16,7 +16,7 @@ mcp: echo "Kernel: $(uname -r)" echo "Uptime: $(uptime)" echo "" - + echo "CPU Information:" if [[ "$(uname)" == "Darwin" ]]; then # MacOS @@ -30,7 +30,7 @@ mcp: top -bn1 | grep "Cpu(s)" | sed "s/.,%//" fi echo "" - + echo "Memory Information:" if [[ "$(uname)" == "Darwin" ]]; then # MacOS @@ -40,12 +40,36 @@ mcp: free -h fi echo "" - + echo "Disk Usage:" df -h | grep -v "tmp" output: prefix: "System Performance Overview:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: exec + requirements: {} - name: "cpu_load" description: "Show detailed CPU load and utilization statistics" params: @@ -53,28 +77,28 @@ mcp: type: number description: "Number of samples to take (1-10)" delay: - type: number + type: number description: "Delay between samples in seconds (1-5)" constraints: - - "int(samples) == 0 || (int(samples) >= 1 && int(samples) <= 10)" # 1-10 samples - - "int(delay) == 0 || (int(delay) >= 1 && int(delay) <= 5)" # 1-5 second delay + - "int(samples) == 0 || (int(samples) >= 1 && int(samples) <= 10)" # 1-10 samples + - "int(delay) == 0 || (int(delay) >= 1 && int(delay) <= 5)" # 1-5 second delay run: command: | # Set default values if not specified SAMPLES=3 DELAY=2 - + if [ {{ .samples }} -gt 0 ]; then SAMPLES={{ .samples }} fi - + if [ {{ .delay }} -gt 0 ]; then DELAY={{ .delay }} fi - + echo "Taking $SAMPLES CPU samples with ${DELAY}s delay..." echo "" - + if [[ "$(uname)" == "Darwin" ]]; then # MacOS for i in $(seq 1 $SAMPLES); do @@ -85,13 +109,13 @@ mcp: echo "" fi done - + echo "" echo "CPU Info:" sysctl -n machdep.cpu.brand_string sysctl -n hw.physicalcpu sysctl -n hw.logicalcpu - + else # Linux for i in $(seq 1 $SAMPLES); do @@ -102,11 +126,11 @@ mcp: echo "" fi done - + echo "" echo "Load averages (1, 5, 15 min):" cat /proc/loadavg - + echo "" echo "CPU Info:" grep "model name" /proc/cpuinfo | head -1 @@ -114,47 +138,62 @@ mcp: fi output: prefix: "CPU Load Analysis:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: exec + requirements: {} - name: "memory_stats" description: "Show detailed memory usage statistics" run: - command: | - if [[ "$(uname)" == "Darwin" ]]; then - # MacOS - echo "Memory Usage Summary:" - vm_stat | perl -ne '/page size of (\d+)/ and $size=$1; /Pages free: (\d+)/ and print "Free Memory: " . $1 * $size / 1048576 . " MB\n"; /Pages active: (\d+)/ and print "Active Memory: " . $1 * $size / 1048576 . " MB\n"; /Pages inactive: (\d+)/ and print "Inactive Memory: " . $1 * $size / 1048576 . " MB\n"; /Pages speculative: (\d+)/ and print "Speculative Memory: " . $1 * $size / 1048576 . " MB\n"; /Pages wired down: (\d+)/ and print "Wired Memory: " . $1 * $size / 1048576 . " MB\n";' - - echo "" - echo "Memory Pressure:" - memory_pressure - - echo "" - echo "Top Memory Processes:" - top -l 1 -o MEM -n 5 | head -n 12 - else - # Linux - echo "Memory Usage Summary:" - free -h - - echo "" - echo "Memory Details:" - cat /proc/meminfo | head -15 - - echo "" - echo "Swap Usage:" - swapon --show - - echo "" - echo "Top Memory Processes:" - ps aux --sort=-%mem | head -6 - - echo "" - echo "Memory Caches:" - echo "Buffer/Cache usage: $(free | grep Mem | awk '{print $6}') bytes" - fi + command: "if [[ \"$(uname)\" == \"Darwin\" ]]; then\n # MacOS\n echo \"Memory Usage Summary:\"\n vm_stat | perl -ne '/page size of (\\d+)/ and $size=$1; /Pages free: (\\d+)/ and print \"Free Memory: \" . $1 * $size / 1048576 . \" MB\\n\"; /Pages active: (\\d+)/ and print \"Active Memory: \" . $1 * $size / 1048576 . \" MB\\n\"; /Pages inactive: (\\d+)/ and print \"Inactive Memory: \" . $1 * $size / 1048576 . \" MB\\n\"; /Pages speculative: (\\d+)/ and print \"Speculative Memory: \" . $1 * $size / 1048576 . \" MB\\n\"; /Pages wired down: (\\d+)/ and print \"Wired Memory: \" . $1 * $size / 1048576 . \" MB\\n\";'\n \n echo \"\"\n echo \"Memory Pressure:\"\n memory_pressure\n \n echo \"\"\n echo \"Top Memory Processes:\"\n top -l 1 -o MEM -n 5 | head -n 12\nelse\n # Linux\n echo \"Memory Usage Summary:\"\n free -h\n \n echo \"\"\n echo \"Memory Details:\"\n cat /proc/meminfo | head -15\n \n echo \"\"\n echo \"Swap Usage:\"\n swapon --show\n \n echo \"\"\n echo \"Top Memory Processes:\"\n ps aux --sort=-%mem | head -6\n \n echo \"\"\n echo \"Memory Caches:\"\n echo \"Buffer/Cache usage: $(free | grep Mem | awk '{print $6}') bytes\"\nfi\n" output: prefix: "Memory Usage Analysis:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: exec + requirements: {} - name: "disk_io_stats" description: "Show detailed disk I/O statistics" params: @@ -162,28 +201,28 @@ mcp: type: number description: "Number of samples to take (1-5)" delay: - type: number + type: number description: "Delay between samples in seconds (1-5)" constraints: - - "int(samples) == 0 || (int(samples) >= 1 && int(samples) <= 5)" # 1-5 samples - - "int(delay) == 0 || (int(delay) >= 1 && int(delay) <= 5)" # 1-5 second delay + - "int(samples) == 0 || (int(samples) >= 1 && int(samples) <= 5)" # 1-5 samples + - "int(delay) == 0 || (int(delay) >= 1 && int(delay) <= 5)" # 1-5 second delay run: command: | # Set default values if not specified SAMPLES=2 DELAY=3 - + if [ {{ .samples }} -gt 0 ]; then SAMPLES={{ .samples }} fi - + if [ {{ .delay }} -gt 0 ]; then DELAY={{ .delay }} fi - + echo "Taking $SAMPLES disk I/O samples with ${DELAY}s delay..." echo "" - + if [[ "$(uname)" == "Darwin" ]]; then # MacOS echo "Disk I/O Statistics:" @@ -195,7 +234,7 @@ mcp: echo "" fi done - + echo "" echo "Filesystem Status:" df -h @@ -210,21 +249,45 @@ mcp: echo "" fi done - + echo "" echo "Current I/O Operations:" for disk in $(lsblk -d -o NAME | grep -v NAME); do echo "Disk: $disk" cat /proc/diskstats | grep $disk done - + echo "" echo "Filesystem Status:" df -h fi output: prefix: "Disk I/O Performance Analysis:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: exec + requirements: {} - name: "network_stats" description: "Show detailed network usage statistics" params: @@ -235,18 +298,18 @@ mcp: type: number description: "Number of samples to take (1-5)" constraints: - - "interface.matches(\"^[a-zA-Z0-9_:.\\\\-]+$\")" # Valid interface name - - "interface.size() <= 15" # Reasonable length - - "int(samples) == 0 || (int(samples) >= 1 && int(samples) <= 5)" # 1-5 samples + - "interface.matches(\"^[a-zA-Z0-9_:.\\\\-]+$\")" # Valid interface name + - "interface.size() <= 15" # Reasonable length + - "int(samples) == 0 || (int(samples) >= 1 && int(samples) <= 5)" # 1-5 samples run: command: | # Set default values if not specified SAMPLES=2 - + if [ {{ .samples }} -gt 0 ]; then SAMPLES={{ .samples }} fi - + # Get default interface if none specified INTERFACE="" if [ -n "{{ .interface }}" ]; then @@ -260,20 +323,20 @@ mcp: INTERFACE=$(ip route show default | grep -o "dev [^ ]*" | awk '{print $2}') fi fi - + echo "Network interface: $INTERFACE" echo "" - + if [[ "$(uname)" == "Darwin" ]]; then # MacOS echo "Interface details:" ifconfig $INTERFACE - + echo "" echo "Network statistics:" netstat -i | head -2 netstat -i | grep $INTERFACE - + echo "" echo "Connection statistics:" netstat -na | grep "tcp4\\|tcp6" | awk '{print $6}' | sort | uniq -c | sort -rn @@ -281,7 +344,7 @@ mcp: # Linux echo "Interface details:" ip addr show $INTERFACE - + echo "" echo "Network statistics:" for i in $(seq 1 $SAMPLES); do @@ -292,12 +355,12 @@ mcp: echo "" fi done - + echo "" echo "Connection statistics:" ss -tan | awk '{print $1}' | sort | uniq -c | sort -rn fi - + echo "" echo "Active connections:" if [[ "$(uname)" == "Darwin" ]]; then @@ -309,7 +372,31 @@ mcp: fi output: prefix: "Network Performance Analysis:" - + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: exec + requirements: {} - name: "process_stats" description: "Show stats for top processes by CPU, memory, or both" params: @@ -327,28 +414,28 @@ mcp: # Set default values if not specified SORT="cpu" COUNT=10 - + if [ -n "{{ .sort_by }}" ]; then SORT="{{ .sort_by }}" fi - + if [ {{ .count }} -gt 0 ]; then COUNT={{ .count }} fi - + if [[ "$(uname)" == "Darwin" ]]; then # MacOS if [ "$SORT" == "cpu" ] || [ "$SORT" == "both" ]; then echo "Top $COUNT processes by CPU usage:" ps -Ao pid,user,comm,%cpu,%mem -r | head -n $(($COUNT + 1)) fi - + if [ "$SORT" == "memory" ] || [ "$SORT" == "both" ]; then echo "" echo "Top $COUNT processes by memory usage:" ps -Ao pid,user,comm,%cpu,%mem -m | head -n $(($COUNT + 1)) fi - + echo "" echo "Process counts by user:" ps -Ao user | sort | uniq -c | sort -r @@ -358,16 +445,41 @@ mcp: echo "Top $COUNT processes by CPU usage:" ps -Ao pid,user,comm,%cpu,%mem --sort=-%cpu | head -n $(($COUNT + 1)) fi - + if [ "$SORT" == "memory" ] || [ "$SORT" == "both" ]; then echo "" echo "Top $COUNT processes by memory usage:" ps -Ao pid,user,comm,%cpu,%mem --sort=-%mem | head -n $(($COUNT + 1)) fi - + echo "" echo "Process counts by user:" ps -Ao user | sort | uniq -c | sort -r fi output: - prefix: "Process Statistics:" \ No newline at end of file + prefix: "Process Statistics:" + runners: + - name: sandbox-exec + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: firejail + options: + allow_networking: true + allow_user_folders: false + allow_read_folders: + - /usr/bin + - /usr/local/bin + - /bin + - /etc + - /proc + - /sys + - name: exec + requirements: {} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 4ddf10d..7224bc3 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -1,3 +1,7 @@ +// 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 ( @@ -14,16 +18,14 @@ import ( "github.com/inercia/MCPShell/pkg/server" ) -// AgentConfig holds the configuration for the agent +// 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 { - ConfigFile string - Model string - SystemPrompt string - UserPrompt string - OpenAIApiKey string - OpenAIApiURL string - Once bool - Version string + 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 @@ -43,9 +45,9 @@ func New(cfg AgentConfig, logger *common.Logger) *Agent { // Validate checks if the configuration is valid func (a *Agent) Validate() error { // Check if config file is provided - if a.config.ConfigFile == "" { - a.logger.Error("Configuration file is required") - return fmt.Errorf("configuration file is required") + if a.config.ToolsFile == "" { + a.logger.Error("Tools configuration file is required") + return fmt.Errorf("tools configuration file is required") } // Check if model is provided @@ -55,15 +57,9 @@ func (a *Agent) Validate() error { } // Check if API key is provided or in environment - if a.config.OpenAIApiKey == "" { - // Try to get from environment if not provided in config (e.g. flags) - // Note: This logic might be better placed in the CLI or a dedicated config loader - // if agentOpenAIApiKey := os.Getenv("OPENAI_API_KEY"); agentOpenAIApiKey != "" { - // a.config.OpenAIApiKey = agentOpenAIApiKey - // } else { - a.logger.Error("OpenAI API key is required") - return fmt.Errorf("OpenAI API key is required (set OPENAI_API_KEY environment variable or pass via config/flags)") - // } + if a.config.APIKey == "" { + a.logger.Error("API key is required") + return fmt.Errorf("API key is required (set API key environment variable or pass via config/flags)") } return nil @@ -75,47 +71,16 @@ func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan defer common.RecoverPanic() defer close(agentOutput) // Ensure agentOutput is closed when Run exits - // Load the configuration file (local or remote) - localConfigPath, cleanup, err := config.ResolveConfigPath(a.config.ConfigFile, a.logger) + // Create server instance + srv, cleanup, err := a.setupServer(ctx) if err != nil { - a.logger.Error("Failed to load configuration: %v", err) - agentOutput <- fmt.Sprintf("Error: Failed to load configuration: %v", err) - return fmt.Errorf("failed to load configuration: %w", err) - } - - // Ensure temporary files are cleaned up - defer cleanup() - - // Load configuration to access prompts - cfg, err := config.NewConfigFromFile(localConfigPath) - if err != nil { - a.logger.Error("Failed to parse configuration: %v", err) - agentOutput <- fmt.Sprintf("Error: Failed to parse configuration: %v", err) - return fmt.Errorf("failed to parse configuration: %w", err) - } - - // 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) - agentOutput <- fmt.Sprintf("Error: Failed to create MCP server: %v", err) - return fmt.Errorf("failed to create MCP server: %w", err) + agentOutput <- fmt.Sprintf("Error: %v", err) + return err } + defer cleanup() // Ensure cleanup is called // Initialize OpenAI client - openaiConfig := openai.DefaultConfig(a.config.OpenAIApiKey) - if a.config.OpenAIApiURL != "" { - openaiConfig.BaseURL = a.config.OpenAIApiURL - } - client := openai.NewClientWithConfig(openaiConfig) - a.logger.Info("Initialized OpenAI client with model: %s", a.config.Model) + client := a.initializeOpenAIClient() // Convert MCP tools to OpenAI tools openaiTools, err := srv.GetOpenAITools() @@ -126,102 +91,25 @@ func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan } a.logger.Info("Retrieved %d tools from MCP server", len(openaiTools)) - // Add termination instructions to the system prompt - systemPrompt := a.config.SystemPrompt - if systemPrompt == "" && len(cfg.Prompts) > 0 { - // Use system prompts from config if available - var systemPrompts []string - for _, prompt := range cfg.Prompts { - systemPrompts = append(systemPrompts, prompt.System...) - } - if len(systemPrompts) > 0 { - systemPrompt = strings.Join(systemPrompts, "\n\n") - a.logger.Info("Using system prompt from config file") - } - } - - if systemPrompt == "" { - systemPrompt = "You are a helpful assistant." - } - - if !strings.Contains(systemPrompt, "terminate the conversation") { - systemPrompt += "\n\nWhen you have completed your task, please type 'TERMINATE' to end the conversation." - } - - // Start the conversation - messages := []openai.ChatCompletionMessage{ - { - Role: openai.ChatMessageRoleSystem, - Content: systemPrompt, - }, - } - - // Add user prompt if provided or from config - if a.config.UserPrompt != "" { - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleUser, - Content: a.config.UserPrompt, - }) - } else if len(cfg.Prompts) > 0 { - // Add user prompts from config - for _, promptSet := range cfg.Prompts { - for _, userPrompt := range promptSet.User { - if userPrompt != "" { - messages = append(messages, openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleUser, - Content: userPrompt, - }) - a.logger.Info("Added user prompt from config file") - } - } - } - } + // Setup conversation + messages := a.setupConversation() // Create a single-run context if in --once mode - var singleRunCtx context.Context - var singleRunCancel context.CancelFunc - 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, 30*time.Second) + singleRunCtx, singleRunCancel := context.WithTimeout(ctx, 30*time.Second) defer singleRunCancel() - - // Replace the main context with our single-run context for the duration of this Run ctx = singleRunCtx - a.logger.Info("Running in one-shot mode with 30s safety timeout") } // Main interaction loop for { - // Create the chat completion request - req := openai.ChatCompletionRequest{ - Model: a.config.Model, - Messages: messages, - Tools: openaiTools, - } - // Get response from the model - var resp openai.ChatCompletionResponse - var llmErr error - - // Perform LLM call in a separate goroutine to allow context cancellation - done := make(chan struct{}) - go func() { - defer close(done) - resp, llmErr = client.CreateChatCompletion(ctx, req) - }() - - select { - case <-ctx.Done(): - a.logger.Info("Context cancelled, terminating agent Run loop.") - return ctx.Err() - case <-done: - if llmErr != nil { - a.logger.Error("Error getting LLM response: %v", llmErr) - agentOutput <- fmt.Sprintf("Error: Error getting LLM response: %v", llmErr) - return fmt.Errorf("error getting LLM response: %w", llmErr) - } + resp, err := a.callLLM(ctx, client, messages, openaiTools) + if err != nil { + agentOutput <- fmt.Sprintf("Error: %v", err) + return err } // Process the response - first, check if we have any choices @@ -242,61 +130,9 @@ func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan agentOutput <- fmt.Sprintf("Assistant: %s", respMsg.Content) } - // Process each tool call - for _, call := range respMsg.ToolCalls { - a.logger.Info("Processing tool call: %s", call.Function.Name) - a.logger.Debug("Raw tool arguments: %s", call.Function.Arguments) - - // Parse the arguments - var args map[string]interface{} - if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil { - a.logger.Error("Failed to parse tool arguments: %v", err) - toolResultContent := fmt.Sprintf("Error: Failed to parse arguments - %v", err) - toolResultMsg := openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleTool, - Content: toolResultContent, - ToolCallID: call.ID, - } - messages = append(messages, toolResultMsg) - agentOutput <- fmt.Sprintf("Tool %s error: Failed to parse arguments", call.Function.Name) - continue - } - - // Log the parsed arguments - argsJSON, _ := json.MarshalIndent(args, "", " ") - a.logger.Debug("Parsed tool arguments: %s", string(argsJSON)) - - // Convert non-string arguments to strings - for key, value := range args { - if _, ok := value.(string); !ok && value != nil { - args[key] = fmt.Sprintf("%v", value) - } - } - - // Execute the tool - toolResult, err := srv.ExecuteTool(ctx, call.Function.Name, args) - if err != nil { - a.logger.Error("Failed to execute tool '%s': %v", call.Function.Name, err) - errorContent := fmt.Sprintf("Error: %v", err) - toolResultMsg := openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleTool, - Content: errorContent, - ToolCallID: call.ID, - } - messages = append(messages, toolResultMsg) - agentOutput <- fmt.Sprintf("Tool %s error: %v", call.Function.Name, err) - continue - } - - // Add the result - toolResultMsg := openai.ChatCompletionMessage{ - Role: openai.ChatMessageRoleTool, - Content: toolResult, - ToolCallID: call.ID, - } - messages = append(messages, toolResultMsg) - agentOutput <- fmt.Sprintf("Tool %s result: %s", call.Function.Name, toolResult) - } + // Execute the tool calls + toolMessages := a.executeToolCalls(ctx, srv, respMsg.ToolCalls, agentOutput) + messages = append(messages, toolMessages...) } else { // No tool calls, just print the message agentOutput <- fmt.Sprintf("Assistant: %s", respMsg.Content) @@ -340,3 +176,172 @@ func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan } } } + +// setupServer initializes and creates the MCP server +func (a *Agent) setupServer(ctx context.Context) (*server.Server, func(), error) { + // Load the configuration file (local or remote) + localConfigPath, cleanup, err := config.ResolveConfigPath(a.config.ToolsFile, a.logger) + if err != nil { + a.logger.Error("Failed to load configuration: %v", err) + return nil, cleanup, fmt.Errorf("failed to load configuration: %w", err) + } + + // 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 +} + +// initializeOpenAIClient creates and configures the OpenAI client +func (a *Agent) initializeOpenAIClient() *openai.Client { + openaiConfig := openai.DefaultConfig(a.config.APIKey) + if a.config.APIURL != "" { + openaiConfig.BaseURL = a.config.APIURL + } + client := openai.NewClientWithConfig(openaiConfig) + a.logger.Info("Initialized OpenAI client with model: %s", a.config.Model) + return client +} + +// setupConversation prepares the initial conversation messages and system prompt +func (a *Agent) setupConversation() []openai.ChatCompletionMessage { + // Add termination instructions to the system prompt + systemPrompt := a.config.Prompts.GetSystemPrompts() + if systemPrompt == "" { + a.logger.Info("No system prompt configured, using default") + systemPrompt = "You are a helpful assistant." + } else { + a.logger.Info("Using system prompt from config") + } + + if !strings.Contains(systemPrompt, "terminate the conversation") { + systemPrompt += "\n\nWhen you have completed your task, please type 'TERMINATE' to end the conversation." + } + + // Start the conversation + messages := []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleSystem, + Content: systemPrompt, + }, + } + + // Add user prompt if provided from command line + if a.config.UserPrompt != "" { + messages = append(messages, openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleUser, + Content: a.config.UserPrompt, + }) + } + // Note: User prompts from agent config file are ignored + + return messages +} + +// executeToolCalls processes and executes tool calls from the LLM response +func (a *Agent) executeToolCalls(ctx context.Context, srv *server.Server, toolCalls []openai.ToolCall, agentOutput chan string) []openai.ChatCompletionMessage { + var toolMessages []openai.ChatCompletionMessage + + // Process each tool call + for _, call := range toolCalls { + a.logger.Info("Processing tool call: %s", call.Function.Name) + a.logger.Debug("Raw tool arguments: %s", call.Function.Arguments) + + // Parse the arguments + var args map[string]interface{} + if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err != nil { + a.logger.Error("Failed to parse tool arguments: %v", err) + toolResultContent := fmt.Sprintf("Error: Failed to parse arguments - %v", err) + toolResultMsg := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleTool, + Content: toolResultContent, + ToolCallID: call.ID, + } + toolMessages = append(toolMessages, toolResultMsg) + agentOutput <- fmt.Sprintf("Tool %s error: Failed to parse arguments", call.Function.Name) + continue + } + + // Log the parsed arguments + argsJSON, _ := json.MarshalIndent(args, "", " ") + a.logger.Debug("Parsed tool arguments: %s", string(argsJSON)) + + // Convert non-string arguments to strings + for key, value := range args { + if _, ok := value.(string); !ok && value != nil { + args[key] = fmt.Sprintf("%v", value) + } + } + + // Execute the tool + toolResult, err := srv.ExecuteTool(ctx, call.Function.Name, args) + if err != nil { + a.logger.Error("Failed to execute tool '%s': %v", call.Function.Name, err) + errorContent := fmt.Sprintf("Error: %v", err) + toolResultMsg := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleTool, + Content: errorContent, + ToolCallID: call.ID, + } + toolMessages = append(toolMessages, toolResultMsg) + agentOutput <- fmt.Sprintf("Tool %s error: %v", call.Function.Name, err) + continue + } + + // Add the result + toolResultMsg := openai.ChatCompletionMessage{ + Role: openai.ChatMessageRoleTool, + Content: toolResult, + ToolCallID: call.ID, + } + toolMessages = append(toolMessages, toolResultMsg) + agentOutput <- fmt.Sprintf("Tool %s result: %s", call.Function.Name, toolResult) + } + + return toolMessages +} + +// callLLM makes a chat completion call to the LLM with context cancellation support +func (a *Agent) callLLM(ctx context.Context, client *openai.Client, messages []openai.ChatCompletionMessage, openaiTools []openai.Tool) (openai.ChatCompletionResponse, error) { + // Create the chat completion request + req := openai.ChatCompletionRequest{ + Model: a.config.Model, + Messages: messages, + Tools: openaiTools, + } + + // Get response from the model + var resp openai.ChatCompletionResponse + var llmErr error + + // Perform LLM call in a separate goroutine to allow context cancellation + done := make(chan struct{}) + go func() { + defer close(done) + resp, llmErr = client.CreateChatCompletion(ctx, req) + }() + + select { + case <-ctx.Done(): + a.logger.Info("Context cancelled, terminating LLM call.") + return openai.ChatCompletionResponse{}, ctx.Err() + case <-done: + if llmErr != nil { + a.logger.Error("Error getting LLM response: %v", llmErr) + return openai.ChatCompletionResponse{}, fmt.Errorf("error getting LLM response: %w", llmErr) + } + } + + return resp, nil +} diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go new file mode 100644 index 0000000..9ff8990 --- /dev/null +++ b/pkg/agent/agent_test.go @@ -0,0 +1,413 @@ +package agent + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sashabaranov/go-openai" + + "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", + 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 config", + config: AgentConfig{ + ToolsFile: "test.yaml", + ModelConfig: ModelConfig{ + Model: "gpt-4", + APIKey: "test-key", + }, + }, + wantErr: false, + }, + { + name: "missing tools file", + config: AgentConfig{ + ToolsFile: "", + ModelConfig: ModelConfig{ + Model: "gpt-4", + APIKey: "test-key", + }, + }, + wantErr: true, + errMsg: "tools configuration file is required", + }, + { + name: "missing model", + config: AgentConfig{ + ToolsFile: "test.yaml", + ModelConfig: ModelConfig{ + Model: "", + APIKey: "test-key", + }, + }, + wantErr: true, + errMsg: "LLM model is required", + }, + { + name: "missing API key", + config: AgentConfig{ + ToolsFile: "test.yaml", + ModelConfig: ModelConfig{ + Model: "gpt-4", + APIKey: "", + }, + }, + wantErr: true, + errMsg: "API key is required (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) + } + } + }) + } +} + +func TestSetupConversation(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 + expectedLength int + hasUserPrompt bool + }{ + { + name: "with user prompt", + config: AgentConfig{ + UserPrompt: "test user prompt", + ModelConfig: ModelConfig{ + Prompts: common.PromptsConfig{ + System: []string{"test system prompt"}, + }, + }, + }, + expectedLength: 2, + hasUserPrompt: true, + }, + { + name: "without user prompt", + config: AgentConfig{ + UserPrompt: "", + ModelConfig: ModelConfig{ + Prompts: common.PromptsConfig{ + System: []string{"test system prompt"}, + }, + }, + }, + expectedLength: 1, + hasUserPrompt: false, + }, + { + name: "no system prompt - uses default", + config: AgentConfig{ + UserPrompt: "test user prompt", + ModelConfig: ModelConfig{ + Prompts: common.PromptsConfig{}, + }, + }, + expectedLength: 2, + hasUserPrompt: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := New(tt.config, logger) + messages := agent.setupConversation() + + if len(messages) != tt.expectedLength { + t.Errorf("Expected %d messages, got %d", tt.expectedLength, len(messages)) + } + + // First message should always be system message + if len(messages) > 0 && messages[0].Role != openai.ChatMessageRoleSystem { + t.Errorf("Expected first message to be system message, got %s", messages[0].Role) + } + + // Check if user message is present when expected + if tt.hasUserPrompt { + if len(messages) < 2 { + t.Error("Expected user message but didn't find it") + } else if messages[1].Role != openai.ChatMessageRoleUser { + t.Errorf("Expected second message to be user message, got %s", messages[1].Role) + } + } + + // System prompt should always contain termination instruction + if len(messages) > 0 && !strings.Contains(messages[0].Content, "TERMINATE") { + t.Error("Expected system prompt to contain termination instruction") + } + }) + } +} + +func TestInitializeOpenAIClient(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 + }{ + { + name: "basic client initialization", + config: AgentConfig{ + ModelConfig: ModelConfig{ + Model: "gpt-4", + APIKey: "test-key", + }, + }, + }, + { + name: "client with custom API URL", + config: AgentConfig{ + ModelConfig: ModelConfig{ + Model: "gpt-4", + APIKey: "test-key", + APIURL: "https://custom.api.url", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := New(tt.config, logger) + client := agent.initializeOpenAIClient() + + if client == nil { + t.Error("Expected OpenAI client to be initialized") + } + }) + } +} + +// TestAgentWithOllama tests the agent with a real Ollama model +// 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 agent configuration for Ollama + cfg := AgentConfig{ + ToolsFile: testConfig, + UserPrompt: "What is the current date? Use the date tool to find out.", + Once: true, + Version: "test", + ModelConfig: ModelConfig{ + Model: modelName, + 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) + } + + // Test OpenAI client initialization + client := agent.initializeOpenAIClient() + if client == nil { + t.Fatal("Failed to initialize OpenAI client") + } + + // Test conversation setup + messages := agent.setupConversation() + if len(messages) < 2 { + t.Fatal("Expected at least 2 messages (system + user)") + } + + if messages[0].Role != "system" { + t.Error("First message should be system message") + } + + if messages[1].Role != "user" { + t.Error("Second message should be user message") + } + + if !strings.Contains(messages[1].Content, "date") { + t.Error("User message should contain 'date' from our test prompt") + } + + // Test that we can call the model (basic connectivity test) + // We'll do a simple test call without tools to verify the connection works + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Create a simple test request + testMessages := []openai.ChatCompletionMessage{ + { + Role: openai.ChatMessageRoleSystem, + Content: "You are a helpful assistant. Respond briefly.", + }, + { + Role: openai.ChatMessageRoleUser, + Content: "Say 'Hello, MCPShell test!' and nothing else.", + }, + } + + req := openai.ChatCompletionRequest{ + Model: cfg.Model, + Messages: testMessages, + MaxTokens: 50, + Temperature: 0.1, + } + + resp, err := client.CreateChatCompletion(ctx, req) + if err != nil { + t.Fatalf("Failed to create chat completion: %v", err) + } + + if len(resp.Choices) == 0 { + t.Fatal("No response choices returned") + } + + responseText := resp.Choices[0].Message.Content + if responseText == "" { + t.Fatal("Empty response from model") + } + + t.Logf("Model response: %s", responseText) + + // Verify the response contains expected content + if !strings.Contains(strings.ToLower(responseText), "hello") { + t.Errorf("Expected response to contain 'hello', got: %s", responseText) + } + + t.Log("Ollama integration test completed successfully") +} + +// 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 +} diff --git a/pkg/agent/config_file.go b/pkg/agent/config_file.go new file mode 100644 index 0000000..5740db4 --- /dev/null +++ b/pkg/agent/config_file.go @@ -0,0 +1,159 @@ +// 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"` +} + +// 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 { + 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 +} diff --git a/pkg/agent/config_file_test.go b/pkg/agent/config_file_test.go new file mode 100644 index 0000000..d87268e --- /dev/null +++ b/pkg/agent/config_file_test.go @@ -0,0 +1,181 @@ +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()) + } +} diff --git a/pkg/agent/config_sample.yaml b/pkg/agent/config_sample.yaml new file mode 100644 index 0000000..9c64ad4 --- /dev/null +++ b/pkg/agent/config_sample.yaml @@ -0,0 +1,20 @@ +agent: + models: + - model: "gpt-4o" + class: "openai" + name: "openai" + default: true + api-key: "your-openai-api-key" + api-url: "https://api.openai.com/v1" + prompts: + system: + - "You are a helpful assistant." + - "Use the available tools to help diagnose and solve problems." + + - model: "gemma3n" + class: "ollama" + name: "ollama" + prompts: + system: + - "You are a helpful assistant." + - "Please provide detailed explanations." diff --git a/pkg/common/prompts.go b/pkg/common/prompts.go new file mode 100644 index 0000000..c366266 --- /dev/null +++ b/pkg/common/prompts.go @@ -0,0 +1,49 @@ +package common + +// PromptsConfig holds prompt configuration with system and user prompts +type PromptsConfig struct { + System []string `yaml:"system,omitempty"` // System prompts + User []string `yaml:"user,omitempty"` // User prompts +} + +// GetSystemPrompts returns all system prompts joined with newlines +func (p PromptsConfig) GetSystemPrompts() string { + if len(p.System) == 0 { + return "" + } + // Join with newlines + result := "" + for i, prompt := range p.System { + if i > 0 { + result += "\n" + } + result += prompt + } + return result +} + +// GetUserPrompts returns all user prompts joined with newlines +func (p PromptsConfig) GetUserPrompts() string { + if len(p.User) == 0 { + return "" + } + // Join with newlines + result := "" + for i, prompt := range p.User { + if i > 0 { + result += "\n" + } + result += prompt + } + return result +} + +// HasSystemPrompts returns true if there are any system prompts configured +func (p PromptsConfig) HasSystemPrompts() bool { + return len(p.System) > 0 +} + +// HasUserPrompts returns true if there are any user prompts configured +func (p PromptsConfig) HasUserPrompts() bool { + return len(p.User) > 0 +} diff --git a/pkg/config/resolve.go b/pkg/config/resolve.go index 846ebb9..3ab3e92 100644 --- a/pkg/config/resolve.go +++ b/pkg/config/resolve.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/inercia/MCPShell/pkg/common" + "github.com/inercia/MCPShell/pkg/utils" ) // ResolveConfigPath tries to resolve the configuration file path. @@ -32,31 +33,26 @@ func ResolveConfigPath(configPath string, logger *common.Logger) (string, func() return "", noopCleanup, fmt.Errorf("invalid configuration path: %w", err) } - // If it's not a URL, check if it's a local file or directory + // If it's not a URL, use our tools file resolution if parsedURL.Scheme == "" || parsedURL.Scheme == "file" { localPath := configPath if parsedURL.Scheme == "file" { localPath = parsedURL.Path } - // Check if the path exists - fileInfo, err := os.Stat(localPath) - if os.IsNotExist(err) { - return "", noopCleanup, fmt.Errorf("configuration path does not exist: %s", localPath) - } - - // If it's a directory, resolve all YAML files in it - if fileInfo.IsDir() { + // If localPath is a directory, merge all YAMLs inside + if info, statErr := os.Stat(localPath); statErr == nil && info.IsDir() { return resolveConfigDirectory(localPath, logger) } - // If it's a file, verify it's a YAML file - if !strings.HasSuffix(strings.ToLower(localPath), ".yaml") && !strings.HasSuffix(strings.ToLower(localPath), ".yml") { - return "", noopCleanup, fmt.Errorf("configuration file must have .yaml or .yml extension: %s", localPath) + // Use ResolveToolsFile for local file resolution with directory support + resolvedPath, err := utils.ResolveToolsFile(localPath) + if err != nil { + return "", noopCleanup, err } - logger.Info("Using local configuration file: %s", localPath) - return localPath, noopCleanup, nil + logger.Info("Using local configuration file: %s", resolvedPath) + return resolvedPath, noopCleanup, nil } // If it's a remote URL, download it diff --git a/pkg/config/config.go b/pkg/config/tools_config.go similarity index 83% rename from pkg/config/config.go rename to pkg/config/tools_config.go index 3daa945..c02cb47 100644 --- a/pkg/config/config.go +++ b/pkg/config/tools_config.go @@ -15,10 +15,10 @@ import ( "github.com/inercia/MCPShell/pkg/common" ) -// Config represents the top-level configuration structure for the application. -type Config struct { - // Prompts is a list of prompts that will be provided to clients - Prompts []Prompts `yaml:"prompts,omitempty"` +// ToolsConfig represents the top-level configuration structure for the application. +type ToolsConfig struct { + // Prompts is a prompt configuration that will be provided to clients + Prompts common.PromptsConfig `yaml:"prompts,omitempty"` // MCP contains the configuration specific to the MCP server and tools MCP MCPConfig `yaml:"mcp"` @@ -36,15 +36,6 @@ type MCPConfig struct { Tools []MCPToolConfig `yaml:"tools"` } -// Prompts is a list of prompts that could be provided to clients -type Prompts struct { - // System is a list of system prompts - System []string `yaml:"system,omitempty"` - - // User is a list of user prompts - User []string `yaml:"user,omitempty"` -} - // MCPRunConfig represents run-specific configuration options. type MCPRunConfig struct { // Shell is the shell to use for executing commands (e.g., bash, sh, zsh) @@ -111,18 +102,19 @@ type MCPToolRunConfig struct { //////////////////////////////////////////////////////////////////////////////////// // NewConfigFromFile loads the configuration from a YAML file at the specified path. +// The file path should already be resolved (use ResolveConfigPath for URL/directory resolution). // // Parameters: -// - filepath: Path to the YAML configuration file +// - filepath: Path to the YAML configuration file (should be absolute and resolved) // // Returns: // - A pointer to the loaded Config structure // - An error if loading or parsing fails -func NewConfigFromFile(filepath string) (*Config, error) { +func NewConfigFromFile(filepath string) (*ToolsConfig, error) { // Open the configuration file file, err := os.Open(filepath) if err != nil { - return nil, fmt.Errorf("failed to open config file: %w", err) + return nil, fmt.Errorf("failed to open config file %s: %w", filepath, err) } defer func() { _ = file.Close() @@ -131,14 +123,14 @@ func NewConfigFromFile(filepath string) (*Config, error) { // Read the file content data, err := io.ReadAll(file) if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) + return nil, fmt.Errorf("failed to read config file %s: %w", filepath, err) } // Parse the YAML content - var config Config + var config ToolsConfig err = yaml.Unmarshal(data, &config) if err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) + return nil, fmt.Errorf("failed to parse config file %s: %w", filepath, err) } return &config, nil @@ -149,7 +141,7 @@ func NewConfigFromFile(filepath string) (*Config, error) { // // Returns: // - A slice of ToolDefinition objects -func (c *Config) GetTools() []Tool { +func (c *ToolsConfig) GetTools() []Tool { var tools []Tool for _, toolConfig := range c.MCP.Tools { @@ -175,7 +167,7 @@ func (c *Config) GetTools() []Tool { // Returns: // - YAML data as bytes // - An error if serialization fails -func (c *Config) ToYAML() ([]byte, error) { +func (c *ToolsConfig) ToYAML() ([]byte, error) { return yaml.Marshal(c) } @@ -192,12 +184,12 @@ func (c *Config) ToYAML() ([]byte, error) { // Returns: // - A pointer to the merged Config structure // - An error if loading or merging fails -func LoadAndMergeConfigs(filepaths []string) (*Config, error) { +func LoadAndMergeConfigs(filepaths []string) (*ToolsConfig, error) { if len(filepaths) == 0 { return nil, fmt.Errorf("no configuration files provided") } - var mergedConfig Config + var mergedConfig ToolsConfig var isFirstFile = true for _, filepath := range filepaths { @@ -206,8 +198,9 @@ func LoadAndMergeConfigs(filepaths []string) (*Config, error) { return nil, fmt.Errorf("failed to load config file %s: %w", filepath, err) } - // Merge prompts (concatenate from all files) - mergedConfig.Prompts = append(mergedConfig.Prompts, config.Prompts...) + // Merge prompts (concatenate system and user prompts) + mergedConfig.Prompts.System = append(mergedConfig.Prompts.System, config.Prompts.System...) + mergedConfig.Prompts.User = append(mergedConfig.Prompts.User, config.Prompts.User...) // For MCP config, use the first file's description and run config if isFirstFile { diff --git a/pkg/config/config_test.go b/pkg/config/tools_config_test.go similarity index 99% rename from pkg/config/config_test.go rename to pkg/config/tools_config_test.go index 987e7d5..9d5c980 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/tools_config_test.go @@ -105,7 +105,7 @@ func TestCheckToolPrerequisites(t *testing.T) { func TestCreateTools_Prerequisites(t *testing.T) { // Create a simple config with two tools, one with unmet prerequisites - cfg := &Config{ + cfg := &ToolsConfig{ MCP: MCPConfig{ Tools: []MCPToolConfig{ { diff --git a/pkg/server/description_test.go b/pkg/server/description_test.go index b62d219..f427589 100644 --- a/pkg/server/description_test.go +++ b/pkg/server/description_test.go @@ -45,7 +45,7 @@ func TestGetDescription(t *testing.T) { } // Define a custom implementation for testing - testGetDescription := func(cfg Config, configLoader func(string) (*config.Config, error)) (string, error) { + testGetDescription := func(cfg Config, configLoader func(string) (*config.ToolsConfig, error)) (string, error) { var finalDesc string // First, check if we should load the description from the config file @@ -107,7 +107,7 @@ func TestGetDescription(t *testing.T) { tests := []struct { name string config Config - mockLoader func(string) (*config.Config, error) + mockLoader func(string) (*config.ToolsConfig, error) expectedResult string expectError bool }{ @@ -117,8 +117,8 @@ func TestGetDescription(t *testing.T) { ConfigFile: configPath, Logger: logger, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{ + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{ MCP: config.MCPConfig{ Description: "description from config", }, @@ -133,8 +133,8 @@ func TestGetDescription(t *testing.T) { Logger: logger, Descriptions: []string{"description from cmd"}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{}, nil + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{}, nil }, expectedResult: "description from cmd", }, @@ -145,8 +145,8 @@ func TestGetDescription(t *testing.T) { Logger: logger, Descriptions: []string{"description 1", "description 2"}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{}, nil + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{}, nil }, expectedResult: "description 1\ndescription 2", }, @@ -157,8 +157,8 @@ func TestGetDescription(t *testing.T) { Logger: logger, Descriptions: []string{"description from cmd"}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{ + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{ MCP: config.MCPConfig{ Description: "description from config", }, @@ -174,8 +174,8 @@ func TestGetDescription(t *testing.T) { Descriptions: []string{"description from cmd"}, DescriptionOverride: true, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{ + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{ MCP: config.MCPConfig{ Description: "description from config", }, @@ -190,8 +190,8 @@ func TestGetDescription(t *testing.T) { Logger: logger, DescriptionFiles: []string{file1Path, file2Path}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{}, nil + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{}, nil }, expectedResult: "description from file 1\ndescription from file 2", }, @@ -202,8 +202,8 @@ func TestGetDescription(t *testing.T) { Logger: logger, DescriptionFiles: []string{file1Path}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{ + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{ MCP: config.MCPConfig{ Description: "description from config", }, @@ -219,8 +219,8 @@ func TestGetDescription(t *testing.T) { DescriptionFiles: []string{file1Path}, DescriptionOverride: true, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{ + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{ MCP: config.MCPConfig{ Description: "description from config", }, @@ -235,8 +235,8 @@ func TestGetDescription(t *testing.T) { Logger: logger, DescriptionFiles: []string{filepath.Join(tempDir, "nonexistent.txt")}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{}, nil + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{}, nil }, expectError: true, }, @@ -248,8 +248,8 @@ func TestGetDescription(t *testing.T) { Descriptions: []string{"description from cmd"}, DescriptionFiles: []string{file1Path}, }, - mockLoader: func(path string) (*config.Config, error) { - return &config.Config{ + mockLoader: func(path string) (*config.ToolsConfig, error) { + return &config.ToolsConfig{ MCP: config.MCPConfig{ Description: "description from config", }, diff --git a/pkg/server/server.go b/pkg/server/server.go index ae2e00c..c7ff9c4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -240,7 +240,7 @@ func (s *Server) CreateServer() error { } // loadTools loads tools from the configuration and registers them with the server -func (s *Server) loadTools(cfg *config.Config) error { +func (s *Server) loadTools(cfg *config.ToolsConfig) error { s.logger.Info("Loading configuration from file: %s", s.configFile) // Check if there are any tools defined diff --git a/pkg/utils/home.go b/pkg/utils/home.go new file mode 100644 index 0000000..6058c70 --- /dev/null +++ b/pkg/utils/home.go @@ -0,0 +1,72 @@ +// Package utils provides utility functions for MCPShell +package utils + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +const ( + // MCPShellDirEnv is the environment variable that specifies the configuration directory for MCPShell + MCPShellDirEnv = "MCPSHELL_DIR" + // MCPShellToolsDirEnv is the environment variable that specifies the tools directory for MCPShell + MCPShellToolsDirEnv = "MCPSHELL_TOOLS_DIR" + // MCPShellHome is the name of the configuration directory for MCPShell + MCPShellHome = ".mcpshell" + // MCPShellToolsDir is the name of the tools directory within MCPShell home + MCPShellToolsDir = "tools" +) + +// GetHome returns the user's home directory in a portable way +func GetHome() (string, error) { + var home string + + if runtime.GOOS == "windows" { + home = os.Getenv("USERPROFILE") + if home == "" { + home = os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + } + } else { + home = os.Getenv("HOME") + } + + if home == "" { + return "", fmt.Errorf("unable to determine home directory") + } + + return home, nil +} + +// GetMCPShellHome returns the MCPShell configuration directory +// This is typically ~/.mcpshell on Unix-like systems or %USERPROFILE%\.mcpshell on Windows +func GetMCPShellHome() (string, error) { + if mcpHome := os.Getenv(MCPShellDirEnv); mcpHome != "" { + return mcpHome, nil + } + + home, err := GetHome() + if err != nil { + return "", err + } + + mcpShellHome := filepath.Join(home, MCPShellHome) + return mcpShellHome, nil +} + +// GetMCPShellToolsDir returns the MCPShell tools directory +// This is typically ~/.mcpshell/tools on Unix-like systems or %USERPROFILE%\.mcpshell\tools on Windows +func GetMCPShellToolsDir() (string, error) { + if toolsDir := os.Getenv(MCPShellToolsDirEnv); toolsDir != "" { + return toolsDir, nil + } + + mcpShellHome, err := GetMCPShellHome() + if err != nil { + return "", err + } + + toolsDir := filepath.Join(mcpShellHome, MCPShellToolsDir) + return toolsDir, nil +} diff --git a/pkg/utils/home_test.go b/pkg/utils/home_test.go new file mode 100644 index 0000000..e3ceee5 --- /dev/null +++ b/pkg/utils/home_test.go @@ -0,0 +1,51 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetHome(t *testing.T) { + home, err := GetHome() + if err != nil { + t.Fatalf("Failed to get home directory: %v", err) + } + + if home == "" { + t.Error("Expected non-empty home directory") + } + + // Verify the directory exists + if _, err := os.Stat(home); os.IsNotExist(err) { + t.Errorf("Home directory does not exist: %s", home) + } +} + +func TestGetMCPShellHome(t *testing.T) { + mcpShellHome, err := GetMCPShellHome() + if err != nil { + t.Fatalf("Failed to get MCPShell home directory: %v", err) + } + + if mcpShellHome == "" { + t.Error("Expected non-empty MCPShell home directory") + } + + // Verify it ends with .mcpshell + expectedSuffix := ".mcpshell" + if filepath.Base(mcpShellHome) != expectedSuffix { + t.Errorf("Expected MCPShell home to end with %s, got %s", expectedSuffix, mcpShellHome) + } + + // Verify it's under the user's home directory + home, err := GetHome() + if err != nil { + t.Fatalf("Failed to get home directory: %v", err) + } + + expectedPath := filepath.Join(home, ".mcpshell") + if mcpShellHome != expectedPath { + t.Errorf("Expected MCPShell home to be %s, got %s", expectedPath, mcpShellHome) + } +} diff --git a/pkg/utils/tests.go b/pkg/utils/tests.go new file mode 100644 index 0000000..74e9c3f --- /dev/null +++ b/pkg/utils/tests.go @@ -0,0 +1,201 @@ +// Package utils provides utility functions for testing and development +package utils + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// OllamaModel represents a model available in Ollama +type OllamaModel struct { + Name string `json:"name"` + ModifiedAt time.Time `json:"modified_at"` + Size int64 `json:"size"` + Digest string `json:"digest"` + Details ModelDetails `json:"details"` +} + +// ModelDetails contains detailed information about a model +type ModelDetails struct { + Format string `json:"format"` + Family string `json:"family"` + Families []string `json:"families"` + ParameterSize string `json:"parameter_size"` + QuantizationLevel string `json:"quantization_level"` +} + +// OllamaModelsResponse represents the response from Ollama's models API +type OllamaModelsResponse struct { + Models []OllamaModel `json:"models"` +} + +// PreferredModels defines the order of preference for testing models +// These models are known to support tools/function calling +var PreferredModels = []string{ + "qwen2.5:14b", + "qwen2.5:7b", + "qwen2.5:3b", + "qwen2.5:1.5b", + "llama3.1:8b", + "llama3.1:7b", + "llama3.2:3b", + "llama3.2:1b", + "mistral:7b", + "phi3:3.8b", + "phi3:mini", +} + +// ToolCapableModels contains model families known to support tools +var ToolCapableModels = map[string]bool{ + "qwen": true, + "qwen2": true, + "qwen2.5": true, + "llama3": true, + "llama3.1": true, + "llama3.2": true, + "mistral": true, + "phi3": true, + "gemma": false, // Most Gemma models don't support tools well + "codellama": false, // Code-focused, limited tool support +} + +// IsOllamaRunning checks if Ollama server is running and accessible +func IsOllamaRunning() bool { + client := &http.Client{ + Timeout: 2 * time.Second, + } + + resp, err := client.Get("http://localhost:11434/api/tags") + if err != nil { + return false + } + defer func() { _ = resp.Body.Close() }() + + return resp.StatusCode == http.StatusOK +} + +// GetAvailableModels retrieves the list of models available in Ollama +func GetAvailableModels() ([]OllamaModel, error) { + client := &http.Client{ + Timeout: 5 * time.Second, + } + + resp, err := client.Get("http://localhost:11434/api/tags") + if err != nil { + return nil, fmt.Errorf("failed to connect to ollama: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama API returned status %d", resp.StatusCode) + } + + var response OllamaModelsResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to parse Ollama response: %w", err) + } + + return response.Models, nil +} + +// IsModelToolCapable checks if a model supports tools based on its family +func IsModelToolCapable(modelName string) bool { + // Extract the model family from the full model name + // e.g., "qwen2.5:7b" -> "qwen2.5", "llama3.1:8b" -> "llama3.1" + parts := strings.Split(modelName, ":") + if len(parts) == 0 { + return false + } + + family := parts[0] + + // Check exact match first + if capable, exists := ToolCapableModels[family]; exists { + return capable + } + + // Check for partial matches (e.g., "qwen2.5" should match "qwen") + for knownFamily, capable := range ToolCapableModels { + if strings.HasPrefix(family, knownFamily) && capable { + return true + } + } + + return false +} + +// FindBestAvailableModel finds the best available model for testing +// Returns the model name and whether it supports tools +func FindBestAvailableModel() (string, bool, error) { + models, err := GetAvailableModels() + if err != nil { + return "", false, err + } + + // Create a map of available models for quick lookup + availableModels := make(map[string]bool) + for _, model := range models { + availableModels[model.Name] = true + } + + // Check preferred models in order + for _, preferredModel := range PreferredModels { + if availableModels[preferredModel] { + toolCapable := IsModelToolCapable(preferredModel) + return preferredModel, toolCapable, nil + } + } + + // If no preferred model is found, try to find any tool-capable model + for _, model := range models { + if IsModelToolCapable(model.Name) { + return model.Name, true, nil + } + } + + // If no tool-capable model found, return the first available model + if len(models) > 0 { + return models[0].Name, false, nil + } + + return "", false, fmt.Errorf("no models available in Ollama") +} + +// SkipIfOllamaNotRunning skips the test if Ollama is not running +func SkipIfOllamaNotRunning(t *testing.T) { + if !IsOllamaRunning() { + t.Skip("Skipping test: Ollama server is not running") + } +} + +// RequireOllamaWithTools skips the test if Ollama is not running or no tool-capable models are available +func RequireOllamaWithTools(t *testing.T) string { + SkipIfOllamaNotRunning(t) + + modelName, supportsTools, err := FindBestAvailableModel() + if err != nil { + t.Skipf("Skipping test: failed to find available model: %v", err) + } + + if !supportsTools { + t.Skipf("Skipping test: no tool-capable models available (found: %s)", modelName) + } + + return modelName +} + +// RequireOllama skips the test if Ollama is not running but returns any available model +func RequireOllama(t *testing.T) string { + SkipIfOllamaNotRunning(t) + + modelName, _, err := FindBestAvailableModel() + if err != nil { + t.Skipf("Skipping test: failed to find available model: %v", err) + } + + return modelName +} diff --git a/pkg/utils/tests_test.go b/pkg/utils/tests_test.go new file mode 100644 index 0000000..ecdccd4 --- /dev/null +++ b/pkg/utils/tests_test.go @@ -0,0 +1,82 @@ +package utils + +import ( + "testing" +) + +func TestIsOllamaRunning(t *testing.T) { + // This test will check if Ollama is running + // The result will depend on whether Ollama is actually running + running := IsOllamaRunning() + t.Logf("Ollama running: %v", running) + + // We don't assert a specific value since it depends on the environment + // This test is mainly to verify the function doesn't panic +} + +func TestGetAvailableModels(t *testing.T) { + if !IsOllamaRunning() { + t.Skip("Skipping test: Ollama is not running") + } + + models, err := GetAvailableModels() + if err != nil { + t.Fatalf("Failed to get available models: %v", err) + } + + t.Logf("Found %d models", len(models)) + for _, model := range models { + t.Logf("Model: %s (size: %d bytes)", model.Name, model.Size) + } +} + +func TestFindBestAvailableModel(t *testing.T) { + if !IsOllamaRunning() { + t.Skip("Skipping test: Ollama is not running") + } + + modelName, supportsTools, err := FindBestAvailableModel() + if err != nil { + t.Fatalf("Failed to find best available model: %v", err) + } + + t.Logf("Best available model: %s (supports tools: %v)", modelName, supportsTools) + + if modelName == "" { + t.Error("Expected non-empty model name") + } +} + +func TestIsModelToolCapable(t *testing.T) { + tests := []struct { + model string + expected bool + }{ + {"qwen2.5:7b", true}, + {"llama3.1:8b", true}, + {"mistral:7b", true}, + {"gemma:7b", false}, + {"unknown:model", false}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + result := IsModelToolCapable(tt.model) + if result != tt.expected { + t.Errorf("IsModelToolCapable(%s) = %v, expected %v", tt.model, result, tt.expected) + } + }) + } +} + +func TestRequireOllamaWithTools(t *testing.T) { + // Create a sub-test that should skip if no tool-capable models are available + t.Run("WithOllamaAndTools", func(t *testing.T) { + modelName := RequireOllamaWithTools(t) + t.Logf("Got tool-capable model: %s", modelName) + + if !IsModelToolCapable(modelName) { + t.Errorf("Model %s should be tool-capable", modelName) + } + }) +} diff --git a/pkg/utils/tools.go b/pkg/utils/tools.go new file mode 100644 index 0000000..b9a2c72 --- /dev/null +++ b/pkg/utils/tools.go @@ -0,0 +1,72 @@ +// Package utils provides utility functions for MCPShell +package utils + +import ( + "fmt" + "os" + "path/filepath" +) + +// ResolveToolsFile resolves a tools file path with the following logic: +// 1. If the file path is absolute, use it as-is +// 2. If the file path is relative, first check current directory, then tools directory +// 3. If the file doesn't have an extension, append .yaml +// 4. Return an error if the resolved file doesn't exist +func ResolveToolsFile(toolsFile string) (string, error) { + // Add .yaml extension if no extension is present + if filepath.Ext(toolsFile) == "" { + toolsFile = toolsFile + ".yaml" + } + + // If it's an absolute path, use it directly + if filepath.IsAbs(toolsFile) { + if _, err := os.Stat(toolsFile); err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("tools file not found: %s", toolsFile) + } + return "", fmt.Errorf("failed to access tools file %s: %w", toolsFile, err) + } + return toolsFile, nil + } + + // It's a relative path, check current directory first + currentDirPath := toolsFile + if _, err := os.Stat(currentDirPath); err == nil { + // File exists in current directory + absPath, err := filepath.Abs(currentDirPath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path for %s: %w", currentDirPath, err) + } + return absPath, nil + } + + // File not found in current directory, try tools directory + toolsDir, err := GetMCPShellToolsDir() + if err != nil { + return "", fmt.Errorf("failed to get tools directory: %w", err) + } + toolsDirPath := filepath.Join(toolsDir, toolsFile) + + if _, err := os.Stat(toolsDirPath); err == nil { + // File exists in tools directory + return toolsDirPath, nil + } + + // File not found in either location + return "", fmt.Errorf("tools file not found. Searched in:\n%s\n%s", + currentDirPath, toolsDirPath) +} + +// EnsureToolsDir creates the tools directory if it doesn't exist +func EnsureToolsDir() error { + toolsDir, err := GetMCPShellToolsDir() + if err != nil { + return fmt.Errorf("failed to get tools directory: %w", err) + } + + if err := os.MkdirAll(toolsDir, 0755); err != nil { + return fmt.Errorf("failed to create tools directory %s: %w", toolsDir, err) + } + + return nil +} diff --git a/pkg/utils/tools_test.go b/pkg/utils/tools_test.go new file mode 100644 index 0000000..4bcff32 --- /dev/null +++ b/pkg/utils/tools_test.go @@ -0,0 +1,194 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveToolsFile(t *testing.T) { + // Create a temporary tools directory for testing + tmpDir := t.TempDir() + toolsDir := filepath.Join(tmpDir, "tools") + if err := os.MkdirAll(toolsDir, 0o755); err != nil { + t.Fatal(err) + } + + // Set the tools directory environment variable + t.Setenv(MCPShellToolsDirEnv, toolsDir) + + // Create test files in tools directory + toolsTestFile := filepath.Join(toolsDir, "test.yaml") + if err := os.WriteFile(toolsTestFile, []byte("test content from tools dir"), 0o644); err != nil { + t.Fatal(err) + } + + // Create a current directory test file + currentDir := t.TempDir() + originalWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(originalWd) }() + if err := os.Chdir(currentDir); err != nil { + t.Fatal(err) + } + + currentTestFile := filepath.Join(currentDir, "current.yaml") + if err := os.WriteFile(currentTestFile, []byte("test content from current dir"), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + input string + expected string + wantErr bool + }{ + { + name: "file in current directory takes precedence", + input: "current.yaml", + expected: currentTestFile, + wantErr: false, + }, + { + name: "file in tools directory when not in current", + input: "test.yaml", + expected: toolsTestFile, + wantErr: false, + }, + { + name: "relative path without extension found in tools dir", + input: "test", + expected: toolsTestFile, + wantErr: false, + }, + { + name: "nonexistent file", + input: "nonexistent", + wantErr: true, + }, + { + name: "absolute path", + input: toolsTestFile, + expected: toolsTestFile, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := ResolveToolsFile(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + // Resolve symlinks for comparison (important on macOS where /var -> /private/var) + expectedResolved, err := filepath.EvalSymlinks(tt.expected) + if err != nil { + expectedResolved = tt.expected + } + resultResolved, err := filepath.EvalSymlinks(result) + if err != nil { + resultResolved = result + } + if resultResolved != expectedResolved { + t.Errorf("expected %s, got %s", expectedResolved, resultResolved) + } + }) + } +} + +func TestEnsureToolsDir(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + toolsDir := filepath.Join(tmpDir, "tools") + + // Set the tools directory environment variable + t.Setenv(MCPShellToolsDirEnv, toolsDir) + + // Ensure the directory doesn't exist initially + if _, err := os.Stat(toolsDir); !os.IsNotExist(err) { + t.Fatal("Tools directory should not exist initially") + } + + // Call EnsureToolsDir + err := EnsureToolsDir() + if err != nil { + t.Fatalf("EnsureToolsDir failed: %v", err) + } + + // Check that the directory was created + if _, err := os.Stat(toolsDir); os.IsNotExist(err) { + t.Fatal("Tools directory was not created") + } + + // Ensure calling it again doesn't cause an error + err = EnsureToolsDir() + if err != nil { + t.Fatalf("EnsureToolsDir failed on second call: %v", err) + } +} + +func TestGetMCPShellToolsDir(t *testing.T) { + tests := []struct { + name string + envVar string + wantErr bool + }{ + { + name: "default directory", + envVar: "", + wantErr: false, + }, + { + name: "custom directory from env", + envVar: "/custom/tools/dir", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envVar != "" { + t.Setenv(MCPShellToolsDirEnv, tt.envVar) + } else { + if err := os.Unsetenv(MCPShellToolsDirEnv); err != nil { + t.Fatalf("failed to unset env var: %v", err) + } + } + + result, err := GetMCPShellToolsDir() + if tt.wantErr { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if tt.envVar != "" { + if result != tt.envVar { + t.Errorf("expected %s, got %s", tt.envVar, result) + } + } else { + // Should contain the default tools directory + if !filepath.IsAbs(result) { + t.Errorf("expected absolute path, got %s", result) + } + if filepath.Base(result) != MCPShellToolsDir { + t.Errorf("expected path to end with %s, got %s", MCPShellToolsDir, result) + } + } + }) + } +} diff --git a/test_file.txt b/test_file.txt deleted file mode 100644 index 8d7dd7e..0000000 --- a/test_file.txt +++ /dev/null @@ -1 +0,0 @@ -This is a test file created by the agent diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..cccd2d5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,105 @@ +# MCPShell Tests + +This directory contains end-to-end tests for MCPShell, organized by functionality. + +## Directory Structure + +```text +tests/ +├── agent/ # Agent functionality tests +│ ├── test_agent.sh # Main agent test script +│ └── tools/ # Agent tools configurations +│ └── test_agent.yaml # Agent test configuration +├── exe/ # Direct tool execution tests +│ ├── test_exe.sh # Basic exe command test +│ ├── test_exe_empty_file.sh # Empty file creation test +│ ├── test_exe_constraints.sh # Constraint validation test +│ └── test_exe_config.yaml # Tool configuration for exe tests +├── runners/ # Runner-specific tests +│ ├── test_runner_docker.sh # Docker runner tests +│ └── test_runner_docker.yaml # Docker runner configuration +├── common/ # Shared utilities and fixtures +│ ├── common.sh # Common test utilities +│ ├── test_prompt.json # Test prompt fixtures +│ └── test_response.json # Test response fixtures +└── run_tests.sh # Main test runner script +``` + +## Running Tests + +### Run All Tests + +```bash +cd tests +./run_tests.sh +``` + +### Run Specific Test Categories + +```bash +# Agent tests +cd tests/agent +./test_agent.sh + +# Exe command tests +cd tests/exe +./test_exe.sh +./test_exe_empty_file.sh +./test_exe_constraints.sh + +# Runner tests +cd tests/runners +./test_runner_docker.sh +``` + +## Test Categories + +### Agent Tests (`agent/`) + +Tests the interactive agent functionality that uses LLMs to interact with tools. + +- **test_agent.sh**: Tests agent initialization, tool calling, and file creation +- **tools/test_agent.yaml**: Agent test configuration (uses MCPSHELL_TOOLS_DIR) + +**Note**: The agent test uses the `MCPSHELL_TOOLS_DIR` environment variable to specify +the tools directory, demonstrating how MCPShell can load configurations from custom +directories. + +### Exe Tests (`exe/`) + +Tests direct tool execution without the agent (using the `exe` command). + +- **test_exe.sh**: Basic tool execution and file creation +- **test_exe_empty_file.sh**: Tests default content handling for empty files +- **test_exe_constraints.sh**: Tests that constraints are properly enforced + +### Runner Tests (`runners/`) + +Tests different execution environments for tools. + +- **test_runner_docker.sh**: Tests Docker-based tool execution + +### Common Utilities (`common/`) + +Shared utilities and test fixtures used across all tests. + +- **common.sh**: Common functions for test setup, assertions, and utilities +- **test_prompt.json**: Sample prompt data for testing +- **test_response.json**: Sample response data for testing + +## Adding New Tests + +When adding new tests: + +1. **Determine the category**: agent, exe, runners, or create a new category +2. **Create test files in the appropriate subdirectory** +3. **Update `run_tests.sh`** to include the new test in the TEST_FILES array +4. **Use common utilities** by sourcing `../common/common.sh` (or appropriate path) +5. **Follow naming conventions**: `test_.sh` for scripts + +## Test Dependencies + +- All test scripts depend on the built `mcpshell` binary in `../build/mcpshell` +- Agent tests require an LLM endpoint (default: local Ollama at `http://localhost:11434/v1`) +- Docker tests require Docker to be installed and running +- All tests use the shared utilities in `common/common.sh` diff --git a/tests/test_agent.sh b/tests/agent/test_agent.sh similarity index 74% rename from tests/test_agent.sh rename to tests/agent/test_agent.sh index 72c55e5..d8ac92c 100755 --- a/tests/test_agent.sh +++ b/tests/agent/test_agent.sh @@ -3,12 +3,14 @@ # Source common utilities SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" +TESTS_ROOT="$(dirname "$SCRIPT_DIR")" +source "$TESTS_ROOT/common/common.sh" ##################################################################################### # Configuration for this test -CONFIG_FILE="$SCRIPT_DIR/test_agent.yaml" -LOG_FILE="$SCRIPT_DIR/../agent_test_output.log" +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" # LLM configuration @@ -22,7 +24,7 @@ MODEL="${MODEL:-qwen3:14b}" testcase "$TEST_NAME" -info "Testing MCPShell agent with config: $CONFIG_FILE" +info "Testing MCPShell agent with config: $CONFIG_FILE (using MCPSHELL_TOOLS_DIR=$MCPSHELL_TOOLS_DIR)" separator info "1. Checking the URL of the LLM" @@ -49,7 +51,7 @@ 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" --config "$CONFIG_FILE" exe create_test_file filename="$TEST_FILENAME" content="$TEST_CONTENT" 2>&1)] +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" @@ -81,14 +83,22 @@ separator 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." -"$CLI_BIN" --config "$CONFIG_FILE" agent \ +info "Starting agent interaction..." +info "System prompt: $SYSTEM_PROMPT" +info "User prompt: $USER_PROMPT" +info "Model: $MODEL" + +"$CLI_BIN" --tools "$CONFIG_FILE" agent \ --system-prompt "$SYSTEM_PROMPT" \ --user-prompt "$USER_PROMPT" \ --model "$MODEL" \ --once \ --logfile "$LOG_FILE" \ --openai-api-key "$OPENAI_API_KEY" \ - --openai-api-url "$OPENAI_API_BASE" > /dev/null 2>&1 + --openai-api-url "$OPENAI_API_BASE" + +AGENT_RESULT=$? +info "Agent finished with exit code: $AGENT_RESULT" # Wait a moment for file operations to complete sleep 1 @@ -103,12 +113,20 @@ sleep 1 [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$LOG_FILE" >> "$E2E_LOG_FILE" -# Get the name of the file created by the agent from the log -AGENT_FILENAME=$(grep -o "agent_test_output-[0-9]*\.txt" "$LOG_FILE" | head -1) +# 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..." - AGENT_FILENAME=$(grep -o "File.*created" "$LOG_FILE" | grep -o "[a-zA-Z0-9_-]*\.txt" | head -1) + # 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" ] || { diff --git a/tests/test_agent.yaml b/tests/agent/tools/test_agent.yaml similarity index 99% rename from tests/test_agent.yaml rename to tests/agent/tools/test_agent.yaml index 80cd4e7..02a4808 100644 --- a/tests/test_agent.yaml +++ b/tests/agent/tools/test_agent.yaml @@ -1,5 +1,5 @@ prompts: - - system: + system: - "You are a helpful assistant that can create files." - "Please respond directly to the task requested without unnecessary explanations." diff --git a/tests/common.sh b/tests/common/common.sh similarity index 92% rename from tests/common.sh rename to tests/common/common.sh index 9b17477..8a4cc7e 100755 --- a/tests/common.sh +++ b/tests/common/common.sh @@ -3,9 +3,9 @@ ##################################################################################### -# Set script directory for relative paths -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CLI_BIN="$SCRIPT_DIR/../MCPShell" +# Set common script directory for relative paths +COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CLI_BIN="$COMMON_DIR/../../build/mcpshell" # ANSI color codes RED='\033[0;31m' diff --git a/tests/test_prompt.json b/tests/common/test_prompt.json similarity index 100% rename from tests/test_prompt.json rename to tests/common/test_prompt.json diff --git a/tests/test_response.json b/tests/common/test_response.json similarity index 100% rename from tests/test_response.json rename to tests/common/test_response.json diff --git a/tests/test_exe.sh b/tests/exe/test_exe.sh similarity index 86% rename from tests/test_exe.sh rename to tests/exe/test_exe.sh index 162aa83..97b4c72 100755 --- a/tests/test_exe.sh +++ b/tests/exe/test_exe.sh @@ -4,11 +4,12 @@ # Source common utilities SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" +TESTS_ROOT="$(dirname "$SCRIPT_DIR")" +source "$TESTS_ROOT/common/common.sh" ##################################################################################### # Test configuration -CONFIG_FILE="$SCRIPT_DIR/test_exe_config.yaml" +TOOLS_FILE="$SCRIPT_DIR/test_exe_config.yaml" TEST_NAME="test_exe_command" ##################################################################################### @@ -16,7 +17,7 @@ TEST_NAME="test_exe_command" testcase "$TEST_NAME" -info_blue "Configuration file: $CONFIG_FILE" +info_blue "Configuration file: $TOOLS_FILE" # Generate a random test file path TEST_FILE=$(random_tmpfile "mcpshell_test_file") @@ -31,7 +32,7 @@ check_cli_exists # Command to test TEST_CONTENT="This is a test file created by the mcpshell exe command test." -CMD="$CLI_BIN exe -c $CONFIG_FILE create_file filepath=$TEST_FILE content=\"$TEST_CONTENT\"" +CMD="$CLI_BIN exe --tools $TOOLS_FILE create_file filepath=$TEST_FILE content=\"$TEST_CONTENT\"" info "Executing: $CMD" # Run the command diff --git a/tests/test_exe_config.yaml b/tests/exe/test_exe_config.yaml similarity index 100% rename from tests/test_exe_config.yaml rename to tests/exe/test_exe_config.yaml diff --git a/tests/test_exe_constraints.sh b/tests/exe/test_exe_constraints.sh similarity index 86% rename from tests/test_exe_constraints.sh rename to tests/exe/test_exe_constraints.sh index 6fd72d8..a7b66e5 100755 --- a/tests/test_exe_constraints.sh +++ b/tests/exe/test_exe_constraints.sh @@ -4,7 +4,8 @@ # Source common utilities SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" +TESTS_ROOT="$(dirname "$SCRIPT_DIR")" +source "$TESTS_ROOT/common/common.sh" # Test configuration CONFIG_FILE="$SCRIPT_DIR/test_exe_config.yaml" @@ -26,7 +27,7 @@ info "Invalid path (expected to fail): $INVALID_PATH" separator # Command to test with invalid path -CMD="$CLI_BIN exe -c $CONFIG_FILE create_file filepath=$INVALID_PATH" +CMD="$CLI_BIN exe --tools $CONFIG_FILE create_file filepath=$INVALID_PATH" info "Executing: $CMD" OUTPUT=$(eval "$CMD" 2>&1) @@ -40,7 +41,7 @@ success "Command failed as expected. Testing constraint violation for path conta # Test path with shell injection attempt INJECTION_PATH="/tmp/test;rm -rf /" -CMD="$CLI_BIN exe -c $CONFIG_FILE create_file filepath=\"$INJECTION_PATH\"" +CMD="$CLI_BIN exe --tools $CONFIG_FILE create_file filepath=\"$INJECTION_PATH\"" info "Executing: $CMD" OUTPUT=$(eval "$CMD" 2>&1) diff --git a/tests/test_exe_empty_file.sh b/tests/exe/test_exe_empty_file.sh similarity index 91% rename from tests/test_exe_empty_file.sh rename to tests/exe/test_exe_empty_file.sh index 62f79d2..98f74e9 100755 --- a/tests/test_exe_empty_file.sh +++ b/tests/exe/test_exe_empty_file.sh @@ -4,7 +4,8 @@ # Source common utilities SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" +TESTS_ROOT="$(dirname "$SCRIPT_DIR")" +source "$TESTS_ROOT/common/common.sh" # Test configuration CONFIG_FILE="$SCRIPT_DIR/test_exe_config.yaml" @@ -26,7 +27,7 @@ separator check_cli_exists # Command to test -CMD="$CLI_BIN exe -c $CONFIG_FILE create_file filepath=$TEST_FILE" +CMD="$CLI_BIN exe --tools $CONFIG_FILE create_file filepath=$TEST_FILE" OUTPUT=$(eval "$CMD" 2>&1) RESULT=$? [ -n "$E2E_LOG_FILE" ] && echo -e "\n$TEST_NAME:\n\n$OUTPUT" >> "$E2E_LOG_FILE" diff --git a/tests/run_tests.sh b/tests/run_tests.sh index f30cb95..e8be42e 100755 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -2,15 +2,15 @@ # Source common utilities SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" +source "$SCRIPT_DIR/common/common.sh" -# Test files to run +# Test files to run (now in subdirectories) TEST_FILES=( - "test_agent.sh" - "test_exe.sh" - "test_exe_empty_file.sh" - "test_exe_constraints.sh" - "test_runner_docker.sh" + "agent/test_agent.sh" + "exe/test_exe.sh" + "exe/test_exe_empty_file.sh" + "exe/test_exe_constraints.sh" + "runners/test_runner_docker.sh" ) echo "===================================" @@ -18,7 +18,7 @@ echo "MCPShell E2E Tests" echo "===================================" # Make test scripts executable -chmod +x "$SCRIPT_DIR"/*.sh +find "$SCRIPT_DIR" -name "*.sh" -exec chmod +x {} \; # Track overall test status PASSED=0 diff --git a/tests/test_runner_docker.sh b/tests/runners/test_runner_docker.sh similarity index 88% rename from tests/test_runner_docker.sh rename to tests/runners/test_runner_docker.sh index d07100f..9df35be 100755 --- a/tests/test_runner_docker.sh +++ b/tests/runners/test_runner_docker.sh @@ -2,7 +2,8 @@ # Source common utilities SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -source "$SCRIPT_DIR/common.sh" +TESTS_ROOT="$(dirname "$SCRIPT_DIR")" +source "$TESTS_ROOT/common/common.sh" # Configuration file for this test CONFIG_FILE="$SCRIPT_DIR/test_runner_docker.yaml" @@ -33,7 +34,7 @@ separator info "2. Simple hello world in Docker container" separator -CMD="$CLI_BIN --config $CONFIG_FILE exe docker_hello" +CMD="$CLI_BIN --tools $CONFIG_FILE exe docker_hello" info "Executing: $CMD" OUTPUT=$(eval "$CMD" 2>&1) RESULT=$? @@ -47,7 +48,7 @@ separator info "3. Environment variable passing" separator -CMD="$CLI_BIN --config $CONFIG_FILE exe docker_with_env message=\"Hello from Docker container\"" +CMD="$CLI_BIN --tools $CONFIG_FILE exe docker_with_env message=\"Hello from Docker container\"" info "Executing: $CMD" OUTPUT=$(eval "$CMD" 2>&1) RESULT=$? @@ -62,7 +63,7 @@ separator info "4. Prepare command functionality" separator -CMD="$CLI_BIN --config $CONFIG_FILE exe docker_with_prepare" +CMD="$CLI_BIN --tools $CONFIG_FILE exe docker_with_prepare" info "Executing: $CMD" OUTPUT=$(eval "$CMD" 2>&1) RESULT=$? diff --git a/tests/test_runner_docker.yaml b/tests/runners/test_runner_docker.yaml similarity index 100% rename from tests/test_runner_docker.yaml rename to tests/runners/test_runner_docker.yaml