Files
kit/cmd/script.go
T
Ed Zynda cdc4abfb36 Add comprehensive hooks system for MCPHost lifecycle events (#111)
* Add comprehensive hooks system for MCPHost lifecycle events

Implements a flexible hooks system based on Anthropic Claude Code specification:

- **Hook Events**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
- **Hook Types**: Command execution with JSON input/output
- **Configuration**: XDG-compliant with layered config support
- **Security**: Command validation, timeout controls, safe execution
- **Common Fields**: Consistent session ID, timestamps, model info across all hooks

Key features:
- Hooks receive JSON via stdin and can control flow via stdout
- Pattern matching for tool-specific hooks (regex support)
- Enhanced Stop hook with agent response and metadata
- Centralized session management with consistent IDs
- Built-in examples for logging, validation, and monitoring

This enables users to:
- Log and audit all tool usage and prompts
- Implement custom security policies
- Track usage metrics and model performance
- Integrate with external systems
- Build custom workflows around MCPHost

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

* Enable hooks in script mode

Previously, hooks were only initialized and executed in normal mode but not
in script mode. This was because script mode had its own execution path that
bypassed the hook initialization code.

This fix:
- Adds hook initialization to runScriptMode function
- Creates hook executor with proper session ID and model info
- Passes the hook executor to runAgenticLoop

Now hooks work consistently across all execution modes (normal, script, and
interactive), ensuring uniform behavior for logging, validation, and monitoring.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

* Remove unnecessary hooks.local.yml pattern

The .local.yml pattern adds unnecessary complexity. Users who want project-specific
hooks that aren't committed to git can simply add .mcphost/ to their .gitignore.

This simplifies the hooks configuration loading and makes it clearer that:
- Global user hooks go in ~/.config/mcphost/hooks.yml
- Project-specific hooks go in .mcphost/hooks.yml
- Git ignore management is left to the user

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

* Fix hooks test isolation and add --no-hooks flag

- Fix TestLoadHooksConfig by setting temporary XDG_CONFIG_HOME to prevent loading global hooks
- Add --no-hooks flag to disable all hooks execution across all modes
- Update README with documentation for the new flag
- Add test to verify hooks loading behavior

This allows users to temporarily disable hooks for security or debugging purposes.

🤖 Generated with [opencode](https://opencode.ai)

Co-Authored-By: opencode <noreply@opencode.ai>

---------

Co-authored-by: opencode <noreply@opencode.ai>
2025-07-24 13:56:33 +03:00

701 lines
22 KiB
Go

package cmd
import (
"bufio"
"context"
"fmt"
"log"
"os"
"regexp"
"strings"
"time"
"github.com/cloudwego/eino/schema"
"github.com/mark3labs/mcphost/internal/agent"
"github.com/mark3labs/mcphost/internal/config"
"github.com/mark3labs/mcphost/internal/hooks"
"github.com/mark3labs/mcphost/internal/models"
"github.com/mark3labs/mcphost/internal/ui"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var scriptCmd = &cobra.Command{
Use: "script <script-file>",
Short: "Execute a script file with YAML frontmatter configuration",
Long: `Execute a script file that contains YAML frontmatter with configuration
and a prompt. The script file can contain MCP server configurations,
model settings, and other options.
Example script file:
---
model: "anthropic:claude-sonnet-4-20250514"
max-steps: 10
mcpServers:
filesystem:
type: "local"
command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "${directory:-/tmp}"]
---
Hello ${name:-World}! List the files in ${directory:-/tmp} and tell me about them.
The script command supports the same flags as the main command,
which will override any settings in the script file.
Variable substitution:
Variables in the script can be substituted using ${variable} syntax.
Variables can have default values using ${variable:-default} syntax.
Pass variables using --args:variable value syntax:
mcphost script myscript.sh --args:directory /tmp --args:name "John"
This will replace ${directory} with "/tmp" and ${name} with "John" in the script.
Variables with defaults (${var:-default}) are optional and use the default if not provided.`,
Args: cobra.ExactArgs(1),
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true, // Allow unknown flags for variable substitution
},
PreRun: func(cmd *cobra.Command, args []string) {
// Override config with frontmatter values from the script file
scriptFile := args[0]
variables := parseCustomVariables(cmd)
overrideConfigWithFrontmatter(scriptFile, variables, cmd)
},
RunE: func(cmd *cobra.Command, args []string) error {
scriptFile := args[0]
// Parse custom variables from unknown flags
variables := parseCustomVariables(cmd)
return runScriptCommand(context.Background(), scriptFile, variables, cmd)
},
}
func init() {
rootCmd.AddCommand(scriptCmd)
}
// overrideConfigWithFrontmatter parses the script file and overrides viper config with frontmatter values
// This is the only purpose of this function - to apply frontmatter configuration to viper
func overrideConfigWithFrontmatter(scriptFile string, variables map[string]string, cmd *cobra.Command) {
// Parse the script file to get frontmatter configuration
scriptConfig, err := parseScriptFile(scriptFile, variables)
if err != nil {
// If we can't parse the script file, just continue with existing config
// The error will be handled again in runScriptCommand
return
}
// Override viper values with frontmatter values (only if flags weren't explicitly set)
// Check both local flags and persistent flags since script inherits from root
flagChanged := func(name string) bool {
return cmd.Flags().Changed(name) || rootCmd.PersistentFlags().Changed(name)
}
if scriptConfig.Model != "" && !flagChanged("model") {
viper.Set("model", scriptConfig.Model)
}
if scriptConfig.MaxSteps != 0 && !flagChanged("max-steps") {
viper.Set("max-steps", scriptConfig.MaxSteps)
}
if scriptConfig.Debug && !flagChanged("debug") {
viper.Set("debug", scriptConfig.Debug)
}
if scriptConfig.Compact && !flagChanged("compact") {
viper.Set("compact", scriptConfig.Compact)
}
if scriptConfig.SystemPrompt != "" && !flagChanged("system-prompt") {
viper.Set("system-prompt", scriptConfig.SystemPrompt)
}
if scriptConfig.ProviderAPIKey != "" && !flagChanged("provider-api-key") {
viper.Set("provider-api-key", scriptConfig.ProviderAPIKey)
}
if scriptConfig.ProviderURL != "" && !flagChanged("provider-url") {
viper.Set("provider-url", scriptConfig.ProviderURL)
}
if scriptConfig.MaxTokens != 0 && !flagChanged("max-tokens") {
viper.Set("max-tokens", scriptConfig.MaxTokens)
}
if scriptConfig.Temperature != nil && !flagChanged("temperature") {
viper.Set("temperature", *scriptConfig.Temperature)
}
if scriptConfig.TopP != nil && !flagChanged("top-p") {
viper.Set("top-p", *scriptConfig.TopP)
}
if scriptConfig.TopK != nil && !flagChanged("top-k") {
viper.Set("top-k", *scriptConfig.TopK)
}
if len(scriptConfig.StopSequences) > 0 && !flagChanged("stop-sequences") {
viper.Set("stop-sequences", scriptConfig.StopSequences)
}
if scriptConfig.NoExit && !flagChanged("no-exit") {
// Set the global noExitFlag variable if it wasn't explicitly set via command line
noExitFlag = scriptConfig.NoExit
}
if scriptConfig.Stream != nil && !flagChanged("stream") {
viper.Set("stream", *scriptConfig.Stream)
}
}
// parseCustomVariables extracts custom variables from command line arguments
func parseCustomVariables(_ *cobra.Command) map[string]string {
variables := make(map[string]string)
// Get all arguments passed to the command
args := os.Args[1:] // Skip program name
// Find the script subcommand position
scriptPos := -1
for i, arg := range args {
if arg == "script" {
scriptPos = i
break
}
}
if scriptPos == -1 {
return variables
}
// Parse arguments after the script file
scriptFileFound := false
for i := scriptPos + 1; i < len(args); i++ {
arg := args[i]
// Skip the script file argument (first non-flag after "script")
if !scriptFileFound && !strings.HasPrefix(arg, "-") {
scriptFileFound = true
continue
}
// Parse custom variables with --args: prefix
if strings.HasPrefix(arg, "--args:") {
varName := strings.TrimPrefix(arg, "--args:")
if varName == "" {
continue // Skip malformed --args: without name
}
// Check if we have a value
if i+1 < len(args) {
varValue := args[i+1]
// Make sure the next arg isn't a flag
if !strings.HasPrefix(varValue, "-") {
variables[varName] = varValue
i++ // Skip the value
} else {
// No value provided, treat as empty string
variables[varName] = ""
}
} else {
// No value provided, treat as empty string
variables[varName] = ""
}
}
}
return variables
}
func runScriptCommand(ctx context.Context, scriptFile string, variables map[string]string, _ *cobra.Command) error {
// Parse the script file to get MCP servers and prompt
scriptConfig, err := parseScriptFile(scriptFile, variables)
if err != nil {
return fmt.Errorf("failed to parse script file: %v", err)
}
// Get MCP config - use script servers if available, otherwise use global viper config
var mcpConfig *config.Config
if len(scriptConfig.MCPServers) > 0 {
// Load base config and merge with script config
baseConfig, err := config.LoadAndValidateConfig()
if err != nil {
return fmt.Errorf("failed to load base config: %v", err)
}
mcpConfig = config.MergeConfigs(baseConfig, scriptConfig)
} else {
// Use the new config loader
var err error
mcpConfig, err = config.LoadAndValidateConfig()
if err != nil {
return fmt.Errorf("failed to load MCP config: %v", err)
}
}
// Get final prompt - prioritize command line flag, then script content
finalPrompt := viper.GetString("prompt")
if finalPrompt == "" && scriptConfig.Prompt != "" {
finalPrompt = scriptConfig.Prompt
}
// Get final no-exit setting - prioritize command line flag, then script config
finalNoExit := noExitFlag || scriptConfig.NoExit
// Validate that --no-exit is only used when there's a prompt
if finalNoExit && finalPrompt == "" {
return fmt.Errorf("--no-exit flag can only be used when there's a prompt (either from script content or --prompt flag)")
}
// Run the script using the unified agentic loop
return runScriptMode(ctx, mcpConfig, finalPrompt, finalNoExit)
}
// mergeScriptConfig and setScriptValuesInViper functions removed
// Configuration override is now handled in overrideConfigWithFrontmatter in the PreRun hook
// parseScriptFile parses a script file with YAML frontmatter and returns config
func parseScriptFile(filename string, variables map[string]string) (*config.Config, 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), variables)
}
}
// Read the rest of the file
content := readRemainingLines(scanner)
return parseScriptContent(content, variables)
}
// 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, variables map[string]string) (*config.Config, error) {
// STEP 1: Apply environment variable substitution FIRST
envSubstituter := &config.EnvSubstituter{}
processedContent, err := envSubstituter.SubstituteEnvVars(content)
if err != nil {
return nil, fmt.Errorf("script env substitution failed: %v", err)
}
// STEP 2: Validate that all declared script variables are provided
if err := validateVariables(processedContent, variables); err != nil {
return nil, err
}
// STEP 3: Apply script args substitution
argsSubstituter := config.NewArgsSubstituter(variables)
content, err = argsSubstituter.SubstituteArgs(processedContent)
if err != nil {
return nil, fmt.Errorf("script args substitution failed: %v", err)
}
lines := strings.Split(content, "\n")
// Find YAML frontmatter between --- delimiters
var yamlLines []string
var promptLines []string
var inFrontmatter bool
var foundFrontmatter bool
var frontmatterEnd int = -1
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip comment lines (lines starting with #)
if strings.HasPrefix(trimmed, "#") {
continue
}
// Check for frontmatter start
if trimmed == "---" && !inFrontmatter {
// Start of frontmatter
inFrontmatter = true
foundFrontmatter = true
continue
}
// Check for frontmatter end
if trimmed == "---" && inFrontmatter {
// End of frontmatter
inFrontmatter = false
frontmatterEnd = i + 1
continue
}
// Collect frontmatter lines
if inFrontmatter {
yamlLines = append(yamlLines, line)
}
}
// Extract prompt (everything after frontmatter)
if foundFrontmatter && frontmatterEnd != -1 && frontmatterEnd < len(lines) {
promptLines = lines[frontmatterEnd:]
} else if !foundFrontmatter {
// If no frontmatter found, treat entire content as prompt
promptLines = lines
yamlLines = []string{} // Empty YAML
}
// Parse YAML frontmatter using Viper for consistency with config file parsing
var scriptConfig config.Config
if len(yamlLines) > 0 {
yamlContent := strings.Join(yamlLines, "\n")
// Create temporary viper instance for frontmatter parsing
frontmatterViper := viper.New()
frontmatterViper.SetConfigType("yaml")
if err := frontmatterViper.ReadConfig(strings.NewReader(yamlContent)); err != nil {
return nil, fmt.Errorf("failed to parse YAML frontmatter: %v\nYAML content:\n%s", err, yamlContent)
}
if err := frontmatterViper.Unmarshal(&scriptConfig); err != nil {
return nil, fmt.Errorf("failed to unmarshal frontmatter config: %v", err)
}
// Manually extract hyphenated keys that Viper might not handle correctly during unmarshal
if providerURL := frontmatterViper.GetString("provider-url"); providerURL != "" {
scriptConfig.ProviderURL = providerURL
}
if providerAPIKey := frontmatterViper.GetString("provider-api-key"); providerAPIKey != "" {
scriptConfig.ProviderAPIKey = providerAPIKey
}
if systemPrompt := frontmatterViper.GetString("system-prompt"); systemPrompt != "" {
scriptConfig.SystemPrompt = systemPrompt
}
if maxSteps := frontmatterViper.GetInt("max-steps"); maxSteps != 0 {
scriptConfig.MaxSteps = maxSteps
}
if maxTokens := frontmatterViper.GetInt("max-tokens"); maxTokens != 0 {
scriptConfig.MaxTokens = maxTokens
}
if topP := frontmatterViper.GetFloat64("top-p"); topP != 0 {
topPFloat32 := float32(topP)
scriptConfig.TopP = &topPFloat32
}
if topK := frontmatterViper.GetInt("top-k"); topK != 0 {
topKInt32 := int32(topK)
scriptConfig.TopK = &topKInt32
}
if stopSequences := frontmatterViper.GetStringSlice("stop-sequences"); len(stopSequences) > 0 {
scriptConfig.StopSequences = stopSequences
}
if noExit := frontmatterViper.GetBool("no-exit"); noExit {
scriptConfig.NoExit = noExit
}
}
// Set prompt from content after frontmatter
if len(promptLines) > 0 {
prompt := strings.Join(promptLines, "\n")
prompt = strings.TrimSpace(prompt) // Remove leading/trailing whitespace
if prompt != "" {
scriptConfig.Prompt = prompt
}
}
return &scriptConfig, nil
}
// Variable represents a script variable with optional default value
type Variable struct {
Name string
DefaultValue string
HasDefault bool
}
// findVariables extracts all unique variable names from ${variable} patterns in content
// Maintains backward compatibility by returning just variable names
func findVariables(content string) []string {
variables := findVariablesWithDefaults(content)
var names []string
for _, v := range variables {
names = append(names, v.Name)
}
return names
}
// findVariablesWithDefaults extracts all unique variables with their default values
// Supports both ${variable} and ${variable:-default} syntax
func findVariablesWithDefaults(content string) []Variable {
// Pattern matches:
// ${varname} - simple variable
// ${varname:-default} - variable with default value
re := regexp.MustCompile(`\$\{([^}:]+)(?::-([^}]*))?\}`)
matches := re.FindAllStringSubmatch(content, -1)
seenVars := make(map[string]bool)
var variables []Variable
for _, match := range matches {
if len(match) >= 2 {
varName := match[1]
if !seenVars[varName] {
seenVars[varName] = true
// Check if the original match contains the :- pattern
hasDefault := strings.Contains(match[0], ":-")
variable := Variable{
Name: varName,
HasDefault: hasDefault,
}
if hasDefault && len(match) >= 3 {
variable.DefaultValue = match[2] // Can be empty string
}
variables = append(variables, variable)
}
}
}
return variables
}
// validateVariables checks that all declared variables in the content are provided
// Variables with default values are not required
func validateVariables(content string, variables map[string]string) error {
declaredVars := findVariablesWithDefaults(content)
var missingVars []string
for _, variable := range declaredVars {
if _, exists := variables[variable.Name]; !exists && !variable.HasDefault {
missingVars = append(missingVars, variable.Name)
}
}
if len(missingVars) > 0 {
return fmt.Errorf("missing required variables: %s\nProvide them using --args:variable value syntax", strings.Join(missingVars, ", "))
}
return nil
}
// substituteVariables replaces ${variable} and ${variable:-default} patterns with their values
// This function is kept for backward compatibility but now uses the shared ArgsSubstituter
func substituteVariables(content string, variables map[string]string) string {
substituter := config.NewArgsSubstituter(variables)
result, err := substituter.SubstituteArgs(content)
if err != nil {
// For backward compatibility, if there's an error, return the original content
// This maintains the existing behavior where missing variables were left as-is
return content
}
return result
}
// runScriptMode executes the script using the unified agentic loop
func runScriptMode(ctx context.Context, mcpConfig *config.Config, prompt string, noExit bool) error {
// Set up logging
if debugMode || mcpConfig.Debug {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Get final values from viper and script config
finalModel := viper.GetString("model")
if finalModel == "" && mcpConfig.Model != "" {
finalModel = mcpConfig.Model
}
if finalModel == "" {
finalModel = "anthropic:claude-sonnet-4-20250514" // default
}
finalSystemPrompt := viper.GetString("system-prompt")
if finalSystemPrompt == "" && mcpConfig.SystemPrompt != "" {
finalSystemPrompt = mcpConfig.SystemPrompt
}
finalDebug := viper.GetBool("debug") || mcpConfig.Debug
finalCompact := viper.GetBool("compact")
finalMaxSteps := viper.GetInt("max-steps")
if finalMaxSteps == 0 && mcpConfig.MaxSteps != 0 {
finalMaxSteps = mcpConfig.MaxSteps
}
finalProviderURL := viper.GetString("provider-url")
if finalProviderURL == "" && mcpConfig.ProviderURL != "" {
finalProviderURL = mcpConfig.ProviderURL
}
finalProviderAPIKey := viper.GetString("provider-api-key")
if finalProviderAPIKey == "" && mcpConfig.ProviderAPIKey != "" {
finalProviderAPIKey = mcpConfig.ProviderAPIKey
}
finalMaxTokens := viper.GetInt("max-tokens")
if finalMaxTokens == 0 && mcpConfig.MaxTokens != 0 {
finalMaxTokens = mcpConfig.MaxTokens
}
if finalMaxTokens == 0 {
finalMaxTokens = 4096 // default
}
finalTemperature := float32(viper.GetFloat64("temperature"))
if finalTemperature == 0 && mcpConfig.Temperature != nil {
finalTemperature = *mcpConfig.Temperature
}
if finalTemperature == 0 {
finalTemperature = 0.7 // default
}
finalTopP := float32(viper.GetFloat64("top-p"))
if finalTopP == 0 && mcpConfig.TopP != nil {
finalTopP = *mcpConfig.TopP
}
if finalTopP == 0 {
finalTopP = 0.95 // default
}
finalTopK := int32(viper.GetInt("top-k"))
if finalTopK == 0 && mcpConfig.TopK != nil {
finalTopK = *mcpConfig.TopK
}
if finalTopK == 0 {
finalTopK = 40 // default
}
finalStopSequences := viper.GetStringSlice("stop-sequences")
if len(finalStopSequences) == 0 && len(mcpConfig.StopSequences) > 0 {
finalStopSequences = mcpConfig.StopSequences
}
// Load system prompt
systemPrompt, err := config.LoadSystemPrompt(finalSystemPrompt)
if err != nil {
return fmt.Errorf("failed to load system prompt: %v", err)
}
// Create model configuration
modelConfig := &models.ProviderConfig{
ModelString: finalModel,
SystemPrompt: systemPrompt,
ProviderAPIKey: finalProviderAPIKey,
ProviderURL: finalProviderURL,
MaxTokens: finalMaxTokens,
Temperature: &finalTemperature,
TopP: &finalTopP,
TopK: &finalTopK,
StopSequences: finalStopSequences,
}
// Create the agent using the factory (scripts don't need spinners)
mcpAgent, err := agent.CreateAgent(ctx, &agent.AgentCreationOptions{
ModelConfig: modelConfig,
MCPConfig: mcpConfig,
SystemPrompt: systemPrompt,
MaxSteps: finalMaxSteps,
StreamingEnabled: viper.GetBool("stream"),
ShowSpinner: false, // Scripts don't need spinners
Quiet: quietFlag,
SpinnerFunc: nil, // No spinner function needed
})
if err != nil {
return fmt.Errorf("failed to create agent: %v", err)
}
defer mcpAgent.Close()
// Get model name for display
parts := strings.SplitN(finalModel, ":", 2)
modelName := "Unknown"
if len(parts) == 2 {
modelName = parts[1]
}
// Create an adapter for the agent to match the UI interface
agentAdapter := &agentUIAdapter{agent: mcpAgent}
// Create CLI interface using the factory
cli, err := ui.SetupCLI(&ui.CLISetupOptions{
Agent: agentAdapter,
ModelString: finalModel,
Debug: finalDebug,
Compact: finalCompact,
Quiet: quietFlag,
ShowDebug: false, // Will be handled separately below
ProviderAPIKey: finalProviderAPIKey,
})
if err != nil {
return fmt.Errorf("failed to setup CLI: %v", err)
}
// Display debug configuration if debug mode is enabled
if !quietFlag && cli != nil && finalDebug {
debugConfig := map[string]any{
"model": finalModel,
"max-steps": finalMaxSteps,
"max-tokens": finalMaxTokens,
"temperature": finalTemperature,
"top-p": finalTopP,
"top-k": finalTopK,
"provider-url": finalProviderURL,
"system-prompt": finalSystemPrompt,
}
// Only include non-empty stop sequences
if len(finalStopSequences) > 0 {
debugConfig["stop-sequences"] = finalStopSequences
}
// Only include API keys if they're set (but don't show the actual values for security)
if finalProviderAPIKey != "" {
debugConfig["provider-api-key"] = "[SET]"
}
cli.DisplayDebugConfig(debugConfig)
}
// Initialize hooks
var hookExecutor *hooks.Executor
if hooksConfig := viper.Get("hooks"); hooksConfig != nil {
if hc, ok := hooksConfig.(*hooks.HookConfig); ok {
// Generate a session ID for this run
sessionID := fmt.Sprintf("mcphost-%d", time.Now().Unix())
transcriptPath := "" // We could add transcript logging later
hookExecutor = hooks.NewExecutor(hc, sessionID, transcriptPath)
// Set model and interactive mode
hookExecutor.SetModel(finalModel)
hookExecutor.SetInteractive(prompt == "")
}
}
// Prepare data for slash commands
var serverNames []string
for name := range mcpConfig.MCPServers {
serverNames = append(serverNames, name)
}
tools := mcpAgent.GetTools()
var toolNames []string
for _, tool := range tools {
if info, err := tool.Info(ctx); err == nil {
toolNames = append(toolNames, info.Name)
}
}
// Configure and run unified agentic loop
var messages []*schema.Message
config := AgenticLoopConfig{
IsInteractive: prompt == "", // If no prompt, start in interactive mode
InitialPrompt: prompt,
ContinueAfterRun: noExit,
Quiet: quietFlag,
ServerNames: serverNames,
ToolNames: toolNames,
ModelName: modelName,
MCPConfig: mcpConfig,
}
return runAgenticLoop(ctx, mcpAgent, cli, messages, config, hookExecutor)
}