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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ mcphost
.idea
test/
build/
scripts/
dist/
contribute/output/
CONTEXT.md
55 changes: 54 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ Currently supports:

- Interactive conversations with support models
- **Non-interactive mode** for scripting and automation
- **Script mode** for executable YAML-based automation scripts
- Support for multiple concurrent MCP servers
- **Tool filtering** with `allowedTools` and `excludedTools` per server
- Dynamic tool discovery and integration
- Tool calling capabilities for both model types
- Configurable MCP server locations and arguments
Expand Down Expand Up @@ -99,7 +101,9 @@ The configuration for an STDIO MCP-server should be defined as the following:
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
],
"allowedTools": ["read_file", "write_file"],
"excludedTools": ["delete_file"]
}
}
}
Expand All @@ -110,6 +114,10 @@ Each STDIO entry requires:
- `args`: Array of arguments for the command:
- For SQLite server: `mcp-server-sqlite` with database path
- For filesystem server: `@modelcontextprotocol/server-filesystem` with directory path
- `allowedTools`: (Optional) Array of tool names to include (whitelist)
- `excludedTools`: (Optional) Array of tool names to exclude (blacklist)

**Note**: `allowedTools` and `excludedTools` are mutually exclusive - you can only use one per server.

### Server Side Events (SSE)

Expand Down Expand Up @@ -159,6 +167,50 @@ Start an interactive conversation session:
mcphost
```

### Script Mode

Run executable YAML-based automation scripts:

```bash
# Using the flag
mcphost --script myscript.sh

# Direct execution (if executable and has shebang)
./myscript.sh
```

#### Script Format

Scripts combine YAML configuration with prompts in a single executable file:

```yaml
#!/usr/local/bin/mcphost --script
# This script uses the container-use MCP server from https://github.com/dagger/container-use
mcpServers:
container-use:
command: cu
args:
- "stdio"
prompt: |
Create 2 variations of a simple hello world app using Flask and FastAPI.
Each in their own environment. Give me the URL of each app
```

#### Script Features

- **Executable**: Use shebang line for direct execution
- **YAML Configuration**: Define MCP servers directly in the script
- **Embedded Prompts**: Include the prompt in the YAML
- **Config Fallback**: If no `mcpServers` defined, uses default config
- **Tool Filtering**: Supports `allowedTools`/`excludedTools` per server
- **Clean Exit**: Automatically exits after completion

#### Script Examples

See `examples/scripts/` for sample scripts:
- `example-script.sh` - Script with custom MCP servers
- `simple-script.sh` - Script using default config fallback

### Non-Interactive Mode

Run a single prompt and exit - perfect for scripting and automation:
Expand Down Expand Up @@ -226,6 +278,7 @@ mcphost -p "Generate a random UUID" --quiet | tr '[:lower:]' '[:upper:]'
- `--google-api-key string`: Google API key (can also be set via GOOGLE_API_KEY environment variable)
- `-p, --prompt string`: **Run in non-interactive mode with the given prompt**
- `--quiet`: **Suppress all output except the AI response (only works with --prompt)**
- `--script`: **Run in script mode (parse YAML frontmatter and prompt from file)**


### Interactive Commands
Expand Down
203 changes: 199 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bufio"
"context"
"fmt"
"io"
Expand All @@ -14,6 +15,7 @@ import (
"github.com/mark3labs/mcphost/internal/models"
"github.com/mark3labs/mcphost/internal/ui"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

var (
Expand All @@ -29,6 +31,8 @@ var (
debugMode bool
promptFlag string
quietFlag bool
scriptFlag bool
scriptMCPConfig *config.Config // Used to override config in script mode
)

var rootCmd = &cobra.Command{
Expand All @@ -52,7 +56,11 @@ Examples:

# Non-interactive mode
mcphost -p "What is the weather like today?"
mcphost -p "Calculate 15 * 23" --quiet`,
mcphost -p "Calculate 15 * 23" --quiet

# Script mode
mcphost --script myscript.sh
./myscript.sh # if script has shebang #!/path/to/mcphost --script`,
RunE: func(cmd *cobra.Command, args []string) error {
return runMCPHost(context.Background())
},
Expand Down Expand Up @@ -81,6 +89,8 @@ func init() {
StringVarP(&promptFlag, "prompt", "p", "", "run in non-interactive mode with the given prompt")
rootCmd.PersistentFlags().
BoolVar(&quietFlag, "quiet", false, "suppress all output (only works with --prompt)")
rootCmd.PersistentFlags().
BoolVar(&scriptFlag, "script", false, "run in script mode (parse YAML frontmatter and prompt from file)")

flags := rootCmd.PersistentFlags()
flags.StringVar(&openaiBaseURL, "openai-url", "", "base URL for OpenAI API")
Expand All @@ -91,6 +101,15 @@ func init() {
}

func runMCPHost(ctx context.Context) error {
// Handle script mode
if scriptFlag {
return runScriptMode(ctx)
}

return runNormalMode(ctx)
}

func runNormalMode(ctx context.Context) error {
// Validate flag combinations
if quietFlag && promptFlag == "" {
return fmt.Errorf("--quiet flag can only be used with --prompt/-p")
Expand All @@ -102,9 +121,18 @@ func runMCPHost(ctx context.Context) error {
}

// Load configuration
mcpConfig, err := config.LoadMCPConfig(configFile)
if err != nil {
return fmt.Errorf("failed to load MCP config: %v", err)
var mcpConfig *config.Config
var err error

if scriptMCPConfig != nil {
// Use script-provided config
mcpConfig = scriptMCPConfig
} else {
// Load normal config
mcpConfig, err = config.LoadMCPConfig(configFile)
if err != nil {
return fmt.Errorf("failed to load MCP config: %v", err)
}
}

systemPrompt, err := config.LoadSystemPrompt(systemPromptFile)
Expand Down Expand Up @@ -390,4 +418,171 @@ func runInteractiveMode(ctx context.Context, mcpAgent *agent.Agent, cli *ui.CLI,
// Add assistant response to history
messages = append(messages, response)
}
}

// ScriptConfig represents the YAML frontmatter in a script file
type ScriptConfig struct {
MCPServers map[string]config.MCPServerConfig `yaml:"mcpServers"`
Prompt string `yaml:"prompt"`
}

// runScriptMode handles script mode execution
func runScriptMode(ctx context.Context) error {
var scriptFile string

// Determine script file from arguments
// When called via shebang, the script file is the first non-flag argument
// When called with --script flag, we need to find the script file in args
args := os.Args[1:]

// Filter out flags to find the script file
for _, arg := range args {
if arg == "--script" {
// Skip the --script flag itself
continue
}
if strings.HasPrefix(arg, "-") {
// Skip other flags
continue
}
// This should be our script file
scriptFile = arg
break
}

if scriptFile == "" {
return fmt.Errorf("script mode requires a script file argument")
}

// Parse the script file
scriptConfig, prompt, err := parseScriptFile(scriptFile)
if err != nil {
return fmt.Errorf("failed to parse script file: %v", err)
}

// Override the global configFile and promptFlag with script values
originalConfigFile := configFile
originalPromptFlag := promptFlag

// Create config from script or load normal config
var mcpConfig *config.Config
if len(scriptConfig.MCPServers) > 0 {
// Use servers from script
mcpConfig = &config.Config{
MCPServers: scriptConfig.MCPServers,
}
} else {
// Fall back to normal config loading
mcpConfig, err = config.LoadMCPConfig(configFile)
if err != nil {
return fmt.Errorf("failed to load MCP config: %v", err)
}
}

// Override the global config for normal mode
scriptMCPConfig = mcpConfig

// Set the prompt from script
promptFlag = prompt

// Restore original values after execution
defer func() {
configFile = originalConfigFile
promptFlag = originalPromptFlag
scriptMCPConfig = nil
}()

// Now run the normal execution path which will use our overridden config
return runNormalMode(ctx)
}

// parseScriptFile parses a script file with YAML frontmatter and prompt
func parseScriptFile(filename string) (*ScriptConfig, string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, "", err
}
defer file.Close()

scanner := bufio.NewScanner(file)

// Skip shebang line if present
if scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "#!") {
// If it's not a shebang, we need to process this line
return parseScriptContent(line + "\n" + readRemainingLines(scanner))
}
}

// Read the rest of the file
content := readRemainingLines(scanner)
return parseScriptContent(content)
}

// readRemainingLines reads all remaining lines from a scanner
func readRemainingLines(scanner *bufio.Scanner) string {
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return strings.Join(lines, "\n")
}

// parseScriptContent parses the content to extract YAML frontmatter and prompt
func parseScriptContent(content string) (*ScriptConfig, string, error) {
lines := strings.Split(content, "\n")

// Find YAML frontmatter and prompt
var yamlLines []string
var promptLines []string
var inPrompt bool

for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "prompt:") {
inPrompt = true
// Extract the prompt value if it's on the same line
if len(trimmed) > 7 {
promptValue := strings.TrimSpace(trimmed[7:])
if promptValue != "" {
promptLines = append(promptLines, promptValue)
}
}
continue
}

if inPrompt {
// Continue collecting prompt lines (handle multi-line YAML strings)
if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
promptLines = append(promptLines, strings.TrimPrefix(strings.TrimPrefix(line, " "), "\t"))
} else if trimmed != "" && !strings.Contains(trimmed, ":") {
promptLines = append(promptLines, line)
} else if trimmed != "" {
// New YAML key, stop collecting prompt
inPrompt = false
yamlLines = append(yamlLines, line)
}
} else {
yamlLines = append(yamlLines, line)
}
}

// Parse YAML
yamlContent := strings.Join(yamlLines, "\n")
var scriptConfig ScriptConfig
if err := yaml.Unmarshal([]byte(yamlContent), &scriptConfig); err != nil {
return nil, "", fmt.Errorf("failed to parse YAML: %v", err)
}

// Join prompt lines
prompt := strings.Join(promptLines, "\n")
prompt = strings.TrimSpace(prompt)

// If prompt wasn't found in YAML, use the scriptConfig.Prompt
if prompt == "" {
prompt = scriptConfig.Prompt
}

return &scriptConfig, prompt, nil
}
9 changes: 9 additions & 0 deletions examples/scripts/example-script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/usr/local/bin/mcphost --script
# This script uses the container-use MCP server from https://github.com/dagger/container-use
mcpServers:
container-use:
command: cu
args:
- "stdio"
prompt: |
Create 2 variations of a simple hello world app using Flask and FastAPI. each in their own environment. Give me the URL of each app
5 changes: 5 additions & 0 deletions examples/scripts/simple-script.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/usr/local/bin/mcphost --script
prompt: |
Hello! This is a simple script that uses the default MCP configuration.
What's 2 + 2?
What tools do you have?
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/spf13/viper v1.20.1
golang.org/x/term v0.31.0
google.golang.org/api v0.228.0
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down Expand Up @@ -121,7 +122,6 @@ require (
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

require (
Expand Down
Loading