diff --git a/.github/workflows/memory-metrics.yml b/.github/workflows/memory-metrics.yml index 5e88a93c5..a3b54a063 100644 --- a/.github/workflows/memory-metrics.yml +++ b/.github/workflows/memory-metrics.yml @@ -80,7 +80,7 @@ jobs: gc.collect() tracemalloc.start() - agent = Agent('bench', agentfield_server='http://localhost:8080', auto_register=False, enable_mcp=False) + agent = Agent('bench', agentfield_server='http://localhost:8080', auto_register=False) for i in range(1000): idx = i diff --git a/control-plane/internal/cli/add.go b/control-plane/internal/cli/add.go deleted file mode 100644 index d508bfb33..000000000 --- a/control-plane/internal/cli/add.go +++ /dev/null @@ -1,360 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/config" - "github.com/Agent-Field/agentfield/control-plane/internal/mcp" - - "github.com/spf13/cobra" -) - -// MCPAddOptions holds all the flag-based options for the 'add --mcp' command. -type MCPAddOptions struct { - Source string // Positional argument - Alias string // Positional argument or --alias flag - MCP bool // --mcp flag - Force bool // --force flag - URL string // --url flag (for remote MCP servers) - RunCmd string // --run flag (command to run the server) - SetupCmds []string // --setup flags (setup commands, repeatable) - WorkingDir string // --working-dir flag - EnvVars []string // --env flags (raw "KEY=VALUE") - Description string // --description flag - Tags []string // --tags flags (repeatable) - HealthCheck string // --health-check flag - Timeout int // --timeout flag (in seconds) - Version string // --version flag -} - -// Assuming color funcs (Bold, Green, Red, Yellow, Gray, Cyan) and -// status consts (StatusInfo, StatusWarning, StatusError, StatusSuccess) -// are available from the package cli (e.g. defined in utils.go or root.go) -// or imported if they are from a different package. -// Removed local definitions to avoid redeclaration. - -// NewAddCommand creates the add command for adding dependencies -func NewAddCommand() *cobra.Command { - opts := &MCPAddOptions{} - - cmd := &cobra.Command{ - Use: "add [alias]", - Short: "Add dependencies to your AgentField agent project", - Long: `Add dependencies to your AgentField agent project. - -Supports adding MCP servers and regular agent packages with advanced configuration options. - -Examples: - # Remote MCP servers (URL-based) - af add --mcp --url https://github.com/modelcontextprotocol/server-github - af add --mcp --url https://github.com/ferrislucas/iterm-mcp github-tools - - # Local MCP servers with custom commands - af add --mcp my-server --run "node server.js --port {{port}}" \ - --setup "npm install" --setup "npm run build" - - # Python MCP server with environment variables - af add --mcp python-server --run "python server.py --port {{port}}" \ - --setup "pip install -r requirements.txt" \ - --env "PYTHONPATH={{server_dir}}" \ - --working-dir "./src" - - # Advanced configuration with health checks - af add --mcp enterprise-server \ - --url https://github.com/company/mcp-server \ - --run "node dist/server.js --port {{port}} --config {{config_file}}" \ - --setup "npm install" --setup "npm run build" \ - --env "NODE_ENV=production" --env "LOG_LEVEL=info" \ - --health-check "curl -f http://localhost:{{port}}/health" \ - --timeout 60 --description "Enterprise MCP server" \ - --tags "enterprise" --tags "production" - - # Regular agent packages (future) - af add github.com/agentfield-helpers/email-utils - af add github.com/openai/prompt-templates - -Template Variables: - {{port}} - Dynamically assigned port number - {{config_file}} - Path to server configuration file - {{data_dir}} - Server data directory path (Note: mcp-integration2.md uses {{server_dir}}) - {{log_file}} - Server log file path - {{server_dir}} - Server installation directory - {{alias}} - Server alias name`, - Args: cobra.MinimumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - opts.Source = args[0] - // If --alias flag is not used, and a second positional arg is present, use it as alias. - if !cmd.Flags().Changed("alias") && len(args) > 1 { - opts.Alias = args[1] - } - // verbose flag is typically a persistent flag from the root command. - // Assuming it's accessible globally as 'verbose' (lowercase) from the cli package (e.g. cli.verbose) - // or passed via context if that's the pattern. For now, using the package global 'verbose'. - return runAddCommandWithOptions(opts, verbose) // Changed Verbose to verbose - }, - } - - cmd.Flags().BoolVar(&opts.MCP, "mcp", false, "Add an MCP server dependency") - cmd.Flags().StringVar(&opts.Alias, "alias", "", "Custom alias for the dependency") - cmd.Flags().BoolVar(&opts.Force, "force", false, "Force reinstall if already exists") - cmd.Flags().StringVar(&opts.URL, "url", "", "URL for remote MCP server") - cmd.Flags().StringVar(&opts.RunCmd, "run", "", "Command to run the MCP server with template variables") - cmd.Flags().StringSliceVar(&opts.SetupCmds, "setup", []string{}, "Setup commands to run before starting (repeatable)") - cmd.Flags().StringVar(&opts.WorkingDir, "working-dir", "", "Working directory for the MCP server") - cmd.Flags().StringSliceVar(&opts.EnvVars, "env", []string{}, "Environment variables (repeatable, KEY=VALUE format)") - cmd.Flags().StringVar(&opts.Description, "description", "", "Description of the MCP server") - cmd.Flags().StringSliceVar(&opts.Tags, "tags", []string{}, "Tags for categorizing the server (repeatable)") - cmd.Flags().StringVar(&opts.HealthCheck, "health-check", "", "Custom health check command") - cmd.Flags().IntVar(&opts.Timeout, "timeout", 30, "Timeout for server operations in seconds") - cmd.Flags().StringVar(&opts.Version, "version", "", "Specific version/tag to install") - - return cmd -} - -func runAddCommandWithOptions(opts *MCPAddOptions, verbose bool) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - if opts.MCP { - // Create and execute the MCPAddCommand - mcpCmd, err := NewMCPAddCommand(projectDir, opts, verbose) - if err != nil { - // Consider a more user-friendly error format here, possibly using PrintError - return fmt.Errorf("failed to prepare add MCP command: %w", err) - } - return mcpCmd.Execute() - } - - return fmt.Errorf("only MCP server dependencies are currently supported. Use --mcp flag") -} - -func validateAgentFieldProject(projectDir string) error { - agentfieldYAMLPath := filepath.Join(projectDir, "agentfield.yaml") - if _, err := os.Stat(agentfieldYAMLPath); os.IsNotExist(err) { - return fmt.Errorf("not a AgentField project directory (agentfield.yaml not found)") - } - return nil -} - -// MCPAddCommand encapsulates the logic for adding an MCP server. -type MCPAddCommand struct { - ProjectDir string - Opts *MCPAddOptions - Verbose bool - AppConfig *config.Config - Manager *mcp.MCPManager // Initialized in the builder or Execute -} - -// NewMCPAddCommand acts as a builder for MCPAddCommand. -// It performs initial processing and validation. -func NewMCPAddCommand(projectDir string, opts *MCPAddOptions, verboseFlag bool) (*MCPAddCommand, error) { - // Load application configuration - appCfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - // Fallback for safety, though agentfield.yaml should exist due to validateAgentFieldProject - appCfg, err = config.LoadConfig("agentfield.yaml") - if err != nil { - return nil, fmt.Errorf("failed to load af configuration: %w. Ensure agentfield.yaml exists", err) - } - } - - // Determine final alias - if opts.Alias == "" { - opts.Alias = deriveAliasLocally(opts.Source) // Using local helper for now - } - - // Construct MCPServerConfig (this will be part of the MCPAddCommand or its options) - // For now, we'll build it inside Execute or pass opts directly. - // The main point here is to set up the command object. - - // TODO: Perform more comprehensive validation using mcp.ConfigValidator if needed here. - // For example, validating specific formats of Source, Runtime, etc. - // The BasicConfigValidator can be instantiated and used. - // validator := mcp.NewBasicConfigValidator() - // tempCfgForValidation := mcp.MCPServerConfig{ Alias: finalAlias, Source: opts.Source, ... } - // validationErrs := validator.Validate(tempCfgForValidation) - // if len(validationErrs) > 0 { - // return nil, fmt.Errorf("MCP configuration validation failed:\n%s", validationErrs.Error()) - // } - - return &MCPAddCommand{ - ProjectDir: projectDir, - Opts: opts, - Verbose: verboseFlag, - AppConfig: appCfg, - // Manager will be initialized in Execute or if needed earlier. - }, nil -} - -// Execute performs the MCP server addition. -func (cmd *MCPAddCommand) Execute() error { - fmt.Printf("Adding MCP server: %s\n", Bold(cmd.Opts.Source)) - - // Initialize MCPManager here if not done in builder - cmd.Manager = mcp.NewMCPManager(cmd.AppConfig, cmd.ProjectDir, cmd.Verbose) - - finalAlias := cmd.Opts.Alias // Alias might have been refined by builder if it was more complex - if finalAlias == "" { - finalAlias = deriveAliasLocally(cmd.Opts.Source) - } - - // This check should use the refined alias - if !cmd.Opts.Force && finalAlias != "" { - if mcpExists(cmd.ProjectDir, finalAlias) { - return fmt.Errorf("MCP server with alias '%s' already exists (use --force to reinstall)", finalAlias) - } - } - - mcpServerCfg := mcp.MCPServerConfig{ - Alias: finalAlias, - Description: cmd.Opts.Description, - URL: cmd.Opts.URL, - RunCmd: cmd.Opts.RunCmd, - SetupCmds: cmd.Opts.SetupCmds, - WorkingDir: cmd.Opts.WorkingDir, - HealthCheck: cmd.Opts.HealthCheck, - Version: cmd.Opts.Version, - Tags: cmd.Opts.Tags, - Force: cmd.Opts.Force, - } - - // Set timeout if provided - if cmd.Opts.Timeout > 0 { - mcpServerCfg.Timeout = time.Duration(cmd.Opts.Timeout) * time.Second - } - - // Parse environment variables - if len(cmd.Opts.EnvVars) > 0 { - mcpServerCfg.Env = make(map[string]string) - for _, envVar := range cmd.Opts.EnvVars { - parts := strings.SplitN(envVar, "=", 2) - if len(parts) == 2 { - mcpServerCfg.Env[parts[0]] = parts[1] - } else { - fmt.Printf(" %s Warning: invalid environment variable format '%s', expected KEY=VALUE\n", - Yellow(StatusWarning), envVar) - } - } - } - - // TODO: Issue 4 - Re-enable validation with new simplified architecture - // Temporarily disabled validator to avoid compilation errors - /* - // Integrate mcp.ConfigValidator (from Task 1.3) - validator := mcp.NewBasicConfigValidator() - validationErrs := validator.Validate(mcpServerCfg) - if len(validationErrs) > 0 { - return fmt.Errorf("MCP configuration validation failed:\n%s", validationErrs.Error()) - } - */ - - fmt.Printf(" %s Adding MCP server...\n", Blue("→")) - - // Use the new simplified Add method - if err := cmd.Manager.Add(mcpServerCfg); err != nil { - fmt.Printf(" %s %s\n", Red(StatusError), err.Error()) - return fmt.Errorf("failed to add MCP server: %w", err) - } - - fmt.Printf(" %s MCP server added successfully\n", Green(StatusSuccess)) - fmt.Printf(" %s Alias: %s\n", Gray(StatusInfo), Cyan(mcpServerCfg.Alias)) - fmt.Printf(" %s Location: %s\n", Gray(StatusInfo), Gray(filepath.Join("packages", "mcp", mcpServerCfg.Alias))) - - // Show configuration details - if mcpServerCfg.URL != "" || mcpServerCfg.RunCmd != "" || len(mcpServerCfg.SetupCmds) > 0 || len(mcpServerCfg.Env) > 0 || mcpServerCfg.HealthCheck != "" { - fmt.Printf(" %s Configuration applied:\n", Gray(StatusInfo)) - if mcpServerCfg.URL != "" { - fmt.Printf(" URL: %s\n", mcpServerCfg.URL) - } - if mcpServerCfg.RunCmd != "" { - fmt.Printf(" Run command: %s\n", mcpServerCfg.RunCmd) - } - if len(mcpServerCfg.SetupCmds) > 0 { - fmt.Printf(" Setup commands: %v\n", mcpServerCfg.SetupCmds) - } - if mcpServerCfg.WorkingDir != "" { - fmt.Printf(" Working directory: %s\n", mcpServerCfg.WorkingDir) - } - if len(mcpServerCfg.Env) > 0 { - fmt.Printf(" Environment variables: %d set\n", len(mcpServerCfg.Env)) - } - if mcpServerCfg.HealthCheck != "" { - fmt.Printf(" Health check: %s\n", mcpServerCfg.HealthCheck) - } - if mcpServerCfg.Description != "" { - fmt.Printf(" Description: %s\n", mcpServerCfg.Description) - } - if len(mcpServerCfg.Tags) > 0 { - fmt.Printf(" Tags: %v\n", mcpServerCfg.Tags) - } - } - - fmt.Printf(" %s Capabilities discovery and skill generation handled by manager\n", Gray(StatusInfo)) - - fmt.Printf("\n%s %s\n", Blue("→"), Bold("Next steps:")) - fmt.Printf(" %s Start the MCP server: %s\n", Gray("1."), Cyan(fmt.Sprintf("af mcp start %s", mcpServerCfg.Alias))) - fmt.Printf(" %s Check status: %s\n", Gray("2."), Cyan("af mcp status")) - fmt.Printf(" %s Use MCP tools as regular skills: %s\n", Gray("3."), Cyan(fmt.Sprintf("await app.call(\"%s_\", ...)", mcpServerCfg.Alias))) - fmt.Printf(" %s Generated skill file: %s\n", Gray("4."), Gray(fmt.Sprintf("skills/mcp_%s.py", mcpServerCfg.Alias))) - - return nil -} - -// This function is now replaced by MCPAddCommand.Execute() -// func addMCPServer(projectDir string, opts *MCPAddOptions, verbose bool) error { -// fmt.Printf("Adding MCP server: %s\n", Bold(opts.Source)) -// -// appCfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) -// The orphaned code block that started with "if err != nil {" and was a remnant -// of the original addMCPServer function body has been removed by this replacement. -// The logic is now consolidated within MCPAddCommand.Execute(). - -func mcpExists(projectDir, alias string) bool { - mcpDir := filepath.Join(projectDir, "packages", "mcp", alias) - _, err := os.Stat(mcpDir) - return err == nil // True if err is nil (exists), false otherwise -} - -// deriveAliasLocally is a placeholder for a more robust alias derivation. -// Ideally, this logic resides in the mcp package or is more comprehensive. -func deriveAliasLocally(source string) string { - if strings.Contains(source, "@modelcontextprotocol/server-github") { - return "github" - } - if strings.Contains(source, "@modelcontextprotocol/server-memory") { - return "memory" - } - if strings.Contains(source, "@modelcontextprotocol/server-filesystem") { - return "filesystem" - } - - // Basic derivation from source string (e.g., github:owner/repo -> repo) - parts := strings.Split(source, "/") - namePart := parts[len(parts)-1] - - nameParts := strings.SplitN(namePart, "@", 2) // remove version if any - namePart = nameParts[0] - - nameParts = strings.SplitN(namePart, ":", 2) // remove scheme if any - if len(nameParts) > 1 { - namePart = nameParts[1] - } - - // Sanitize for use as a directory name (simple version) - namePart = strings.ReplaceAll(namePart, ".", "_") - - if namePart == "" { - return "mcp_server" // Default fallback - } - return namePart -} diff --git a/control-plane/internal/cli/commands_test.go b/control-plane/internal/cli/commands_test.go index 16701518b..735574492 100644 --- a/control-plane/internal/cli/commands_test.go +++ b/control-plane/internal/cli/commands_test.go @@ -71,42 +71,6 @@ func TestConfigCommand(t *testing.T) { _ = err } -// TestAddCommand tests the add command -func TestAddCommand(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - resetCLIStateForTest() - - cmd := NewAddCommand() - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - - // Test missing argument - cmd.SetArgs([]string{}) - err := cmd.Execute() - require.Error(t, err) - - // Test with argument - cmd.SetArgs([]string{"test-package"}) - err = cmd.Execute() - // May error if package doesn't exist, but validates command structure - _ = err -} - -// TestMCPCommand tests the MCP command -func TestMCPCommand(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - resetCLIStateForTest() - - cmd := NewMCPCommand() - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - cmd.SetArgs([]string{}) - - // Should display help - err := cmd.Execute() - require.NoError(t, err) -} - // TestVCCommand tests the VC command func TestVCCommand(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/control-plane/internal/cli/mcp.go b/control-plane/internal/cli/mcp.go deleted file mode 100644 index a82cc60ed..000000000 --- a/control-plane/internal/cli/mcp.go +++ /dev/null @@ -1,785 +0,0 @@ -package cli - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/Agent-Field/agentfield/control-plane/internal/config" // Ensured this import is correct - "github.com/Agent-Field/agentfield/control-plane/internal/mcp" - - "github.com/spf13/cobra" -) - -// NewMCPCommand creates the mcp command for managing MCP servers -func NewMCPCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "mcp", - Short: "Manage MCP servers in your AgentField agent project", - Long: `Manage Model Context Protocol (MCP) servers in your AgentField agent project. - -MCP servers provide external tools and resources that can be integrated into your agent.`, - } - - // Add subcommands - cmd.AddCommand(NewMCPStatusCommand()) - cmd.AddCommand(NewMCPStartCommand()) - cmd.AddCommand(NewMCPStopCommand()) - cmd.AddCommand(NewMCPRestartCommand()) - cmd.AddCommand(NewMCPLogsCommand()) - cmd.AddCommand(NewMCPRemoveCommand()) - cmd.AddCommand(NewMCPDiscoverCommand()) - cmd.AddCommand(NewMCPSkillsCommand()) - cmd.AddCommand(NewMCPMigrateCommand()) - - return cmd -} - -// NewMCPStatusCommand creates the mcp status command -func NewMCPStatusCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "status", - Short: "Show status of all MCP servers", - Long: `Display the current status of all MCP servers in the project.`, - RunE: runMCPStatusCommand, - } - - return cmd -} - -func runMCPStatusCommand(cmd *cobra.Command, args []string) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - manager := mcp.NewMCPManager(cfg, projectDir, verbose) - servers, err := manager.Status() - if err != nil { - return fmt.Errorf("failed to get MCP status: %w", err) - } - - PrintHeader("MCP Server Status") - - if len(servers) == 0 { - PrintInfo("No MCP servers installed") - fmt.Printf("\n%s %s\n", Blue("→"), "Add an MCP server: af add --mcp @modelcontextprotocol/server-github") - return nil - } - - // Count running and stopped servers - running := 0 - stopped := 0 - for _, server := range servers { - if server.Status == mcp.StatusRunning { - running++ - } else { - stopped++ - } - } - - fmt.Printf("\n%s Total: %d | %s Running: %d | %s Stopped: %d\n\n", - Gray("šŸ“¦"), len(servers), - Green("🟢"), running, - Red("šŸ”“"), stopped) - - for _, server := range servers { - statusIcon := "šŸ”“" - statusText := "stopped" - if server.Status == mcp.StatusRunning { - statusIcon = "🟢" - statusText = "running" - } - - fmt.Printf("%s %s\n", statusIcon, Bold(server.Alias)) - if server.URL != "" { - fmt.Printf(" %s %s\n", Gray("URL:"), server.URL) - } - if server.RunCmd != "" { - fmt.Printf(" %s %s\n", Gray("Command:"), server.RunCmd) - } - fmt.Printf(" %s %s\n", Gray("Version:"), server.Version) - fmt.Printf(" %s %s", Gray("Status:"), statusText) - - if server.Status == mcp.StatusRunning && server.PID > 0 { - fmt.Printf(" (PID: %d, Port: %d)", server.PID, server.Port) - } - fmt.Printf("\n") - - if server.StartedAt != nil { - fmt.Printf(" %s %s\n", Gray("Started:"), server.StartedAt.Format("2006-01-02 15:04:05")) - } - fmt.Printf("\n") - } - - return nil -} - -// NewMCPStartCommand creates the mcp start command -func NewMCPStartCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "start ", - Short: "Start an MCP server", - Long: `Start a specific MCP server by its alias.`, - Args: cobra.ExactArgs(1), - RunE: runMCPStartCommand, - } - - return cmd -} - -func runMCPStartCommand(cmd *cobra.Command, args []string) error { - alias := args[0] - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - manager := mcp.NewMCPManager(cfg, projectDir, verbose) - - PrintInfo(fmt.Sprintf("Starting MCP server: %s", alias)) - - _, err = manager.Start(alias) - if err != nil { - PrintError(fmt.Sprintf("Failed to start MCP server: %v", err)) - return err - } - - PrintSuccess(fmt.Sprintf("MCP server '%s' started successfully", alias)) - return nil -} - -// NewMCPStopCommand creates the mcp stop command -func NewMCPStopCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "stop ", - Short: "Stop an MCP server", - Long: `Stop a specific MCP server by its alias.`, - Args: cobra.ExactArgs(1), - RunE: runMCPStopCommand, - } - - return cmd -} - -func runMCPStopCommand(cmd *cobra.Command, args []string) error { - alias := args[0] - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - manager := mcp.NewMCPManager(cfg, projectDir, verbose) - - PrintInfo(fmt.Sprintf("Stopping MCP server: %s", alias)) - - if err := manager.Stop(alias); err != nil { - PrintError(fmt.Sprintf("Failed to stop MCP server: %v", err)) - return err - } - - PrintSuccess(fmt.Sprintf("MCP server '%s' stopped successfully", alias)) - return nil -} - -// NewMCPRestartCommand creates the mcp restart command -func NewMCPRestartCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "restart ", - Short: "Restart an MCP server", - Long: `Restart a specific MCP server by its alias.`, - Args: cobra.ExactArgs(1), - RunE: runMCPRestartCommand, - } - - return cmd -} - -func runMCPRestartCommand(cmd *cobra.Command, args []string) error { - alias := args[0] - - PrintInfo(fmt.Sprintf("Restarting MCP server: %s", alias)) - - // Stop then start - if err := runMCPStopCommand(cmd, args); err != nil { - return err - } - - return runMCPStartCommand(cmd, args) -} - -// NewMCPLogsCommand creates the mcp logs command -func NewMCPLogsCommand() *cobra.Command { - var followFlag bool - var tailLines int - - cmd := &cobra.Command{ - Use: "logs ", - Short: "Show logs for an MCP server", - Long: `Display logs for a specific MCP server.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runMCPLogsCommand(cmd, args, followFlag, tailLines) - }, - } - - cmd.Flags().BoolVarP(&followFlag, "follow", "f", false, "Follow log output") - cmd.Flags().IntVarP(&tailLines, "tail", "n", 50, "Number of lines to show from the end of the logs") - - return cmd -} - -func runMCPLogsCommand(cmd *cobra.Command, args []string, follow bool, tail int) error { - alias := args[0] - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - logFile := filepath.Join(projectDir, "packages", "mcp", alias, fmt.Sprintf("%s.log", alias)) - - if _, err := os.Stat(logFile); os.IsNotExist(err) { - PrintError(fmt.Sprintf("Log file not found for MCP server '%s'", alias)) - return nil - } - - PrintInfo(fmt.Sprintf("Showing logs for MCP server: %s", alias)) - fmt.Printf("%s %s\n\n", Gray("Log file:"), logFile) - - // For now, just show that we would display logs - // In a full implementation, we would use tail command or read the file - fmt.Printf("%s Logs would be displayed here (last %d lines)\n", Gray("→"), tail) - if follow { - fmt.Printf("%s Following logs... (Press Ctrl+C to stop)\n", Gray("→")) - } - - return nil -} - -// NewMCPSkillsCommand creates the mcp skills command -func NewMCPSkillsCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "skills", - Short: "Manage auto-generated skills for MCP servers", - Long: `Manage auto-generated Python skill files for MCP servers.`, - } - - cmd.AddCommand(NewMCPSkillsGenerateCommand()) - cmd.AddCommand(NewMCPSkillsListCommand()) - cmd.AddCommand(NewMCPSkillsRefreshCommand()) - - return cmd -} - -// NewMCPSkillsGenerateCommand creates the mcp skills generate command -func NewMCPSkillsGenerateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "generate [alias]", - Short: "Generate skill files for MCP servers", - Long: `Generate Python skill files that wrap MCP tools as AgentField skills.`, - Args: cobra.MaximumNArgs(1), - RunE: runMCPSkillsGenerateCommand, - } - - cmd.Flags().BoolP("verbose", "v", false, "Enable verbose output for debugging") - - return cmd -} - -func runMCPSkillsGenerateCommand(cmd *cobra.Command, args []string) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - verboseFlag, _ := cmd.Flags().GetBool("verbose") - generator := mcp.NewSkillGenerator(projectDir, verboseFlag) - - if len(args) == 1 { - // Generate skills for specific server - alias := args[0] - PrintInfo(fmt.Sprintf("Generating skills for MCP server: %s", alias)) - - result, err := generator.GenerateSkillsForServer(alias) - if err != nil { - PrintError(fmt.Sprintf("Failed to generate skills: %v", err)) - return err - } - - if result.Generated { - PrintSuccess(fmt.Sprintf("Skills generated for '%s'", alias)) - fmt.Printf(" %s Generated file: %s (%d tools)\n", Gray("→"), Gray(fmt.Sprintf("skills/mcp_%s.py", alias)), result.ToolCount) - } else { - PrintWarning(result.Message) - fmt.Printf(" %s %s\n", Gray("→"), "No skill file was created") - } - } else { - // Generate skills for all servers - PrintInfo("Generating skills for all MCP servers...") - - if err := generator.GenerateSkillsForAllServers(); err != nil { - PrintError(fmt.Sprintf("Failed to generate skills: %v", err)) - return err - } - - PrintSuccess("All skills processed successfully") - } - - return nil -} - -// NewMCPSkillsListCommand creates the mcp skills list command -func NewMCPSkillsListCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "list", - Short: "List generated skill files", - Long: `List all auto-generated skill files for MCP servers.`, - RunE: runMCPSkillsListCommand, - } - - return cmd -} - -func runMCPSkillsListCommand(cmd *cobra.Command, args []string) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - skillsDir := filepath.Join(projectDir, "skills") - if _, err := os.Stat(skillsDir); os.IsNotExist(err) { - PrintInfo("No skills directory found") - return nil - } - - entries, err := os.ReadDir(skillsDir) - if err != nil { - return fmt.Errorf("failed to read skills directory: %w", err) - } - - PrintHeader("Auto-Generated MCP Skills") - - skillCount := 0 - for _, entry := range entries { - if entry.IsDir() || !strings.HasPrefix(entry.Name(), "mcp_") || !strings.HasSuffix(entry.Name(), ".py") { - continue - } - - skillCount++ - alias := strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "mcp_"), ".py") - fmt.Printf("%s %s\n", Green("āœ“"), Bold(alias)) - fmt.Printf(" %s %s\n", Gray("File:"), entry.Name()) - - // Try to get server info - if cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")); err == nil { - discovery := mcp.NewCapabilityDiscovery(cfg, projectDir) - if capability, err := discovery.GetServerCapability(alias); err == nil { - fmt.Printf(" %s %d tools available\n", Gray("Tools:"), len(capability.Tools)) - } - } - } - - if skillCount == 0 { - PrintInfo("No auto-generated MCP skills found") - fmt.Printf("\n%s %s\n", Blue("→"), "Generate skills: af mcp skills generate") - } else { - fmt.Printf("\n%s %d skill files found\n", Gray("Total:"), skillCount) - } - - return nil -} - -// NewMCPSkillsRefreshCommand creates the mcp skills refresh command -func NewMCPSkillsRefreshCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "refresh", - Short: "Refresh all skill files", - Long: `Refresh capabilities and regenerate all skill files for MCP servers.`, - RunE: runMCPSkillsRefreshCommand, - } - - return cmd -} - -func runMCPSkillsRefreshCommand(cmd *cobra.Command, args []string) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - manager := mcp.NewMCPManager(cfg, projectDir, verbose) - - PrintInfo("Refreshing capabilities and regenerating skills...") - - // Get all servers and refresh their capabilities - servers, err := manager.Status() - if err != nil { - PrintError(fmt.Sprintf("Failed to get server list: %v", err)) - return err - } - - if len(servers) == 0 { - PrintInfo("No MCP servers found") - return nil - } - - // For each server, discover capabilities - for _, server := range servers { - if _, err := manager.DiscoverCapabilities(server.Alias); err != nil { - PrintWarning(fmt.Sprintf("Failed to refresh capabilities for %s: %v", server.Alias, err)) - } - } - - PrintSuccess("All skills refreshed successfully") - return nil -} - -// NewMCPRemoveCommand creates the mcp remove command -func NewMCPRemoveCommand() *cobra.Command { - var forceFlag bool - - cmd := &cobra.Command{ - Use: "remove ", - Short: "Remove an MCP server", - Long: `Remove an MCP server from the project.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runMCPRemoveCommand(cmd, args, forceFlag) - }, - } - - cmd.Flags().BoolVar(&forceFlag, "force", false, "Force removal even if server is running") - - return cmd -} - -func runMCPRemoveCommand(cmd *cobra.Command, args []string, force bool) error { - alias := args[0] - - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - manager := mcp.NewMCPManager(cfg, projectDir, verbose) - - PrintInfo(fmt.Sprintf("Removing MCP server: %s", alias)) - - if err := manager.Remove(alias); err != nil { - if !force && strings.Contains(err.Error(), "is running") { - PrintError("MCP server is running. Stop it first or use --force") - return err - } - PrintError(fmt.Sprintf("Failed to remove MCP server: %v", err)) - return err - } - - PrintSuccess(fmt.Sprintf("MCP server '%s' removed successfully", alias)) - return nil -} - -// NewMCPDiscoverCommand creates the mcp discover command -func NewMCPDiscoverCommand() *cobra.Command { - var refreshFlag bool - - cmd := &cobra.Command{ - Use: "discover [alias]", - Short: "Discover MCP server capabilities", - Long: `Discover and display capabilities (tools and resources) of MCP servers.`, - Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runMCPDiscoverCommand(cmd, args, refreshFlag) - }, - } - - cmd.Flags().BoolVar(&refreshFlag, "refresh", false, "Force refresh of capabilities cache") - - return cmd -} - -func runMCPDiscoverCommand(cmd *cobra.Command, args []string, refresh bool) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - - discovery := mcp.NewCapabilityDiscovery(cfg, projectDir) - - if len(args) == 1 { - // Discover capabilities for specific server - alias := args[0] - PrintInfo(fmt.Sprintf("Discovering capabilities for MCP server: %s", alias)) - - capability, err := discovery.GetServerCapability(alias) - if err != nil { - PrintError(fmt.Sprintf("Failed to discover capabilities: %v", err)) - return err - } - - displayServerCapability(*capability) - } else { - // Discover capabilities for all servers - PrintInfo("Discovering capabilities for all MCP servers...") - - if refresh { - if err := discovery.RefreshCapabilities(); err != nil { - PrintError(fmt.Sprintf("Failed to refresh capabilities: %v", err)) - return err - } - } - - capabilities, err := discovery.DiscoverCapabilities() - if err != nil { - PrintError(fmt.Sprintf("Failed to discover capabilities: %v", err)) - return err - } - - if len(capabilities) == 0 { - PrintInfo("No MCP servers found") - return nil - } - - PrintHeader("MCP Server Capabilities") - for _, capability := range capabilities { - displayServerCapability(capability) - fmt.Println() - } - } - - return nil -} - -func displayServerCapability(capability mcp.MCPCapability) { - fmt.Printf("%s %s\n", Bold("šŸ”§"), Bold(capability.ServerAlias)) - fmt.Printf(" %s %s\n", Gray("Server:"), capability.ServerName) - fmt.Printf(" %s %s\n", Gray("Version:"), capability.Version) - fmt.Printf(" %s %s\n", Gray("Transport:"), capability.Transport) - fmt.Printf(" %s %s\n", Gray("Endpoint:"), capability.Endpoint) - - if len(capability.Tools) > 0 { - fmt.Printf(" %s %s (%d)\n", Gray("Tools:"), Green("āœ“"), len(capability.Tools)) - for _, tool := range capability.Tools { - fmt.Printf(" • %s - %s\n", Bold(tool.Name), tool.Description) - } - } else { - fmt.Printf(" %s %s\n", Gray("Tools:"), Red("None")) - } - - if len(capability.Resources) > 0 { - fmt.Printf(" %s %s (%d)\n", Gray("Resources:"), Green("āœ“"), len(capability.Resources)) - for _, resource := range capability.Resources { - fmt.Printf(" • %s - %s\n", Bold(resource.Name), resource.Description) - } - } else { - fmt.Printf(" %s %s\n", Gray("Resources:"), Red("None")) - } -} - -// NewMCPMigrateCommand creates the mcp migrate command -func NewMCPMigrateCommand() *cobra.Command { - cmd := &cobra.Command{ - Use: "migrate [alias]", - Short: "Migrate MCP server metadata from old format to new format", - Long: `Migrate MCP server metadata from old mcp.json format to new config.json format.`, - Args: cobra.MaximumNArgs(1), - RunE: runMCPMigrateCommand, - } - - return cmd -} - -func runMCPMigrateCommand(cmd *cobra.Command, args []string) error { - projectDir, err := os.Getwd() - if err != nil { - return fmt.Errorf("failed to get current directory: %w", err) - } - - if err := validateAgentFieldProject(projectDir); err != nil { - return err - } - - cfg, err := config.LoadConfig(filepath.Join(projectDir, "agentfield.yaml")) - if err != nil { - cfg, err = config.LoadConfig("agentfield.yaml") // Fallback - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - - discovery := mcp.NewCapabilityDiscovery(cfg, projectDir) - - if len(args) == 1 { - // Migrate specific server - alias := args[0] - PrintInfo(fmt.Sprintf("Migrating MCP server: %s", alias)) - - if err := migrateSingleServer(discovery, projectDir, alias); err != nil { - PrintError(fmt.Sprintf("Failed to migrate %s: %v", alias, err)) - return err - } - - PrintSuccess(fmt.Sprintf("Successfully migrated %s", alias)) - } else { - // Migrate all servers - PrintInfo("Migrating all MCP servers...") - - if err := migrateAllServers(discovery, projectDir); err != nil { - PrintError(fmt.Sprintf("Migration failed: %v", err)) - return err - } - - PrintSuccess("All servers migrated successfully") - } - - return nil -} - -func migrateSingleServer(discovery *mcp.CapabilityDiscovery, projectDir, alias string) error { - serverDir := filepath.Join(projectDir, "packages", "mcp", alias) - - // Check if server directory exists - if _, err := os.Stat(serverDir); os.IsNotExist(err) { - return fmt.Errorf("MCP server '%s' not found", alias) - } - - // Check if already migrated - configPath := filepath.Join(serverDir, "config.json") - if _, err := os.Stat(configPath); err == nil { - PrintInfo(fmt.Sprintf("Server '%s' already uses config.json format", alias)) - return nil - } - - // Check if old format exists - oldPath := filepath.Join(serverDir, "mcp.json") - if _, err := os.Stat(oldPath); os.IsNotExist(err) { - return fmt.Errorf("no mcp.json found for server '%s'", alias) - } - - // Perform migration using the discovery's migration function - // We need to access the migration function, so let's trigger it by calling discoverServerCapability - _, err := discovery.GetServerCapability(alias) - if err != nil { - return fmt.Errorf("migration failed: %w", err) - } - - return nil -} - -func migrateAllServers(discovery *mcp.CapabilityDiscovery, projectDir string) error { - mcpDir := filepath.Join(projectDir, "packages", "mcp") - if _, err := os.Stat(mcpDir); os.IsNotExist(err) { - PrintInfo("No MCP servers found") - return nil - } - - entries, err := os.ReadDir(mcpDir) - if err != nil { - return fmt.Errorf("failed to read MCP directory: %w", err) - } - - migratedCount := 0 - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - alias := entry.Name() - if err := migrateSingleServer(discovery, projectDir, alias); err != nil { - PrintWarning(fmt.Sprintf("Failed to migrate %s: %v", alias, err)) - } else { - migratedCount++ - PrintInfo(fmt.Sprintf("Migrated: %s", alias)) - } - } - - if migratedCount == 0 { - PrintInfo("No servers needed migration") - } else { - PrintInfo(fmt.Sprintf("Migrated %d servers", migratedCount)) - } - - return nil -} diff --git a/control-plane/internal/cli/mcp/config.go b/control-plane/internal/cli/mcp/config.go deleted file mode 100644 index e424bb87c..000000000 --- a/control-plane/internal/cli/mcp/config.go +++ /dev/null @@ -1,8 +0,0 @@ -package mcp - -// TODO: Issue 9 - This file will be deleted as part of cleanup -// Temporarily commented out to avoid compilation errors during transition - -/* -[Previous content commented out] -*/ diff --git a/control-plane/internal/cli/mcp/diagnostics.go b/control-plane/internal/cli/mcp/diagnostics.go deleted file mode 100644 index e424bb87c..000000000 --- a/control-plane/internal/cli/mcp/diagnostics.go +++ /dev/null @@ -1,8 +0,0 @@ -package mcp - -// TODO: Issue 9 - This file will be deleted as part of cleanup -// Temporarily commented out to avoid compilation errors during transition - -/* -[Previous content commented out] -*/ diff --git a/control-plane/internal/cli/mcp/framework.go b/control-plane/internal/cli/mcp/framework.go deleted file mode 100644 index e424bb87c..000000000 --- a/control-plane/internal/cli/mcp/framework.go +++ /dev/null @@ -1,8 +0,0 @@ -package mcp - -// TODO: Issue 9 - This file will be deleted as part of cleanup -// Temporarily commented out to avoid compilation errors during transition - -/* -[Previous content commented out] -*/ diff --git a/control-plane/internal/cli/mcp/interfaces.go b/control-plane/internal/cli/mcp/interfaces.go deleted file mode 100644 index e424bb87c..000000000 --- a/control-plane/internal/cli/mcp/interfaces.go +++ /dev/null @@ -1,8 +0,0 @@ -package mcp - -// TODO: Issue 9 - This file will be deleted as part of cleanup -// Temporarily commented out to avoid compilation errors during transition - -/* -[Previous content commented out] -*/ diff --git a/control-plane/internal/cli/mcp/status.go b/control-plane/internal/cli/mcp/status.go deleted file mode 100644 index e424bb87c..000000000 --- a/control-plane/internal/cli/mcp/status.go +++ /dev/null @@ -1,8 +0,0 @@ -package mcp - -// TODO: Issue 9 - This file will be deleted as part of cleanup -// Temporarily commented out to avoid compilation errors during transition - -/* -[Previous content commented out] -*/ diff --git a/control-plane/internal/cli/root.go b/control-plane/internal/cli/root.go index 12d73d912..06c16c09f 100644 --- a/control-plane/internal/cli/root.go +++ b/control-plane/internal/cli/root.go @@ -110,9 +110,7 @@ AI Agent? Run "af agent help" for structured JSON output optimized for programma RootCmd.AddCommand(NewStopCommand()) RootCmd.AddCommand(NewLogsCommand()) RootCmd.AddCommand(NewConfigCommand()) - RootCmd.AddCommand(NewAddCommand()) - RootCmd.AddCommand(NewMCPCommand()) - RootCmd.AddCommand(NewVCCommand()) +RootCmd.AddCommand(NewVCCommand()) RootCmd.AddCommand(NewNodesCommand()) RootCmd.AddCommand(NewExecutionCommand()) diff --git a/control-plane/internal/core/domain/mcp_health.go b/control-plane/internal/core/domain/mcp_health.go deleted file mode 100644 index a780f044b..000000000 --- a/control-plane/internal/core/domain/mcp_health.go +++ /dev/null @@ -1,224 +0,0 @@ -package domain - -import ( - "fmt" - "time" -) - -// MCPSummaryForUI represents MCP health summary optimized for UI display -type MCPSummaryForUI struct { - TotalServers int `json:"total_servers"` - RunningServers int `json:"running_servers"` - TotalTools int `json:"total_tools"` - OverallHealth float64 `json:"overall_health"` - HasIssues bool `json:"has_issues"` - - // User mode: simplified capability status - CapabilitiesAvailable bool `json:"capabilities_available"` - ServiceStatus string `json:"service_status"` // "ready", "degraded", "unavailable" -} - -// AgentNodeDetailsForUI represents detailed agent node information including MCP data -type AgentNodeDetailsForUI struct { - // Embed the base agent node data - ID string `json:"id"` - TeamID string `json:"team_id"` - BaseURL string `json:"base_url"` - Version string `json:"version"` - HealthStatus string `json:"health_status"` - LastHeartbeat time.Time `json:"last_heartbeat"` - RegisteredAt time.Time `json:"registered_at"` - - // MCP-specific data (only in developer mode) - MCPServers []MCPServerHealthForUI `json:"mcp_servers,omitempty"` - MCPSummary *MCPSummaryForUI `json:"mcp_summary,omitempty"` -} - -// MCPServerHealthForUI represents MCP server health optimized for UI display -type MCPServerHealthForUI struct { - Alias string `json:"alias"` - Status string `json:"status"` - ToolCount int `json:"tool_count"` - StartedAt *time.Time `json:"started_at"` - LastHealthCheck *time.Time `json:"last_health_check"` - ErrorMessage string `json:"error_message,omitempty"` - Port int `json:"port,omitempty"` - ProcessID int `json:"process_id,omitempty"` - SuccessRate float64 `json:"success_rate,omitempty"` - AvgResponseTime int `json:"avg_response_time_ms,omitempty"` - - // UI-specific fields - StatusIcon string `json:"status_icon"` // Icon name for UI - StatusColor string `json:"status_color"` // Color code for UI - UptimeFormatted string `json:"uptime_formatted"` // Human-readable uptime -} - -// CachedMCPHealth represents cached MCP health data with timestamp -type CachedMCPHealth struct { - Data *MCPHealthResponseData `json:"data"` - Timestamp time.Time `json:"timestamp"` - NodeID string `json:"node_id"` -} - -// MCPHealthResponseData represents the raw MCP health data from agent -type MCPHealthResponseData struct { - Servers []MCPServerHealthData `json:"servers"` - Summary MCPSummaryData `json:"summary"` -} - -// MCPServerHealthData represents raw MCP server health data -type MCPServerHealthData struct { - Alias string `json:"alias"` - Status string `json:"status"` - ToolCount int `json:"tool_count"` - StartedAt *time.Time `json:"started_at"` - LastHealthCheck *time.Time `json:"last_health_check"` - ErrorMessage string `json:"error_message,omitempty"` - Port int `json:"port,omitempty"` - ProcessID int `json:"process_id,omitempty"` - SuccessRate float64 `json:"success_rate,omitempty"` - AvgResponseTime int `json:"avg_response_time_ms,omitempty"` -} - -// MCPSummaryData represents raw MCP summary data -type MCPSummaryData struct { - TotalServers int `json:"total_servers"` - RunningServers int `json:"running_servers"` - TotalTools int `json:"total_tools"` - OverallHealth float64 `json:"overall_health"` -} - -// MCPHealthMode represents the mode for MCP health data display -type MCPHealthMode string - -const ( - MCPHealthModeUser MCPHealthMode = "user" - MCPHealthModeDeveloper MCPHealthMode = "developer" -) - -// MCPServerStatus represents the possible statuses of an MCP server -type MCPServerStatus string - -const ( - MCPServerStatusRunning MCPServerStatus = "running" - MCPServerStatusStopped MCPServerStatus = "stopped" - MCPServerStatusError MCPServerStatus = "error" - MCPServerStatusStarting MCPServerStatus = "starting" - MCPServerStatusUnknown MCPServerStatus = "unknown" -) - -// MCPServiceStatus represents the overall service status for user mode -type MCPServiceStatus string - -const ( - MCPServiceStatusReady MCPServiceStatus = "ready" - MCPServiceStatusDegraded MCPServiceStatus = "degraded" - MCPServiceStatusUnavailable MCPServiceStatus = "unavailable" -) - -// TransformMCPHealthForMode transforms raw MCP health data based on display mode -func TransformMCPHealthForMode(data *MCPHealthResponseData, mode MCPHealthMode) (*MCPSummaryForUI, []MCPServerHealthForUI) { - if data == nil { - return nil, nil - } - - // Create summary - summary := &MCPSummaryForUI{ - TotalServers: data.Summary.TotalServers, - RunningServers: data.Summary.RunningServers, - TotalTools: data.Summary.TotalTools, - OverallHealth: data.Summary.OverallHealth, - } - - hasServers := data.Summary.TotalServers > 0 - summary.HasIssues = hasServers && (data.Summary.RunningServers < data.Summary.TotalServers || data.Summary.OverallHealth < 0.8) - - // Set user-mode specific fields - if mode == MCPHealthModeUser { - summary.CapabilitiesAvailable = data.Summary.RunningServers > 0 - if data.Summary.OverallHealth >= 0.9 { - summary.ServiceStatus = string(MCPServiceStatusReady) - } else if data.Summary.OverallHealth >= 0.5 { - summary.ServiceStatus = string(MCPServiceStatusDegraded) - } else { - summary.ServiceStatus = string(MCPServiceStatusUnavailable) - } - } - - // Transform server data (only for developer mode) - var servers []MCPServerHealthForUI - if mode == MCPHealthModeDeveloper { - servers = make([]MCPServerHealthForUI, len(data.Servers)) - for i, server := range data.Servers { - servers[i] = MCPServerHealthForUI{ - Alias: server.Alias, - Status: server.Status, - ToolCount: server.ToolCount, - StartedAt: server.StartedAt, - LastHealthCheck: server.LastHealthCheck, - ErrorMessage: server.ErrorMessage, - Port: server.Port, - ProcessID: server.ProcessID, - SuccessRate: server.SuccessRate, - AvgResponseTime: server.AvgResponseTime, - StatusIcon: getStatusIcon(server.Status), - StatusColor: getStatusColor(server.Status), - UptimeFormatted: formatUptime(server.StartedAt), - } - } - } - - return summary, servers -} - -// getStatusIcon returns the appropriate icon for a server status -func getStatusIcon(status string) string { - switch MCPServerStatus(status) { - case MCPServerStatusRunning: - return "check-circle" - case MCPServerStatusStopped: - return "stop-circle" - case MCPServerStatusError: - return "x-circle" - case MCPServerStatusStarting: - return "play-circle" - default: - return "help-circle" - } -} - -// getStatusColor returns the appropriate color for a server status -func getStatusColor(status string) string { - switch MCPServerStatus(status) { - case MCPServerStatusRunning: - return "green" - case MCPServerStatusStopped: - return "gray" - case MCPServerStatusError: - return "red" - case MCPServerStatusStarting: - return "yellow" - default: - return "gray" - } -} - -// formatUptime formats the uptime duration for display -func formatUptime(startedAt *time.Time) string { - if startedAt == nil { - return "N/A" - } - - duration := time.Since(*startedAt) - if duration < time.Minute { - return "< 1m" - } else if duration < time.Hour { - return duration.Truncate(time.Minute).String() - } else if duration < 24*time.Hour { - return duration.Truncate(time.Hour).String() - } else { - days := int(duration.Hours() / 24) - hours := int(duration.Hours()) % 24 - return fmt.Sprintf("%dd %dh", days, hours) - } -} diff --git a/control-plane/internal/core/domain/models.go b/control-plane/internal/core/domain/models.go index 96bbefd69..efd9992b8 100644 --- a/control-plane/internal/core/domain/models.go +++ b/control-plane/internal/core/domain/models.go @@ -60,19 +60,6 @@ type InstalledPackage struct { type AgentFieldConfig struct { HomeDir string `json:"home_dir"` Environment map[string]string `json:"environment"` - MCP MCPConfig `json:"mcp"` -} - -// MCPConfig contains MCP server configuration -type MCPConfig struct { - Servers []MCPServer `json:"servers"` -} - -// MCPServer represents an MCP server configuration -type MCPServer struct { - Name string `json:"name"` - URL string `json:"url"` - Enabled bool `json:"enabled"` } // InstallOptions represents options for package installation diff --git a/control-plane/internal/core/interfaces/agent_client.go b/control-plane/internal/core/interfaces/agent_client.go index 95b6837ae..e78289c4e 100644 --- a/control-plane/internal/core/interfaces/agent_client.go +++ b/control-plane/internal/core/interfaces/agent_client.go @@ -1,24 +1,9 @@ package interfaces -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" -) +import "context" // AgentClient defines the interface for communicating with agent nodes type AgentClient interface { - // GetMCPHealth retrieves MCP health information from an agent node - GetMCPHealth(ctx context.Context, nodeID string) (*MCPHealthResponse, error) - - // RestartMCPServer restarts a specific MCP server on an agent node - RestartMCPServer(ctx context.Context, nodeID, alias string) error - - // GetMCPTools retrieves the list of tools from a specific MCP server - GetMCPTools(ctx context.Context, nodeID, alias string) (*MCPToolsResponse, error) - // ShutdownAgent requests graceful shutdown of an agent node via HTTP ShutdownAgent(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*AgentShutdownResponse, error) @@ -26,96 +11,6 @@ type AgentClient interface { GetAgentStatus(ctx context.Context, nodeID string) (*AgentStatusResponse, error) } -// MCPHealthResponse represents the complete MCP health data from an agent node -type MCPHealthResponse struct { - Servers []MCPServerHealth `json:"servers"` - Summary MCPSummary `json:"summary"` -} - -// FlexibleTime is a custom time type that can unmarshal timestamps with or without timezone -type FlexibleTime struct { - time.Time -} - -// UnmarshalJSON implements custom JSON unmarshaling for timestamps -func (ft *FlexibleTime) UnmarshalJSON(data []byte) error { - if string(data) == "null" { - return nil - } - - // Remove quotes from JSON string - timeStr := strings.Trim(string(data), `"`) - - // Try parsing with different formats - formats := []string{ - time.RFC3339Nano, // "2006-01-02T15:04:05.999999999Z07:00" - time.RFC3339, // "2006-01-02T15:04:05Z07:00" - "2006-01-02T15:04:05.999999", // Without timezone (microseconds) - "2006-01-02T15:04:05", // Without timezone (seconds) - } - - for _, format := range formats { - if t, err := time.Parse(format, timeStr); err == nil { - // If no timezone was provided, assume UTC - if !strings.Contains(timeStr, "Z") && !strings.Contains(timeStr, "+") && !strings.Contains(timeStr, "-") { - t = t.UTC() - } - ft.Time = t - return nil - } - } - - return fmt.Errorf("unable to parse time: %s", timeStr) -} - -// MarshalJSON implements custom JSON marshaling for timestamps -func (ft FlexibleTime) MarshalJSON() ([]byte, error) { - if ft.Time.IsZero() { - return []byte("null"), nil - } - return json.Marshal(ft.Time.Format(time.RFC3339Nano)) -} - -// MCPServerHealth represents the health status of a single MCP server -type MCPServerHealth struct { - Alias string `json:"alias"` - Status string `json:"status"` // "running", "stopped", "error", "starting" - ToolCount int `json:"tool_count"` - StartedAt *FlexibleTime `json:"started_at"` - LastHealthCheck *FlexibleTime `json:"last_health_check"` - ErrorMessage string `json:"error_message,omitempty"` - Port int `json:"port,omitempty"` - ProcessID int `json:"process_id,omitempty"` - SuccessRate float64 `json:"success_rate,omitempty"` - AvgResponseTime int `json:"avg_response_time_ms,omitempty"` -} - -// MCPSummary represents aggregated MCP health metrics -type MCPSummary struct { - TotalServers int `json:"total_servers"` - RunningServers int `json:"running_servers"` - TotalTools int `json:"total_tools"` - OverallHealth float64 `json:"overall_health"` // 0.0 to 1.0 -} - -// MCPToolsResponse represents the tools available from an MCP server -type MCPToolsResponse struct { - Tools []MCPTool `json:"tools"` -} - -// MCPTool represents a single tool from an MCP server -type MCPTool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]interface{} `json:"input_schema"` -} - -// MCPRestartResponse represents the response from restarting an MCP server -type MCPRestartResponse struct { - Success bool `json:"success"` - Message string `json:"message"` -} - // AgentShutdownResponse represents the response from requesting agent shutdown type AgentShutdownResponse struct { Status string `json:"status"` // "shutting_down", "error" @@ -127,14 +22,13 @@ type AgentShutdownResponse struct { // AgentStatusResponse represents detailed status information from an agent type AgentStatusResponse struct { - Status string `json:"status"` // "running", "stopping", "error" - Uptime string `json:"uptime"` // Human-readable uptime - UptimeSeconds int `json:"uptime_seconds"` // Uptime in seconds - PID int `json:"pid"` // Process ID - Version string `json:"version"` // Agent version - NodeID string `json:"node_id"` // Agent node ID - LastActivity string `json:"last_activity"` // ISO timestamp - Resources map[string]interface{} `json:"resources"` // Resource usage info - MCPServers map[string]interface{} `json:"mcp_servers,omitempty"` // MCP server info - Message string `json:"message,omitempty"` // Additional status message + Status string `json:"status"` // "running", "stopping", "error" + Uptime string `json:"uptime"` // Human-readable uptime + UptimeSeconds int `json:"uptime_seconds"` // Uptime in seconds + PID int `json:"pid"` // Process ID + Version string `json:"version"` // Agent version + NodeID string `json:"node_id"` // Agent node ID + LastActivity string `json:"last_activity"` // ISO timestamp + Resources map[string]interface{} `json:"resources"` // Resource usage info + Message string `json:"message,omitempty"` } diff --git a/control-plane/internal/core/services/agent_service_test.go b/control-plane/internal/core/services/agent_service_test.go index f7b1d81bc..d8438e2f3 100644 --- a/control-plane/internal/core/services/agent_service_test.go +++ b/control-plane/internal/core/services/agent_service_test.go @@ -174,18 +174,6 @@ func newMockAgentClient() *mockAgentClient { return &mockAgentClient{} } -func (m *mockAgentClient) GetMCPHealth(ctx context.Context, nodeID string) (*interfaces.MCPHealthResponse, error) { - return nil, errors.New("not implemented") -} - -func (m *mockAgentClient) RestartMCPServer(ctx context.Context, nodeID, alias string) error { - return errors.New("not implemented") -} - -func (m *mockAgentClient) GetMCPTools(ctx context.Context, nodeID, alias string) (*interfaces.MCPToolsResponse, error) { - return nil, errors.New("not implemented") -} - func (m *mockAgentClient) ShutdownAgent(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { if m.shutdownFunc != nil { return m.shutdownFunc(ctx, nodeID, graceful, timeoutSeconds) diff --git a/control-plane/internal/core/services/dev_service.go b/control-plane/internal/core/services/dev_service.go index d01fffa86..1be76f40e 100644 --- a/control-plane/internal/core/services/dev_service.go +++ b/control-plane/internal/core/services/dev_service.go @@ -69,16 +69,8 @@ func (ds *DefaultDevService) GetDevStatus(path string) (*domain.DevStatus, error func (ds *DefaultDevService) runDev(packagePath string, options domain.DevOptions) error { fmt.Printf("šŸ”§ Development Mode: %s\n", packagePath) - // Temporarily disable MCP server startup - let Python SDK manage them - // Config loading removed since MCP manager is disabled var agentCmd *exec.Cmd // Declare agentCmd here to be accessible in defer and signal handler - // Defer StopAll to ensure it's called on any exit path from runDev - defer func() { - // MCP server management disabled - Python SDK handles MCP servers - fmt.Println("\nāœ… MCP server management delegated to Python SDK.") - }() - // Setup signal handling to gracefully shut down sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) @@ -98,11 +90,6 @@ func (ds *DefaultDevService) runDev(packagePath string, options domain.DevOption } }() - // MCP server startup disabled - Python SDK manages MCP servers - if options.Verbose { - fmt.Println("ā„¹ļø MCP server management delegated to Python SDK.") - } - // 1. Start agent process (let Python SDK choose its own port) fmt.Printf("šŸ“” Starting agent process...\n") var agentStartErr error diff --git a/control-plane/internal/events/node_events.go b/control-plane/internal/events/node_events.go index 9d178bf04..86600da63 100644 --- a/control-plane/internal/events/node_events.go +++ b/control-plane/internal/events/node_events.go @@ -19,8 +19,7 @@ const ( NodeStatusUpdated NodeEventType = "node_status_changed" NodeRemoved NodeEventType = "node_removed" NodeHealthChanged NodeEventType = "node_health_changed" - NodeMCPHealthChanged NodeEventType = "mcp_health_changed" - NodesRefresh NodeEventType = "nodes_refresh" + NodesRefresh NodeEventType = "nodes_refresh" NodeHeartbeat NodeEventType = "node_heartbeat" // New unified status events @@ -199,20 +198,6 @@ func PublishNodeHealthChanged(nodeID, healthStatus string, data interface{}) { GlobalNodeEventBus.Publish(event) } -// PublishNodeMCPHealthChanged publishes an MCP health change event -func PublishNodeMCPHealthChanged(nodeID string, data interface{}) { - event := NodeEvent{ - Type: NodeMCPHealthChanged, - NodeID: nodeID, - Timestamp: time.Now(), - Data: data, - } - - logger.Logger.Debug().Msgf("šŸ” NODE_EVENT_DEBUG: Publishing NodeMCPHealthChanged event - NodeID: %s", nodeID) - - GlobalNodeEventBus.Publish(event) -} - // PublishNodeRemoved publishes a node removed event func PublishNodeRemoved(nodeID string, data interface{}) { event := NodeEvent{ diff --git a/control-plane/internal/handlers/nodes.go b/control-plane/internal/handlers/nodes.go index 48aa48abd..24e64cf8a 100644 --- a/control-plane/internal/handlers/nodes.go +++ b/control-plane/internal/handlers/nodes.go @@ -208,7 +208,7 @@ func normalizeCandidate(raw string, defaultPort string) (string, error) { func probeCandidate(ctx context.Context, client *http.Client, baseURL string) types.CallbackTestResult { result := types.CallbackTestResult{URL: baseURL} trimmedBase := strings.TrimSuffix(baseURL, "/") - endpoints := []string{"/health/mcp", "/health"} + endpoints := []string{"/health"} for _, endpoint := range endpoints { start := time.Now() @@ -282,11 +282,6 @@ type CachedNodeData struct { LastDBUpdate time.Time LastCacheUpdate time.Time Status string - MCPServers []struct { - Alias string `json:"alias"` - Status string `json:"status"` - ToolCount int `json:"tool_count"` - } } // HeartbeatCache manages cached heartbeat data to reduce database writes @@ -306,11 +301,7 @@ var ( ) // shouldUpdateDatabase determines if a heartbeat should trigger a database update -func (hc *HeartbeatCache) shouldUpdateDatabase(nodeID string, now time.Time, status string, mcpServers []struct { - Alias string `json:"alias"` - Status string `json:"status"` - ToolCount int `json:"tool_count"` -}) (bool, *CachedNodeData) { +func (hc *HeartbeatCache) shouldUpdateDatabase(nodeID string, now time.Time, status string) (bool, *CachedNodeData) { hc.mutex.Lock() defer hc.mutex.Unlock() @@ -321,7 +312,6 @@ func (hc *HeartbeatCache) shouldUpdateDatabase(nodeID string, now time.Time, sta LastDBUpdate: now, LastCacheUpdate: now, Status: status, - MCPServers: mcpServers, } hc.nodes[nodeID] = cached return true, cached @@ -330,7 +320,6 @@ func (hc *HeartbeatCache) shouldUpdateDatabase(nodeID string, now time.Time, sta // Update cache timestamp cached.LastCacheUpdate = now cached.Status = status - cached.MCPServers = mcpServers // Check if enough time has passed since last DB update timeSinceDBUpdate := now.Sub(cached.LastDBUpdate) @@ -818,13 +807,8 @@ func HeartbeatHandler(storageProvider storage.StorageProvider, uiService *servic // Try to parse enhanced heartbeat data (optional) var enhancedHeartbeat struct { - Version string `json:"version,omitempty"` - Status string `json:"status,omitempty"` - MCPServers []struct { - Alias string `json:"alias"` - Status string `json:"status"` - ToolCount int `json:"tool_count"` - } `json:"mcp_servers,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status,omitempty"` Timestamp string `json:"timestamp,omitempty"` HealthScore *int `json:"health_score,omitempty"` } @@ -844,7 +828,7 @@ func HeartbeatHandler(storageProvider storage.StorageProvider, uiService *servic if presenceManager != nil && presenceManager.HasLease(nodeID) { presenceManager.Touch(nodeID, enhancedHeartbeat.Version, now) } - needsDBUpdate, cached := heartbeatCache.shouldUpdateDatabase(nodeID, now, enhancedHeartbeat.Status, enhancedHeartbeat.MCPServers) + needsDBUpdate, cached := heartbeatCache.shouldUpdateDatabase(nodeID, now, enhancedHeartbeat.Status) if needsDBUpdate { // Verify node exists only when we need to update DB. @@ -888,7 +872,7 @@ func HeartbeatHandler(storageProvider storage.StorageProvider, uiService *servic } // Process enhanced heartbeat data through unified status system - if statusManager != nil && (enhancedHeartbeat.Status != "" || len(enhancedHeartbeat.MCPServers) > 0 || enhancedHeartbeat.HealthScore != nil) { + if statusManager != nil && (enhancedHeartbeat.Status != "" || enhancedHeartbeat.HealthScore != nil) { // Prepare lifecycle status var lifecycleStatus *types.AgentLifecycleStatus if enhancedHeartbeat.Status != "" { @@ -922,44 +906,6 @@ func HeartbeatHandler(storageProvider storage.StorageProvider, uiService *servic } } - // Prepare MCP status - var mcpStatus *types.MCPStatusInfo - if len(enhancedHeartbeat.MCPServers) > 0 { - totalServers := len(enhancedHeartbeat.MCPServers) - runningServers := 0 - totalTools := 0 - - for _, server := range enhancedHeartbeat.MCPServers { - if server.Status == "running" { - runningServers++ - } - totalTools += server.ToolCount - } - - // Calculate overall health based on running servers - overallHealth := 0.0 - if totalServers > 0 { - overallHealth = float64(runningServers) / float64(totalServers) - } - - // Determine service status - serviceStatus := "unavailable" - if overallHealth >= 0.9 { - serviceStatus = "ready" - } else if overallHealth >= 0.5 { - serviceStatus = "degraded" - } - - mcpStatus = &types.MCPStatusInfo{ - TotalServers: totalServers, - RunningServers: runningServers, - TotalTools: totalTools, - OverallHealth: overallHealth, - ServiceStatus: serviceStatus, - LastChecked: now, - } - } - // Resolve version from DB record when available, fall back to heartbeat payload resolvedVersion := enhancedHeartbeat.Version if existingNode != nil { @@ -967,7 +913,7 @@ func HeartbeatHandler(storageProvider storage.StorageProvider, uiService *servic } // Update status through unified system - if err := statusManager.UpdateFromHeartbeat(ctx, nodeID, lifecycleStatus, mcpStatus, resolvedVersion); err != nil { + if err := statusManager.UpdateFromHeartbeat(ctx, nodeID, lifecycleStatus, resolvedVersion); err != nil { logger.Logger.Error().Err(err).Msgf("āŒ Failed to update unified status for node %s", nodeID) // Continue processing - don't fail the heartbeat } @@ -1054,10 +1000,6 @@ func UpdateLifecycleStatusHandler(storageProvider storage.StorageProvider, uiSer var statusUpdate struct { LifecycleStatus string `json:"lifecycle_status" binding:"required"` - MCPServers *struct { - Total int `json:"total"` - Running int `json:"running"` - } `json:"mcp_servers,omitempty"` } if err := c.ShouldBindJSON(&statusUpdate); err != nil { @@ -1096,34 +1038,9 @@ func UpdateLifecycleStatusHandler(storageProvider storage.StorageProvider, uiSer return } - // Prepare MCP status if provided - var mcpStatus *types.MCPStatusInfo - if statusUpdate.MCPServers != nil { - overallHealth := 0.0 - serviceStatus := "unavailable" - - if statusUpdate.MCPServers.Total > 0 { - overallHealth = float64(statusUpdate.MCPServers.Running) / float64(statusUpdate.MCPServers.Total) - if overallHealth >= 0.9 { - serviceStatus = "ready" - } else if overallHealth >= 0.5 { - serviceStatus = "degraded" - } - } - - mcpStatus = &types.MCPStatusInfo{ - TotalServers: statusUpdate.MCPServers.Total, - RunningServers: statusUpdate.MCPServers.Running, - TotalTools: 0, // Not provided in this endpoint - OverallHealth: overallHealth, - ServiceStatus: serviceStatus, - LastChecked: time.Now(), - } - } - // Update through unified status system if available if statusManager != nil { - if err := statusManager.UpdateFromHeartbeat(ctx, nodeID, &newLifecycleStatus, mcpStatus, ""); err != nil { + if err := statusManager.UpdateFromHeartbeat(ctx, nodeID, &newLifecycleStatus, ""); err != nil { logger.Logger.Error().Err(err).Msgf("āŒ Failed to update unified status for node %s", nodeID) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update status"}) return diff --git a/control-plane/internal/handlers/nodes_discovery_test.go b/control-plane/internal/handlers/nodes_discovery_test.go index 306f646b5..786fe08b3 100644 --- a/control-plane/internal/handlers/nodes_discovery_test.go +++ b/control-plane/internal/handlers/nodes_discovery_test.go @@ -27,7 +27,7 @@ func TestNormalizeCandidateAddsDefaults(t *testing.T) { func TestResolveCallbackCandidatesSuccess(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/health/mcp" || r.URL.Path == "/health" { + if r.URL.Path == "/health" { w.WriteHeader(http.StatusOK) return } diff --git a/control-plane/internal/handlers/ui/api_test.go b/control-plane/internal/handlers/ui/api_test.go index f802e1873..883e8d472 100644 --- a/control-plane/internal/handlers/ui/api_test.go +++ b/control-plane/internal/handlers/ui/api_test.go @@ -474,18 +474,6 @@ func (m *MockAgentClientForUI) GetAgentStatus(ctx context.Context, nodeID string return args.Get(0).(*interfaces.AgentStatusResponse), args.Error(1) } -func (m *MockAgentClientForUI) GetMCPHealth(ctx context.Context, nodeID string) (*interfaces.MCPHealthResponse, error) { - return nil, nil -} - -func (m *MockAgentClientForUI) RestartMCPServer(ctx context.Context, nodeID, alias string) error { - return nil -} - -func (m *MockAgentClientForUI) GetMCPTools(ctx context.Context, nodeID, alias string) (*interfaces.MCPToolsResponse, error) { - return nil, nil -} - func (m *MockAgentClientForUI) ShutdownAgent(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { return nil, nil } diff --git a/control-plane/internal/handlers/ui/mcp.go b/control-plane/internal/handlers/ui/mcp.go deleted file mode 100644 index 29f89194f..000000000 --- a/control-plane/internal/handlers/ui/mcp.go +++ /dev/null @@ -1,319 +0,0 @@ -package ui - -import ( - "net/http" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" - "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" - "github.com/Agent-Field/agentfield/control-plane/internal/services" - - "github.com/gin-gonic/gin" -) - -// MCPHandler provides handlers for MCP-related operations -type MCPHandler struct { - uiService *services.UIService - agentClient interfaces.AgentClient -} - -// NewMCPHandler creates a new MCPHandler -func NewMCPHandler(uiService *services.UIService, agentClient interfaces.AgentClient) *MCPHandler { - return &MCPHandler{ - uiService: uiService, - agentClient: agentClient, - } -} - -// GetMCPHealthHandler handles requests for detailed MCP health information -// GET /api/ui/v1/nodes/{nodeId}/mcp/health?mode=developer|user -func (h *MCPHandler) GetMCPHealthHandler(c *gin.Context) { - ctx := c.Request.Context() - nodeID := c.Param("nodeId") - if nodeID == "" { - RespondBadRequest(c, "nodeId is required") - return - } - - // Get mode parameter (default to developer) - modeParam := c.DefaultQuery("mode", "developer") - var mode domain.MCPHealthMode - switch modeParam { - case "user": - mode = domain.MCPHealthModeUser - case "developer": - mode = domain.MCPHealthModeDeveloper - default: - RespondBadRequest(c, "mode must be 'user' or 'developer'") - return - } - - // Get detailed node information with MCP data - nodeDetails, err := h.uiService.GetNodeDetailsWithMCP(ctx, nodeID, mode) - if err != nil { - RespondNotFound(c, "node not found or failed to retrieve MCP health") - return - } - - // Return the appropriate response based on mode - if mode == domain.MCPHealthModeUser { - // User mode: return only summary - response := map[string]interface{}{ - "node_id": nodeID, - "mcp_summary": nodeDetails.MCPSummary, - "timestamp": time.Now(), - } - c.JSON(http.StatusOK, response) - } else { - // Developer mode: return full details - response := map[string]interface{}{ - "node_id": nodeID, - "mcp_summary": nodeDetails.MCPSummary, - "mcp_servers": nodeDetails.MCPServers, - "timestamp": time.Now(), - } - c.JSON(http.StatusOK, response) - } -} - -// RestartMCPServerHandler handles requests to restart a specific MCP server -// POST /api/ui/v1/nodes/{nodeId}/mcp/servers/{alias}/restart -func (h *MCPHandler) RestartMCPServerHandler(c *gin.Context) { - nodeID := c.Param("nodeId") - alias := c.Param("alias") - - if nodeID == "" { - RespondBadRequest(c, "nodeId is required") - return - } - - if alias == "" { - RespondBadRequest(c, "alias is required") - return - } - - // Check mode - only allow in developer mode - mode := c.DefaultQuery("mode", "developer") - if mode != "developer" { - c.JSON(http.StatusForbidden, ErrorResponse{Error: "MCP server restart is only available in developer mode"}) - return - } - - // Create context with timeout - ctx := c.Request.Context() - - // Call agent client to restart the MCP server - err := h.agentClient.RestartMCPServer(ctx, nodeID, alias) - if err != nil { - RespondInternalError(c, "failed to restart MCP server: "+err.Error()) - return - } - - // Return success response - response := map[string]interface{}{ - "success": true, - "message": "MCP server restart initiated successfully", - "node_id": nodeID, - "alias": alias, - "timestamp": time.Now(), - } - - c.JSON(http.StatusOK, response) -} - -// GetMCPToolsHandler handles requests to get tools from a specific MCP server -// GET /api/ui/v1/nodes/{nodeId}/mcp/servers/{alias}/tools -func (h *MCPHandler) GetMCPToolsHandler(c *gin.Context) { - nodeID := c.Param("nodeId") - alias := c.Param("alias") - - if nodeID == "" { - RespondBadRequest(c, "nodeId is required") - return - } - - if alias == "" { - RespondBadRequest(c, "alias is required") - return - } - - // Check mode - only allow in developer mode - mode := c.DefaultQuery("mode", "developer") - if mode != "developer" { - c.JSON(http.StatusForbidden, ErrorResponse{Error: "MCP tools listing is only available in developer mode"}) - return - } - - // Create context with timeout - ctx := c.Request.Context() - - // Call agent client to get MCP tools - toolsResponse, err := h.agentClient.GetMCPTools(ctx, nodeID, alias) - if err != nil { - RespondInternalError(c, "failed to get MCP tools: "+err.Error()) - return - } - - // Return tools response - response := map[string]interface{}{ - "node_id": nodeID, - "alias": alias, - "tools": toolsResponse.Tools, - "count": len(toolsResponse.Tools), - "timestamp": time.Now(), - } - - c.JSON(http.StatusOK, response) -} - -// GetMCPStatusHandler handles requests for overall MCP status across all nodes -// GET /api/ui/v1/mcp/status -func (h *MCPHandler) GetMCPStatusHandler(c *gin.Context) { - ctx := c.Request.Context() - // Get mode parameter - modeParam := c.DefaultQuery("mode", "user") - var mode domain.MCPHealthMode - switch modeParam { - case "user": - mode = domain.MCPHealthModeUser - case "developer": - mode = domain.MCPHealthModeDeveloper - default: - RespondBadRequest(c, "mode must be 'user' or 'developer'") - return - } - - // Get all node summaries (which now include MCP data) - summaries, _, err := h.uiService.GetNodesSummary(ctx) - if err != nil { - RespondInternalError(c, "failed to get nodes summary") - return - } - - // Aggregate MCP status across all nodes - totalNodes := 0 - nodesWithMCP := 0 - totalMCPServers := 0 - runningMCPServers := 0 - totalTools := 0 - var overallHealth float64 = 1.0 - - for _, summary := range summaries { - totalNodes++ - if summary.MCPSummary != nil { - nodesWithMCP++ - totalMCPServers += summary.MCPSummary.TotalServers - runningMCPServers += summary.MCPSummary.RunningServers - totalTools += summary.MCPSummary.TotalTools - - // Calculate weighted average health - if summary.MCPSummary.TotalServers > 0 { - overallHealth = (overallHealth + summary.MCPSummary.OverallHealth) / 2 - } - } - } - - // Build response - response := map[string]interface{}{ - "total_nodes": totalNodes, - "nodes_with_mcp": nodesWithMCP, - "total_mcp_servers": totalMCPServers, - "running_mcp_servers": runningMCPServers, - "total_tools": totalTools, - "overall_health": overallHealth, - "timestamp": time.Now(), - "mode": mode, - } - - // Add detailed node data for developer mode - if mode == domain.MCPHealthModeDeveloper { - nodeDetails := make([]map[string]interface{}, 0) - for _, summary := range summaries { - nodeDetail := map[string]interface{}{ - "node_id": summary.ID, - "team_id": summary.TeamID, - "version": summary.Version, - "health": summary.HealthStatus, - "mcp_summary": summary.MCPSummary, - } - nodeDetails = append(nodeDetails, nodeDetail) - } - response["nodes"] = nodeDetails - } - - c.JSON(http.StatusOK, response) -} - -// GetMCPEventsHandler handles requests for MCP events (SSE endpoint) -// GET /api/ui/v1/nodes/{nodeId}/mcp/events -func (h *MCPHandler) GetMCPEventsHandler(c *gin.Context) { - nodeID := c.Param("nodeId") - if nodeID == "" { - RespondBadRequest(c, "nodeId is required") - return - } - - // Set SSE headers - c.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - - // Get the response writer - w := c.Writer - - // Send initial connection event - initialEvent := map[string]interface{}{ - "type": "connection", - "node_id": nodeID, - "timestamp": time.Now().Format(time.RFC3339), - "message": "Connected to MCP events stream", - } - - // Write SSE formatted data - c.SSEvent("mcp-event", initialEvent) - w.Flush() - - // Keep connection alive with periodic heartbeat - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - // Create a channel to handle client disconnect - clientGone := c.Request.Context().Done() - - for { - select { - case <-clientGone: - // Client disconnected - return - case <-ticker.C: - // Send heartbeat - heartbeat := map[string]interface{}{ - "type": "heartbeat", - "node_id": nodeID, - "timestamp": time.Now().Format(time.RFC3339), - } - c.SSEvent("heartbeat", heartbeat) - w.Flush() - } - } -} - -// GetMCPMetricsHandler handles requests for MCP metrics -// GET /api/ui/v1/nodes/{nodeId}/mcp/metrics -func (h *MCPHandler) GetMCPMetricsHandler(c *gin.Context) { - nodeID := c.Param("nodeId") - if nodeID == "" { - RespondBadRequest(c, "nodeId is required") - return - } - - // For now, return empty metrics as this endpoint is not fully implemented - // TODO: Implement real MCP metrics collection - response := map[string]interface{}{ - "metrics": map[string]interface{}{}, - "node_id": nodeID, - "timestamp": time.Now(), - } - - c.JSON(http.StatusOK, response) -} diff --git a/control-plane/internal/handlers/ui/packages.go b/control-plane/internal/handlers/ui/packages.go index de8546e40..caee162ac 100644 --- a/control-plane/internal/handlers/ui/packages.go +++ b/control-plane/internal/handlers/ui/packages.go @@ -69,9 +69,8 @@ type PackageConfiguration struct { // PackageCapabilities represents package capabilities type PackageCapabilities struct { - Reasoners []ReasonerDefinition `json:"reasoners"` - Skills []SkillDefinition `json:"skills"` - MCPServers []MCPServerDefinition `json:"mcp_servers"` + Reasoners []ReasonerDefinition `json:"reasoners"` + Skills []SkillDefinition `json:"skills"` } // ReasonerDefinition represents a reasoner definition @@ -93,14 +92,6 @@ type SkillDefinition struct { Tags []string `json:"tags"` } -// MCPServerDefinition represents an MCP server definition -type MCPServerDefinition struct { - Alias string `json:"alias"` - Command []string `json:"command"` - Description string `json:"description"` - Tools []string `json:"tools"` -} - // PackageRuntime represents runtime information type PackageRuntime struct { ProcessID int `json:"process_id,omitempty"` @@ -245,7 +236,7 @@ func (h *PackageHandler) GetPackageDetailsHandler(c *gin.Context) { } // TODO: Add capabilities parsing when agent introspection is implemented - // This would parse reasoners, skills, and MCP servers from the package + // This would parse reasoners and skills from the package // TODO: Add runtime information when agent lifecycle management is implemented // This would include process information, logs, etc. diff --git a/control-plane/internal/infrastructure/communication/agent_client.go b/control-plane/internal/infrastructure/communication/agent_client.go index 3e5f01531..6366dba18 100644 --- a/control-plane/internal/infrastructure/communication/agent_client.go +++ b/control-plane/internal/infrastructure/communication/agent_client.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "strings" - "sync" "time" "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" @@ -20,16 +19,6 @@ type HTTPAgentClient struct { httpClient *http.Client storage storage.StorageProvider timeout time.Duration - - // Cache for MCP health data (30-second TTL) - cache map[string]*CachedMCPHealth - cacheMutex sync.RWMutex -} - -// CachedMCPHealth represents cached MCP health data -type CachedMCPHealth struct { - Data *interfaces.MCPHealthResponse - Timestamp time.Time } // NewHTTPAgentClient creates a new HTTP-based agent client @@ -42,173 +31,9 @@ func NewHTTPAgentClient(storage storage.StorageProvider, timeout time.Duration) httpClient: &http.Client{ Timeout: timeout, }, - storage: storage, - timeout: timeout, - cache: make(map[string]*CachedMCPHealth), - cacheMutex: sync.RWMutex{}, - } -} - -// GetMCPHealth retrieves MCP health information from an agent node -func (c *HTTPAgentClient) GetMCPHealth(ctx context.Context, nodeID string) (*interfaces.MCPHealthResponse, error) { - // Check cache first - if cached := c.getCachedHealth(nodeID); cached != nil { - return cached, nil - } - - // Get agent node details - agent, err := c.storage.GetAgent(ctx, nodeID) - if err != nil { - return nil, fmt.Errorf("failed to get agent node %s: %w", nodeID, err) - } - - // Construct health endpoint URL - healthURL := fmt.Sprintf("%s/health/mcp", agent.BaseURL) - - // Create HTTP request with context - req, err := http.NewRequestWithContext(ctx, "GET", healthURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "AgentField-Server/1.0") - - // Make the request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to call agent health endpoint: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode == http.StatusNotFound { - // Agent doesn't support MCP health endpoint - return empty response - return &interfaces.MCPHealthResponse{ - Servers: []interfaces.MCPServerHealth{}, - Summary: interfaces.MCPSummary{ - TotalServers: 0, - RunningServers: 0, - TotalTools: 0, - OverallHealth: 1.0, // Consider healthy if no MCP servers - }, - }, nil - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("agent returned status %d", resp.StatusCode) + storage: storage, + timeout: timeout, } - - // Parse response - var healthResponse interfaces.MCPHealthResponse - if err := json.NewDecoder(resp.Body).Decode(&healthResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - // Cache the result - c.cacheHealth(nodeID, &healthResponse) - - return &healthResponse, nil -} - -// RestartMCPServer restarts a specific MCP server on an agent node -func (c *HTTPAgentClient) RestartMCPServer(ctx context.Context, nodeID, alias string) error { - // Get agent node details - agent, err := c.storage.GetAgent(ctx, nodeID) - if err != nil { - return fmt.Errorf("failed to get agent node %s: %w", nodeID, err) - } - - // Construct restart endpoint URL - restartURL := fmt.Sprintf("%s/mcp/servers/%s/restart", agent.BaseURL, alias) - - // Create HTTP request with context - req, err := http.NewRequestWithContext(ctx, "POST", restartURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "AgentField-Server/1.0") - - // Make the request - resp, err := c.httpClient.Do(req) - if err != nil { - return fmt.Errorf("failed to call agent restart endpoint: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode == http.StatusNotFound { - return fmt.Errorf("agent does not support MCP server restart or server %s not found", alias) - } - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("agent returned status %d", resp.StatusCode) - } - - // Parse response - var restartResponse interfaces.MCPRestartResponse - if err := json.NewDecoder(resp.Body).Decode(&restartResponse); err != nil { - return fmt.Errorf("failed to decode response: %w", err) - } - - if !restartResponse.Success { - return fmt.Errorf("restart failed: %s", restartResponse.Message) - } - - // Invalidate cache for this node - c.invalidateCache(nodeID) - - return nil -} - -// GetMCPTools retrieves the list of tools from a specific MCP server -func (c *HTTPAgentClient) GetMCPTools(ctx context.Context, nodeID, alias string) (*interfaces.MCPToolsResponse, error) { - // Get agent node details - agent, err := c.storage.GetAgent(ctx, nodeID) - if err != nil { - return nil, fmt.Errorf("failed to get agent node %s: %w", nodeID, err) - } - - // Construct tools endpoint URL - toolsURL := fmt.Sprintf("%s/mcp/servers/%s/tools", agent.BaseURL, alias) - - // Create HTTP request with context - req, err := http.NewRequestWithContext(ctx, "GET", toolsURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "AgentField-Server/1.0") - - // Make the request - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to call agent tools endpoint: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode == http.StatusNotFound { - return nil, fmt.Errorf("agent does not support MCP tools endpoint or server %s not found", alias) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("agent returned status %d", resp.StatusCode) - } - - // Parse response - var toolsResponse interfaces.MCPToolsResponse - if err := json.NewDecoder(resp.Body).Decode(&toolsResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &toolsResponse, nil } // ShutdownAgent requests graceful shutdown of an agent node via HTTP @@ -351,88 +176,6 @@ func (c *HTTPAgentClient) GetAgentStatus(ctx context.Context, nodeID string) (*i return nil, fmt.Errorf("failed after %d retries, last error: %w", maxRetries+1, lastErr) } -// getCachedHealth retrieves cached MCP health data if still valid -func (c *HTTPAgentClient) getCachedHealth(nodeID string) *interfaces.MCPHealthResponse { - c.cacheMutex.RLock() - defer c.cacheMutex.RUnlock() - - cached, exists := c.cache[nodeID] - if !exists { - return nil - } - - // Check if cache is still valid (30 seconds) - if time.Since(cached.Timestamp) > 30*time.Second { - // Cache expired, remove it - delete(c.cache, nodeID) - return nil - } - - return cached.Data -} - -// cacheHealth stores MCP health data in cache -func (c *HTTPAgentClient) cacheHealth(nodeID string, data *interfaces.MCPHealthResponse) { - c.cacheMutex.Lock() - defer c.cacheMutex.Unlock() - - c.cache[nodeID] = &CachedMCPHealth{ - Data: data, - Timestamp: time.Now(), - } -} - -// invalidateCache removes cached data for a specific node -func (c *HTTPAgentClient) invalidateCache(nodeID string) { - c.cacheMutex.Lock() - defer c.cacheMutex.Unlock() - - delete(c.cache, nodeID) -} - -// InvalidateAllCache removes all cached data (useful for testing or manual refresh) -func (c *HTTPAgentClient) InvalidateAllCache() { - c.cacheMutex.Lock() - defer c.cacheMutex.Unlock() - - c.cache = make(map[string]*CachedMCPHealth) -} - -// GetCacheStats returns cache statistics for monitoring -func (c *HTTPAgentClient) GetCacheStats() map[string]interface{} { - c.cacheMutex.RLock() - defer c.cacheMutex.RUnlock() - - stats := map[string]interface{}{ - "total_entries": len(c.cache), - "entries": make([]map[string]interface{}, 0, len(c.cache)), - } - - for nodeID, cached := range c.cache { - entry := map[string]interface{}{ - "node_id": nodeID, - "timestamp": cached.Timestamp, - "age_seconds": time.Since(cached.Timestamp).Seconds(), - } - stats["entries"] = append(stats["entries"].([]map[string]interface{}), entry) - } - - return stats -} - -// CleanupExpiredCache removes expired cache entries (should be called periodically) -func (c *HTTPAgentClient) CleanupExpiredCache() { - c.cacheMutex.Lock() - defer c.cacheMutex.Unlock() - - now := time.Now() - for nodeID, cached := range c.cache { - if now.Sub(cached.Timestamp) > 30*time.Second { - delete(c.cache, nodeID) - } - } -} - // isRetryableError determines if an error is worth retrying func isRetryableError(err error) bool { // Check for common transient network errors diff --git a/control-plane/internal/infrastructure/communication/agent_client_test.go b/control-plane/internal/infrastructure/communication/agent_client_test.go index 0ad5cdd29..3547ffe6a 100644 --- a/control-plane/internal/infrastructure/communication/agent_client_test.go +++ b/control-plane/internal/infrastructure/communication/agent_client_test.go @@ -2,7 +2,6 @@ package communication import ( "context" - "encoding/json" "errors" "net/http" "net/http/httptest" @@ -12,7 +11,6 @@ import ( "testing" "time" - "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" "github.com/Agent-Field/agentfield/control-plane/internal/storage" "github.com/Agent-Field/agentfield/control-plane/pkg/types" "github.com/stretchr/testify/assert" @@ -64,163 +62,6 @@ func registerAgent(t *testing.T, ctx context.Context, provider storage.StoragePr return agent.ID } -func TestHTTPAgentClient_GetMCPHealthCachesAndExpires(t *testing.T) { - ctx := context.Background() - - var healthCalls int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&healthCalls, 1) - assert.Equal(t, "/health/mcp", r.URL.Path) - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(interfaces.MCPHealthResponse{ - Servers: []interfaces.MCPServerHealth{ - { - Alias: "default", - Status: "running", - ToolCount: 2, - SuccessRate: 0.95, - }, - }, - Summary: interfaces.MCPSummary{ - TotalServers: 1, - RunningServers: 1, - TotalTools: 2, - OverallHealth: 1.0, - }, - })) - })) - defer server.Close() - - provider := setupTestStorage(t, ctx) - agentID := registerAgent(t, ctx, provider, server.URL) - client := NewHTTPAgentClient(provider, time.Second) - - resp1, err := client.GetMCPHealth(ctx, agentID) - require.NoError(t, err) - assert.Equal(t, 1, int(atomic.LoadInt64(&healthCalls))) - assert.Equal(t, 1, resp1.Summary.TotalServers) - - resp2, err := client.GetMCPHealth(ctx, agentID) - require.NoError(t, err) - assert.Equal(t, 1, int(atomic.LoadInt64(&healthCalls)), "second call should hit cache") - assert.Equal(t, resp1.Summary, resp2.Summary) - - stats := client.GetCacheStats() - require.Equal(t, 1, stats["total_entries"]) - - client.cacheMutex.Lock() - cached := client.cache[agentID] - require.NotNil(t, cached) - cached.Timestamp = time.Now().Add(-31 * time.Second) - client.cacheMutex.Unlock() - - client.CleanupExpiredCache() - - stats = client.GetCacheStats() - require.Equal(t, 0, stats["total_entries"]) - - _, err = client.GetMCPHealth(ctx, agentID) - require.NoError(t, err) - assert.Equal(t, 2, int(atomic.LoadInt64(&healthCalls)), "cache expiration should trigger a fresh call") -} - -func TestHTTPAgentClient_GetMCPHealthHandlesNotFound(t *testing.T) { - ctx := context.Background() - - var healthCalls int64 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&healthCalls, 1) - http.NotFound(w, r) - })) - defer server.Close() - - provider := setupTestStorage(t, ctx) - agentID := registerAgent(t, ctx, provider, server.URL) - client := NewHTTPAgentClient(provider, time.Second) - - resp, err := client.GetMCPHealth(ctx, agentID) - require.NoError(t, err) - assert.Equal(t, 1, int(atomic.LoadInt64(&healthCalls))) - assert.NotNil(t, resp) - assert.Equal(t, 0, resp.Summary.TotalServers) - assert.Equal(t, 1.0, resp.Summary.OverallHealth) - assert.Empty(t, resp.Servers) -} - -func TestHTTPAgentClient_RestartMCPServerInvalidatesCache(t *testing.T) { - ctx := context.Background() - - var healthCalls, restartCalls int64 - mux := http.NewServeMux() - mux.HandleFunc("/health/mcp", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&healthCalls, 1) - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(interfaces.MCPHealthResponse{ - Servers: []interfaces.MCPServerHealth{}, - Summary: interfaces.MCPSummary{TotalServers: 0, OverallHealth: 1.0}, - })) - }) - mux.HandleFunc("/mcp/servers/search/restart", func(w http.ResponseWriter, r *http.Request) { - atomic.AddInt64(&restartCalls, 1) - assert.Equal(t, http.MethodPost, r.Method) - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(interfaces.MCPRestartResponse{ - Success: true, - Message: "restarted", - })) - }) - - server := httptest.NewServer(mux) - defer server.Close() - - provider := setupTestStorage(t, ctx) - agentID := registerAgent(t, ctx, provider, server.URL) - client := NewHTTPAgentClient(provider, time.Second) - - _, err := client.GetMCPHealth(ctx, agentID) - require.NoError(t, err) - - client.cacheMutex.RLock() - _, exists := client.cache[agentID] - client.cacheMutex.RUnlock() - require.True(t, exists) - - err = client.RestartMCPServer(ctx, agentID, "search") - require.NoError(t, err) - assert.Equal(t, 1, int(atomic.LoadInt64(&restartCalls))) - - client.cacheMutex.RLock() - _, exists = client.cache[agentID] - client.cacheMutex.RUnlock() - assert.False(t, exists, "cache should be invalidated after restart") - - _, err = client.GetMCPHealth(ctx, agentID) - require.NoError(t, err) - assert.Equal(t, 2, int(atomic.LoadInt64(&healthCalls)), "health endpoint should be called again after restart") -} - -func TestHTTPAgentClient_RestartMCPServerPropagatesFailure(t *testing.T) { - ctx := context.Background() - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Header().Set("Content-Type", "application/json") - require.NoError(t, json.NewEncoder(w).Encode(interfaces.MCPRestartResponse{ - Success: false, - Message: "unable to restart", - })) - })) - defer server.Close() - - provider := setupTestStorage(t, ctx) - agentID := registerAgent(t, ctx, provider, server.URL) - client := NewHTTPAgentClient(provider, time.Second) - - err := client.RestartMCPServer(ctx, agentID, "search") - require.Error(t, err) - assert.Contains(t, err.Error(), "unable to restart") -} - type flakyTransport struct { attempts int32 } diff --git a/control-plane/internal/infrastructure/storage/config.go b/control-plane/internal/infrastructure/storage/config.go index 78318e8e7..54671e55b 100644 --- a/control-plane/internal/infrastructure/storage/config.go +++ b/control-plane/internal/infrastructure/storage/config.go @@ -23,9 +23,6 @@ func (s *LocalConfigStorage) LoadAgentFieldConfig(path string) (*domain.AgentFie return &domain.AgentFieldConfig{ HomeDir: filepath.Dir(path), Environment: make(map[string]string), - MCP: domain.MCPConfig{ - Servers: []domain.MCPServer{}, - }, }, nil } diff --git a/control-plane/internal/mcp/capability_discovery.go b/control-plane/internal/mcp/capability_discovery.go deleted file mode 100644 index eec9b7931..000000000 --- a/control-plane/internal/mcp/capability_discovery.go +++ /dev/null @@ -1,1389 +0,0 @@ -package mcp - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/config" -) - -// MCPCapability represents a discovered MCP server capability -type MCPCapability struct { - ServerAlias string `json:"server_alias"` - ServerName string `json:"server_name"` - Version string `json:"version"` - Tools []MCPTool `json:"tools"` - Resources []MCPResource `json:"resources"` - Endpoint string `json:"endpoint"` - Transport string `json:"transport"` -} - -// CapabilityDiscovery handles MCP server capability discovery -type CapabilityDiscovery struct { - projectPath string - Config *config.Config // Added to pass to factory -} - -// NewCapabilityDiscovery creates a new capability discovery instance -func NewCapabilityDiscovery(cfg *config.Config, projectPath string) *CapabilityDiscovery { - return &CapabilityDiscovery{ - projectPath: projectPath, - Config: cfg, - } -} - -// DiscoverCapabilities discovers capabilities from all installed MCP servers -func (cd *CapabilityDiscovery) DiscoverCapabilities() ([]MCPCapability, error) { - var capabilities []MCPCapability - - // Read MCP servers from packages/mcp directory - mcpDir := filepath.Join(cd.projectPath, "packages", "mcp") - if _, err := os.Stat(mcpDir); os.IsNotExist(err) { - return capabilities, nil // No MCP servers installed - } - - entries, err := os.ReadDir(mcpDir) - if err != nil { - return nil, fmt.Errorf("failed to read MCP directory: %w", err) - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - serverAlias := entry.Name() - capability, err := cd.discoverServerCapability(serverAlias) - if err != nil { - fmt.Printf("Warning: Failed to discover capabilities for %s: %v\n", serverAlias, err) - continue - } - - if capability != nil { - capabilities = append(capabilities, *capability) - } - } - - return capabilities, nil -} - -// migrateOldFormat migrates old mcp.json format to new config.json format -func (cd *CapabilityDiscovery) migrateOldFormat(serverDir string) error { - oldPath := filepath.Join(serverDir, "mcp.json") - newPath := filepath.Join(serverDir, "config.json") - - // Check if new format already exists - if _, err := os.Stat(newPath); err == nil { - return nil // Already migrated - } - - // Read old format - oldData, err := os.ReadFile(oldPath) - if err != nil { - return fmt.Errorf("failed to read mcp.json: %w", err) - } - - var oldFormat map[string]interface{} - if err := json.Unmarshal(oldData, &oldFormat); err != nil { - return fmt.Errorf("failed to parse mcp.json: %w", err) - } - - // Convert to new format - newConfig := MCPServerConfig{} - - if alias, ok := oldFormat["alias"].(string); ok { - newConfig.Alias = alias - } - - if startCmd, ok := oldFormat["start_command"].(string); ok { - newConfig.RunCmd = startCmd - } - - if source, ok := oldFormat["source"].(string); ok { - // If source looks like a URL, use it as URL, otherwise as run command - if strings.HasPrefix(source, "http") { - newConfig.URL = source - } - } - - if version, ok := oldFormat["version"].(string); ok { - newConfig.Version = version - } - - if healthCheck, ok := oldFormat["health_check"].(string); ok { - newConfig.HealthCheck = healthCheck - } - - // Convert environment variables - if env, ok := oldFormat["env"].(map[string]interface{}); ok { - newConfig.Env = make(map[string]string) - for k, v := range env { - if vStr, ok := v.(string); ok { - newConfig.Env[k] = vStr - } - } - } - - // Convert port if present - if port, ok := oldFormat["port"].(float64); ok { - newConfig.Port = int(port) - } - - // Convert install commands to setup commands - if installCmds, ok := oldFormat["install_commands"].([]interface{}); ok { - for _, cmd := range installCmds { - if cmdStr, ok := cmd.(string); ok { - newConfig.SetupCmds = append(newConfig.SetupCmds, cmdStr) - } - } - } - - // Save in new format - newData, err := json.MarshalIndent(newConfig, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal new config: %w", err) - } - - if err := os.WriteFile(newPath, newData, 0644); err != nil { - return fmt.Errorf("failed to write config.json: %w", err) - } - - // Remove old file - if err := os.Remove(oldPath); err != nil { - fmt.Printf("Warning: failed to remove old mcp.json: %v\n", err) - } - - fmt.Printf("Migrated %s from mcp.json to config.json format\n", filepath.Base(serverDir)) - return nil -} - -// discoverServerCapability discovers capabilities for a specific MCP server -func (cd *CapabilityDiscovery) discoverServerCapability(serverAlias string) (*MCPCapability, error) { - serverDir := filepath.Join(cd.projectPath, "packages", "mcp", serverAlias) - - // Try migration first if config.json doesn't exist - metadataPath := filepath.Join(serverDir, "config.json") - if _, err := os.Stat(metadataPath); os.IsNotExist(err) { - if err := cd.migrateOldFormat(serverDir); err != nil { - return nil, fmt.Errorf("failed to migrate old format: %w", err) - } - } - - // Read config.json metadata file - metadataBytes, err := os.ReadFile(metadataPath) - if err != nil { - return nil, fmt.Errorf("failed to read config.json (try running: af mcp migrate %s): %w", serverAlias, err) - } - - var metadata MCPServerConfig - if err := json.Unmarshal(metadataBytes, &metadata); err != nil { - return nil, fmt.Errorf("failed to parse metadata: %w", err) - } - - // Initialize capability structure - capability := &MCPCapability{ - ServerAlias: serverAlias, - ServerName: metadata.Alias, // Use alias as server name if no explicit name - Version: metadata.Version, - Transport: "stdio", - Tools: []MCPTool{}, - Resources: []MCPResource{}, - } - - // Set endpoint based on configuration - if metadata.URL != "" { - capability.Endpoint = metadata.URL - capability.Transport = "http" - capability.ServerName = metadata.URL - } else if metadata.RunCmd != "" { - capability.Endpoint = fmt.Sprintf("stdio://%s", metadata.RunCmd) - } - - // Try to load cached capabilities first - capabilitiesPath := filepath.Join(serverDir, "capabilities.json") - if capabilitiesBytes, err := os.ReadFile(capabilitiesPath); err == nil { - var cachedCapabilities struct { - Tools []struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]interface{} `json:"inputSchema"` - } `json:"tools"` - Resources []struct { - URI string `json:"uri"` - Name string `json:"name"` - Description string `json:"description"` - MimeType string `json:"mimeType,omitempty"` - } `json:"resources"` - } - - if err := json.Unmarshal(capabilitiesBytes, &cachedCapabilities); err == nil { - // Convert cached tools to MCPTool format - capability.Tools = make([]MCPTool, len(cachedCapabilities.Tools)) - for i, tool := range cachedCapabilities.Tools { - capability.Tools[i] = MCPTool{ - Name: tool.Name, - Description: tool.Description, - InputSchema: tool.InputSchema, - } - } - - // Convert cached resources to MCPResource format - capability.Resources = make([]MCPResource, len(cachedCapabilities.Resources)) - for i, resource := range cachedCapabilities.Resources { - capability.Resources[i] = MCPResource{ - URI: resource.URI, - Name: resource.Name, - Description: resource.Description, - MimeType: resource.MimeType, - } - } - - // If we have cached capabilities, return them - if len(capability.Tools) > 0 || len(capability.Resources) > 0 { - return capability, nil - } - } - } - - // If no cached capabilities or they're empty, try live discovery - liveTools, liveResources, err := cd.discoverLiveCapabilities(serverAlias, metadata) - if err != nil { - // Create a structured error for better error reporting - discoveryErr := CapabilityDiscoveryError(serverAlias, "live capability discovery failed", err) - - // Log the detailed error information - fmt.Printf("Warning: Failed to discover live capabilities for %s: %v\n", serverAlias, discoveryErr.Error()) - fmt.Printf("Detailed error: %s\n", discoveryErr.DetailedError()) - fmt.Printf("Suggestion: %s\n", discoveryErr.GetSuggestion()) - fmt.Printf("Falling back to static analysis for %s...\n", serverAlias) - - // Try static analysis as fallback - staticTools, staticResources, staticErr := cd.discoverFromStaticAnalysis(filepath.Join(cd.projectPath, "packages", "mcp", serverAlias), metadata) - if staticErr != nil { - staticAnalysisErr := CapabilityDiscoveryError(serverAlias, "static analysis fallback failed", staticErr) - fmt.Printf("Warning: Static analysis also failed for %s: %v\n", serverAlias, staticAnalysisErr.Error()) - fmt.Printf("Detailed error: %s\n", staticAnalysisErr.DetailedError()) - return capability, nil - } - - // Use static analysis results - liveTools = staticTools - liveResources = staticResources - } - - // Update capability with discovered data - capability.Tools = liveTools - capability.Resources = liveResources - - // Cache the discovered capabilities - if len(liveTools) > 0 || len(liveResources) > 0 { - if err := cd.CacheCapabilities(serverAlias, liveTools, liveResources); err != nil { - fmt.Printf("Warning: Failed to cache capabilities for %s: %v\n", serverAlias, err) - } - } - - // Update config file with detected transport type - if capability.Transport != "" { - if err := cd.updateConfigWithTransport(serverAlias, capability.Transport); err != nil { - fmt.Printf("Warning: failed to update transport in config for %s: %v\n", serverAlias, err) - } - } - - return capability, nil -} - -// CacheCapabilities saves discovered capabilities to cache -func (cd *CapabilityDiscovery) CacheCapabilities(serverAlias string, tools []MCPTool, resources []MCPResource) error { - serverDir := filepath.Join(cd.projectPath, "packages", "mcp", serverAlias) - - // Create a structure that matches our expected output format - type CachedTool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]interface{} `json:"inputSchema"` - } - - type CachedResource struct { - URI string `json:"uri"` - Name string `json:"name"` - Description string `json:"description"` - MimeType string `json:"mimeType,omitempty"` - } - - // Convert tools to cached format - cachedTools := make([]CachedTool, len(tools)) - for i, tool := range tools { - cachedTools[i] = CachedTool{ - Name: tool.Name, - Description: tool.Description, - InputSchema: tool.InputSchema, - } - } - - // Convert resources to cached format - cachedResources := make([]CachedResource, len(resources)) - for i, resource := range resources { - cachedResources[i] = CachedResource(resource) - } - - capabilities := struct { - Tools []CachedTool `json:"tools"` - Resources []CachedResource `json:"resources"` - UpdatedAt int64 `json:"updated_at"` - }{ - Tools: cachedTools, - Resources: cachedResources, - UpdatedAt: time.Now().Unix(), - } - - capabilitiesBytes, err := json.MarshalIndent(capabilities, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal capabilities: %w", err) - } - - capabilitiesPath := filepath.Join(serverDir, "capabilities.json") - if err := os.WriteFile(capabilitiesPath, capabilitiesBytes, 0644); err != nil { - return fmt.Errorf("failed to write capabilities cache: %w", err) - } - - return nil -} - -// GetServerCapability gets capability for a specific server -func (cd *CapabilityDiscovery) GetServerCapability(serverAlias string) (*MCPCapability, error) { - return cd.discoverServerCapability(serverAlias) -} - -// RefreshCapabilities forces refresh of capabilities for all servers -func (cd *CapabilityDiscovery) RefreshCapabilities() error { - capabilities, err := cd.DiscoverCapabilities() - if err != nil { - return err - } - - fmt.Printf("Discovered capabilities for %d MCP servers:\n", len(capabilities)) - for _, cap := range capabilities { - fmt.Printf("- %s: %d tools, %d resources\n", cap.ServerAlias, len(cap.Tools), len(cap.Resources)) - } - - return nil -} - -// discoverLiveCapabilities attempts to discover capabilities by running the MCP server -func (cd *CapabilityDiscovery) discoverLiveCapabilities(serverAlias string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - fmt.Printf("Attempting live capability discovery for %s...\n", serverAlias) - - if metadata.URL != "" { - // Remote MCP server - discover from URL - return cd.discoverFromURL(metadata.URL) - } else if metadata.RunCmd != "" { - // Local MCP server - start temporarily and discover - return cd.discoverFromLocalProcess(serverAlias, metadata) - } - - // No URL or run command - fall back to static analysis - fmt.Printf("No URL or run command for %s, using static analysis\n", serverAlias) - return cd.discoverFromStaticAnalysis(filepath.Join(cd.projectPath, "packages", "mcp", serverAlias), metadata) -} - -// discoverFromURL discovers capabilities from a remote MCP server URL -func (cd *CapabilityDiscovery) discoverFromURL(url string) ([]MCPTool, []MCPResource, error) { - fmt.Printf("Discovering capabilities from URL: %s\n", url) - - // TODO: Implement actual HTTP/WebSocket connection to MCP server - // For now, return empty capabilities - // This would involve: - // 1. Connect to the MCP server at the URL - // 2. Send MCP protocol messages to list tools and resources - // 3. Parse the responses - - return []MCPTool{}, []MCPResource{}, fmt.Errorf("URL-based discovery not yet implemented") -} - -// discoverFromLocalProcess discovers capabilities by temporarily starting a local MCP server -func (cd *CapabilityDiscovery) discoverFromLocalProcess(serverAlias string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - fmt.Printf("Discovering capabilities from local process for %s\n", serverAlias) - - // Try stdio discovery first (most common for local MCP servers) - tools, resources, err := cd.tryStdioDiscovery(serverAlias, metadata) - if err == nil { - fmt.Printf("Stdio discovery successful for %s: found %d tools, %d resources\n", serverAlias, len(tools), len(resources)) - return tools, resources, nil - } - - fmt.Printf("Stdio discovery failed for %s: %v\n", serverAlias, err) - fmt.Printf("Trying HTTP discovery as fallback for %s...\n", serverAlias) - - // Fallback to HTTP discovery - tools, resources, err = cd.tryHTTPDiscovery(serverAlias, metadata) - if err == nil { - fmt.Printf("HTTP discovery successful for %s: found %d tools, %d resources\n", serverAlias, len(tools), len(resources)) - return tools, resources, nil - } - - fmt.Printf("Both stdio and HTTP discovery failed for %s: %v\n", serverAlias, err) - fmt.Printf("Falling back to static analysis for %s...\n", serverAlias) - - // Fall back to static analysis - return cd.discoverFromStaticAnalysis(filepath.Join(cd.projectPath, "packages", "mcp", serverAlias), metadata) -} - -// tryStdioDiscovery attempts to discover capabilities using stdio transport with timeout -func (cd *CapabilityDiscovery) tryStdioDiscovery(serverAlias string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - fmt.Printf("Attempting stdio discovery for %s\n", serverAlias) - - // Create context with timeout for entire discovery operation (60 seconds) - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - // Create template processor - template := NewTemplateProcessor(cd.projectPath, false) // Non-verbose for discovery - - // Process template variables in the run command - vars := template.CreateTemplateVars(metadata, 0) // Port 0 for stdio-based servers - processedCmd, err := template.ProcessCommand(metadata.RunCmd, vars) - if err != nil { - return nil, nil, fmt.Errorf("failed to process run command template: %w", err) - } - - // Set working directory - workingDir := vars.ServerDir - if metadata.WorkingDir != "" { - processedWorkingDir, err := template.ProcessCommand(metadata.WorkingDir, vars) - if err != nil { - return nil, nil, fmt.Errorf("failed to process working directory: %w", err) - } - workingDir = processedWorkingDir - } - - // Create command with context timeout - cmd := exec.CommandContext(ctx, "sh", "-c", processedCmd) - cmd.Dir = workingDir - - // Set environment variables - if len(metadata.Env) > 0 { - processedEnv, err := template.ProcessEnvironment(metadata.Env, vars) - if err != nil { - return nil, nil, fmt.Errorf("failed to process environment variables: %w", err) - } - - cmd.Env = append(cmd.Env, os.Environ()...) - for key, value := range processedEnv { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) - } - } - - // Create pipes before starting the process - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, nil, fmt.Errorf("failed to create stdin pipe: %w", err) - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - stdin.Close() - return nil, nil, fmt.Errorf("failed to create stdout pipe: %w", err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - stdin.Close() - stdout.Close() - return nil, nil, fmt.Errorf("failed to create stderr pipe: %w", err) - } - - // Start the process - if err := cmd.Start(); err != nil { - stdin.Close() - stdout.Close() - stderr.Close() - return nil, nil, fmt.Errorf("failed to start MCP server: %w", err) - } - - // Ensure proper cleanup of all resources - defer func() { - stdin.Close() - stdout.Close() - stderr.Close() - - if cmd.Process != nil { - forceKill := func(reason string) { - if killErr := cmd.Process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { - fmt.Printf("āš ļø Failed to force kill MCP server (%s): %v\n", reason, killErr) - } - } - - // Try graceful shutdown first - if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { - // Force kill if graceful shutdown fails - forceKill("sigterm") - } else { - // Wait briefly for graceful shutdown - time.Sleep(1 * time.Second) - // Check if still running and force kill if needed - if cmd.ProcessState == nil || !cmd.ProcessState.Exited() { - forceKill("graceful timeout") - } - } - if waitErr := cmd.Wait(); waitErr != nil && !errors.Is(waitErr, os.ErrProcessDone) { - fmt.Printf("āš ļø MCP server wait returned error: %v\n", waitErr) - } - } - }() - - // Monitor stderr for debugging in a separate goroutine - var stderrOutput strings.Builder - go func() { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - line := scanner.Text() - stderrOutput.WriteString(line + "\n") - fmt.Printf("MCP stderr [%s]: %s\n", serverAlias, line) - } - }() - - // Wait a moment for the server to start - select { - case <-time.After(2 * time.Second): - // Continue with discovery - case <-ctx.Done(): - return nil, nil, fmt.Errorf("timeout waiting for server to start: %w", ctx.Err()) - } - - // Perform discovery with pipes using timeout-aware implementation - tools, resources, err := cd.performDiscoveryWithPipes(ctx, stdin, stdout, serverAlias) - if err != nil { - // Include stderr output in error for debugging - stderrStr := stderrOutput.String() - if stderrStr != "" { - return nil, nil, fmt.Errorf("stdio discovery failed: %w\nStderr output:\n%s", err, stderrStr) - } - return nil, nil, fmt.Errorf("stdio discovery failed: %w", err) - } - - return tools, resources, nil -} - -// performDiscoveryWithPipes performs MCP discovery using stdin/stdout pipes with timeout -func (cd *CapabilityDiscovery) performDiscoveryWithPipes(ctx context.Context, stdin io.WriteCloser, stdout io.ReadCloser, serverAlias string) ([]MCPTool, []MCPResource, error) { - // Create JSON encoder/decoder for line-buffered communication - encoder := json.NewEncoder(stdin) - scanner := bufio.NewScanner(stdout) - - // Helper function to read JSON response with timeout - readJSONResponse := func(timeout time.Duration) (*MCPResponse, error) { - responseCtx, responseCancel := context.WithTimeout(ctx, timeout) - defer responseCancel() - - responseChan := make(chan *MCPResponse, 1) - errorChan := make(chan error, 1) - - go func() { - if scanner.Scan() { - var response MCPResponse - if err := json.Unmarshal(scanner.Bytes(), &response); err != nil { - errorChan <- fmt.Errorf("failed to parse JSON response: %w", err) - return - } - responseChan <- &response - } else { - if err := scanner.Err(); err != nil { - errorChan <- fmt.Errorf("scanner error: %w", err) - } else { - errorChan <- fmt.Errorf("no response received") - } - } - }() - - select { - case response := <-responseChan: - return response, nil - case err := <-errorChan: - return nil, err - case <-responseCtx.Done(): - return nil, fmt.Errorf("timeout waiting for response: %w", responseCtx.Err()) - } - } - - // Step 1: Send initialize request (15 second timeout) - fmt.Printf("Sending initialize request to %s...\n", serverAlias) - initRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 1, - Method: "initialize", - Params: InitializeParams{ - ProtocolVersion: "2024-11-05", - Capabilities: map[string]interface{}{ - "roots": map[string]interface{}{ - "listChanged": true, - }, - }, - ClientInfo: ClientInfo{ - Name: "agentfield-mcp-client", - Version: "1.0.0", - }, - }, - } - - if err := encoder.Encode(initRequest); err != nil { - return nil, nil, fmt.Errorf("failed to send initialize request: %w", err) - } - - // Read initialize response - initResponse, err := readJSONResponse(15 * time.Second) - if err != nil { - return nil, nil, fmt.Errorf("failed to read initialize response: %w", err) - } - - if initResponse.Error != nil { - return nil, nil, fmt.Errorf("initialize failed: %s", initResponse.Error.Message) - } - - fmt.Printf("Initialize successful for %s, sending initialized notification...\n", serverAlias) - - // Step 2: Send initialized notification (must be a notification, not a request - no ID field) - initializedNotification := MCPNotification{ - JSONRPC: "2.0", - Method: "notifications/initialized", - Params: map[string]interface{}{}, - } - - if err := encoder.Encode(initializedNotification); err != nil { - return nil, nil, fmt.Errorf("failed to send initialized notification: %w", err) - } - - // Step 3: Request tools list (10 second timeout) - fmt.Printf("Requesting tools list from %s...\n", serverAlias) - toolsRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 2, - Method: "tools/list", - } - - if err := encoder.Encode(toolsRequest); err != nil { - return nil, nil, fmt.Errorf("failed to send tools/list request: %w", err) - } - - // Read tools response - toolsResponse, err := readJSONResponse(10 * time.Second) - if err != nil { - return nil, nil, fmt.Errorf("failed to read tools response: %w", err) - } - - if toolsResponse.Error != nil { - return nil, nil, fmt.Errorf("tools/list failed: %s", toolsResponse.Error.Message) - } - - // Parse tools from response - tools, err := cd.parseToolsResponse(toolsResponse.Result) - if err != nil { - return nil, nil, fmt.Errorf("failed to parse tools response: %w", err) - } - - fmt.Printf("Successfully discovered %d tools from %s\n", len(tools), serverAlias) - - // Step 4: Request resources list (5 second timeout) - fmt.Printf("Requesting resources list from %s...\n", serverAlias) - resourcesRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 3, - Method: "resources/list", - } - - if err := encoder.Encode(resourcesRequest); err != nil { - return nil, nil, fmt.Errorf("failed to send resources/list request: %w", err) - } - - // Read resources response - resourcesResponse, err := readJSONResponse(5 * time.Second) - if err != nil { - // Resources might not be supported, that's okay - fmt.Printf("Resources not supported or failed to read response from %s: %v\n", serverAlias, err) - return tools, []MCPResource{}, nil - } - - if resourcesResponse.Error != nil { - // Resources might not be supported, that's okay - fmt.Printf("Resources not supported by %s: %s\n", serverAlias, resourcesResponse.Error.Message) - return tools, []MCPResource{}, nil - } - - // Parse resources from response - resources, err := cd.parseResourcesResponse(resourcesResponse.Result) - if err != nil { - fmt.Printf("Failed to parse resources response from %s: %v\n", serverAlias, err) - return tools, []MCPResource{}, nil - } - - fmt.Printf("Successfully discovered %d resources from %s\n", len(resources), serverAlias) - - return tools, resources, nil -} - -// parseToolsResponse parses the tools/list response -func (cd *CapabilityDiscovery) parseToolsResponse(result json.RawMessage) ([]MCPTool, error) { - var resultMap map[string]interface{} - if err := json.Unmarshal(result, &resultMap); err != nil { - return nil, fmt.Errorf("invalid tools response format: %w", err) - } - - toolsInterface, exists := resultMap["tools"] - if !exists { - return []MCPTool{}, nil - } - - toolsList, ok := toolsInterface.([]interface{}) - if !ok { - return nil, fmt.Errorf("tools is not an array") - } - - var tools []MCPTool - for _, toolInterface := range toolsList { - toolMap, ok := toolInterface.(map[string]interface{}) - if !ok { - continue - } - - tool := MCPTool{} - - if name, ok := toolMap["name"].(string); ok { - tool.Name = name - } - - if description, ok := toolMap["description"].(string); ok { - tool.Description = description - } - - if inputSchema, ok := toolMap["inputSchema"].(map[string]interface{}); ok { - tool.InputSchema = inputSchema - } - - tools = append(tools, tool) - } - - return tools, nil -} - -// parseResourcesResponse parses the resources/list response -func (cd *CapabilityDiscovery) parseResourcesResponse(result json.RawMessage) ([]MCPResource, error) { - var resultMap map[string]interface{} - if err := json.Unmarshal(result, &resultMap); err != nil { - return nil, fmt.Errorf("invalid resources response format: %w", err) - } - - resourcesInterface, exists := resultMap["resources"] - if !exists { - return []MCPResource{}, nil - } - - resourcesList, ok := resourcesInterface.([]interface{}) - if !ok { - return nil, fmt.Errorf("resources is not an array") - } - - var resources []MCPResource - for _, resourceInterface := range resourcesList { - resourceMap, ok := resourceInterface.(map[string]interface{}) - if !ok { - continue - } - - resource := MCPResource{} - - if uri, ok := resourceMap["uri"].(string); ok { - resource.URI = uri - } - - if name, ok := resourceMap["name"].(string); ok { - resource.Name = name - } - - if description, ok := resourceMap["description"].(string); ok { - resource.Description = description - } - - if mimeType, ok := resourceMap["mimeType"].(string); ok { - resource.MimeType = mimeType - } - - resources = append(resources, resource) - } - - return resources, nil -} - -// tryHTTPDiscovery attempts to discover capabilities using HTTP transport -func (cd *CapabilityDiscovery) tryHTTPDiscovery(serverAlias string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - fmt.Printf("Attempting HTTP discovery for %s\n", serverAlias) - - // Create a temporary manager and process manager for discovery - template := NewTemplateProcessor(cd.projectPath, false) // Non-verbose for discovery - processManager := NewProcessManager(cd.projectPath, template, false) - - // Start the MCP server temporarily with port assignment - process, err := processManager.StartLocalMCP(metadata) - if err != nil { - return nil, nil, fmt.Errorf("failed to start MCP server for HTTP discovery: %w", err) - } - - // Ensure we clean up the process - defer func() { - if err := processManager.StopProcess(process); err != nil { - fmt.Printf("Warning: failed to stop HTTP discovery process for %s: %v\n", serverAlias, err) - } - }() - - // Wait a moment for the server to fully start - time.Sleep(2 * time.Second) - - // Connect and discover capabilities via HTTP - tools, resources, err := cd.connectAndDiscover(process.Config.Port) - if err != nil { - return nil, nil, fmt.Errorf("HTTP discovery failed: %w", err) - } - - return tools, resources, nil -} - -// connectAndDiscover connects to a running MCP server and discovers its capabilities -func (cd *CapabilityDiscovery) connectAndDiscover(port int) ([]MCPTool, []MCPResource, error) { - fmt.Printf("Connecting to MCP server on port %d for capability discovery\n", port) - - // Create MCP protocol client - client := NewMCPProtocolClient(false) // Use non-verbose mode for discovery - - // Try to discover capabilities via HTTP - tools, resources, err := client.discoverFromHTTP(port) - if err != nil { - return nil, nil, fmt.Errorf("failed to discover capabilities via HTTP: %w", err) - } - - return tools, resources, nil -} - -// discoverFromStaticAnalysis attempts static analysis as fallback -func (cd *CapabilityDiscovery) discoverFromStaticAnalysis(serverDir string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - // For Node.js servers, try to run the server and get capabilities - // Check URL or run command for hints about the server type - urlOrCmd := metadata.URL - if metadata.RunCmd != "" { - urlOrCmd = metadata.RunCmd - } - - if strings.Contains(urlOrCmd, "node") || strings.Contains(urlOrCmd, "npm") || strings.Contains(urlOrCmd, "npx") { - return cd.discoverNodeJSCapabilities(serverDir, metadata) - } - - // For Python servers - if strings.Contains(urlOrCmd, "python") || strings.Contains(urlOrCmd, "pip") || strings.Contains(urlOrCmd, "uvx") { - return cd.discoverPythonCapabilities(serverDir, metadata) - } - - // Default: try to parse from package.json or other metadata - return cd.discoverFromMetadata(serverDir, metadata) -} - -// discoverNodeJSCapabilities discovers capabilities from a Node.js MCP server -func (cd *CapabilityDiscovery) discoverNodeJSCapabilities(serverDir string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - // Try to read from package.json first (in root directory for GitHub cloned servers) - packageJSONPath := filepath.Join(serverDir, "package.json") - if _, err := os.Stat(packageJSONPath); err == nil { - return cd.parseNodeJSPackage(packageJSONPath) - } - - // Try to find main server file and parse it - serverFiles := []string{ - // GitHub cloned MCP servers - files are in root directory - filepath.Join(serverDir, "build", "index.js"), // Built TypeScript (most common) - filepath.Join(serverDir, "dist", "index.js"), // Alternative build dir - filepath.Join(serverDir, "src", "index.js"), // Source JS - filepath.Join(serverDir, "src", "index.ts"), // Source TypeScript - filepath.Join(serverDir, "index.js"), // Root JS - filepath.Join(serverDir, "index.ts"), // Root TypeScript - // Legacy server subdirectory support (keep for backward compatibility) - filepath.Join(serverDir, "server", "index.js"), - filepath.Join(serverDir, "server", "src", "index.js"), - filepath.Join(serverDir, "server", "dist", "index.js"), - filepath.Join(serverDir, "server", "index.ts"), - filepath.Join(serverDir, "server", "src", "index.ts"), - filepath.Join(serverDir, "server", "dist", "index.ts"), - } - - for _, serverFile := range serverFiles { - if _, err := os.Stat(serverFile); err == nil { - fmt.Printf("Debug: Found server file: %s\n", serverFile) - return cd.parseNodeJSServerFile(serverFile) - } else { - fmt.Printf("Debug: Checked but not found: %s\n", serverFile) - } - } - - return []MCPTool{}, []MCPResource{}, fmt.Errorf("no server files found") -} - -// discoverPythonCapabilities discovers capabilities from a Python MCP server -func (cd *CapabilityDiscovery) discoverPythonCapabilities(serverDir string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - // For Python servers, try to find and parse the main module - pythonFiles := []string{ - // GitHub cloned Python MCP servers - files are in root directory - filepath.Join(serverDir, "src", "__main__.py"), // Source directory - filepath.Join(serverDir, "src", "main.py"), - filepath.Join(serverDir, "src", "server.py"), - filepath.Join(serverDir, "__main__.py"), // Root directory - filepath.Join(serverDir, "main.py"), - filepath.Join(serverDir, "server.py"), - filepath.Join(serverDir, "app.py"), // Common Python entry - // Legacy server subdirectory support - filepath.Join(serverDir, "server", "__main__.py"), - filepath.Join(serverDir, "server", "main.py"), - filepath.Join(serverDir, "server", "server.py"), - } - - for _, pythonFile := range pythonFiles { - if _, err := os.Stat(pythonFile); err == nil { - fmt.Printf("Debug: Found Python server file: %s\n", pythonFile) - return cd.parsePythonServerFile(pythonFile) - } else { - fmt.Printf("Debug: Checked but not found: %s\n", pythonFile) - } - } - - return []MCPTool{}, []MCPResource{}, fmt.Errorf("no Python server files found") -} - -// discoverFromMetadata tries to discover capabilities from metadata files -func (cd *CapabilityDiscovery) discoverFromMetadata(serverDir string, metadata MCPServerConfig) ([]MCPTool, []MCPResource, error) { - // Check for a manifest or capabilities file - manifestFiles := []string{ - filepath.Join(serverDir, "manifest.json"), - filepath.Join(serverDir, "capabilities.json"), - filepath.Join(serverDir, "server", "manifest.json"), - } - - for _, manifestFile := range manifestFiles { - if _, err := os.Stat(manifestFile); err == nil { - return cd.parseManifestFile(manifestFile) - } - } - - return []MCPTool{}, []MCPResource{}, nil -} - -// parseNodeJSPackage parses package.json to extract tool information -func (cd *CapabilityDiscovery) parseNodeJSPackage(packagePath string) ([]MCPTool, []MCPResource, error) { - data, err := os.ReadFile(packagePath) - if err != nil { - return nil, nil, err - } - - var pkg map[string]interface{} - if err := json.Unmarshal(data, &pkg); err != nil { - return nil, nil, err - } - - // Look for MCP-specific metadata in package.json - if mcpData, ok := pkg["mcp"].(map[string]interface{}); ok { - return cd.parseMCPMetadata(mcpData) - } - - return []MCPTool{}, []MCPResource{}, nil -} - -// parseNodeJSServerFile parses a Node.js server file to extract tool definitions -func (cd *CapabilityDiscovery) parseNodeJSServerFile(serverFile string) ([]MCPTool, []MCPResource, error) { - data, err := os.ReadFile(serverFile) - if err != nil { - return nil, nil, err - } - - content := string(data) - tools := []MCPTool{} - resources := []MCPResource{} - - fmt.Printf("Debug: Parsing server file: %s\n", serverFile) - fmt.Printf("Debug: File contains ListToolsRequestSchema: %v\n", strings.Contains(content, "ListToolsRequestSchema")) - fmt.Printf("Debug: File contains 'tools: [': %v\n", strings.Contains(content, "tools: [")) - - // Look for tool definitions in the tools array within ListToolsRequestSchema handler - if strings.Contains(content, "ListToolsRequestSchema") { - tools = cd.extractToolsFromContent(content) - fmt.Printf("Debug: Extracted %d tools\n", len(tools)) - for i, tool := range tools { - fmt.Printf("Debug: Tool %d: %s - %s\n", i+1, tool.Name, tool.Description) - } - } - - // Look for resource definitions in the resources array within ListResourcesRequestSchema handler - if strings.Contains(content, "ListResourcesRequestSchema") { - resources = cd.extractResourcesFromContent(content) - fmt.Printf("Debug: Extracted %d resources\n", len(resources)) - } - - return tools, resources, nil -} - -// parsePythonServerFile parses a Python server file to extract tool definitions -func (cd *CapabilityDiscovery) parsePythonServerFile(pythonFile string) ([]MCPTool, []MCPResource, error) { - data, err := os.ReadFile(pythonFile) - if err != nil { - return nil, nil, err - } - - content := string(data) - tools := []MCPTool{} - resources := []MCPResource{} - - // Simple pattern matching for Python MCP patterns - lines := strings.Split(content, "\n") - for _, line := range lines { - line = strings.TrimSpace(line) - - // Look for @server.call_tool decorators or similar patterns - if strings.Contains(line, "@server.call_tool") || strings.Contains(line, "def ") && strings.Contains(line, "_tool") { - if toolName := cd.extractToolNameFromPython(line); toolName != "" { - tools = append(tools, MCPTool{ - Name: toolName, - Description: fmt.Sprintf("Tool discovered from %s", filepath.Base(pythonFile)), - }) - } - } - - // Look for resource handlers - if strings.Contains(line, "@server.list_resources") || strings.Contains(line, "def ") && strings.Contains(line, "_resource") { - if resourceName := cd.extractResourceNameFromPython(line); resourceName != "" { - resources = append(resources, MCPResource{ - Name: resourceName, - Description: fmt.Sprintf("Resource discovered from %s", filepath.Base(pythonFile)), - }) - } - } - } - - return tools, resources, nil -} - -// parseManifestFile parses a manifest file for capabilities -func (cd *CapabilityDiscovery) parseManifestFile(manifestFile string) ([]MCPTool, []MCPResource, error) { - data, err := os.ReadFile(manifestFile) - if err != nil { - return nil, nil, err - } - - var manifest struct { - Tools []MCPTool `json:"tools"` - Resources []MCPResource `json:"resources"` - } - - if err := json.Unmarshal(data, &manifest); err != nil { - return nil, nil, err - } - - return manifest.Tools, manifest.Resources, nil -} - -// parseMCPMetadata parses MCP metadata from package.json -func (cd *CapabilityDiscovery) parseMCPMetadata(mcpData map[string]interface{}) ([]MCPTool, []MCPResource, error) { - tools := []MCPTool{} - resources := []MCPResource{} - - if toolsData, ok := mcpData["tools"].([]interface{}); ok { - for _, toolData := range toolsData { - if toolMap, ok := toolData.(map[string]interface{}); ok { - tool := MCPTool{} - if name, ok := toolMap["name"].(string); ok { - tool.Name = name - } - if desc, ok := toolMap["description"].(string); ok { - tool.Description = desc - } - tools = append(tools, tool) - } - } - } - - if resourcesData, ok := mcpData["resources"].([]interface{}); ok { - for _, resourceData := range resourcesData { - if resourceMap, ok := resourceData.(map[string]interface{}); ok { - resource := MCPResource{} - if name, ok := resourceMap["name"].(string); ok { - resource.Name = name - } - if desc, ok := resourceMap["description"].(string); ok { - resource.Description = desc - } - resources = append(resources, resource) - } - } - } - - return tools, resources, nil -} - -// Helper functions for extracting names from code patterns -// -//nolint:unused // Reserved for enhanced static analysis -//nolint:unused // reserved for future JS analysis fallback improvements -func (cd *CapabilityDiscovery) extractToolNameFromJS(line string) string { - // Simple extraction - could be enhanced - if strings.Contains(line, "CallToolRequestSchema") { - // Try to find tool name in the handler - return "generic_tool" - } - return "" -} - -//nolint:unused // Reserved for enhanced static analysis -//nolint:unused // reserved for future JS analysis fallback improvements -func (cd *CapabilityDiscovery) extractResourceNameFromJS(line string) string { - // Simple extraction - could be enhanced - if strings.Contains(line, "ListResourcesRequestSchema") { - return "generic_resource" - } - return "" -} - -func (cd *CapabilityDiscovery) extractToolNameFromPython(line string) string { - // Extract function name from Python def statements - if strings.Contains(line, "def ") { - parts := strings.Split(line, "def ") - if len(parts) > 1 { - funcPart := strings.Split(parts[1], "(")[0] - return strings.TrimSpace(funcPart) - } - } - return "" -} - -func (cd *CapabilityDiscovery) extractResourceNameFromPython(line string) string { - // Extract function name from Python def statements - if strings.Contains(line, "def ") { - parts := strings.Split(line, "def ") - if len(parts) > 1 { - funcPart := strings.Split(parts[1], "(")[0] - return strings.TrimSpace(funcPart) - } - } - return "" -} - -// extractToolsFromContent extracts tool definitions from JavaScript/TypeScript content -func (cd *CapabilityDiscovery) extractToolsFromContent(content string) []MCPTool { - tools := []MCPTool{} - - // Look for tools array in the ListToolsRequestSchema handler - toolsStart := strings.Index(content, "tools: [") - if toolsStart == -1 { - return tools - } - - // Find the end of the tools array - remaining := content[toolsStart:] - bracketCount := 0 - toolsEnd := -1 - - for i, char := range remaining { - if char == '[' { - bracketCount++ - } else if char == ']' { - bracketCount-- - if bracketCount == 0 { - toolsEnd = i - break - } - } - } - - if toolsEnd == -1 { - return tools - } - - toolsSection := remaining[:toolsEnd+1] - - // Extract individual tool objects - toolObjects := cd.extractObjectsFromArray(toolsSection) - - for _, toolObj := range toolObjects { - tool := cd.parseToolObject(toolObj) - if tool.Name != "" { - tools = append(tools, tool) - } - } - - return tools -} - -// extractResourcesFromContent extracts resource definitions from JavaScript/TypeScript content -func (cd *CapabilityDiscovery) extractResourcesFromContent(content string) []MCPResource { - resources := []MCPResource{} - - // Look for resources array in the ListResourcesRequestSchema handler - resourcesStart := strings.Index(content, "resources: [") - if resourcesStart == -1 { - return resources - } - - // Find the end of the resources array - remaining := content[resourcesStart:] - bracketCount := 0 - resourcesEnd := -1 - - for i, char := range remaining { - if char == '[' { - bracketCount++ - } else if char == ']' { - bracketCount-- - if bracketCount == 0 { - resourcesEnd = i - break - } - } - } - - if resourcesEnd == -1 { - return resources - } - - resourcesSection := remaining[:resourcesEnd+1] - - // Extract individual resource objects - resourceObjects := cd.extractObjectsFromArray(resourcesSection) - - for _, resourceObj := range resourceObjects { - resource := cd.parseResourceObject(resourceObj) - if resource.Name != "" { - resources = append(resources, resource) - } - } - - return resources -} - -// extractObjectsFromArray extracts individual objects from a JavaScript array string -func (cd *CapabilityDiscovery) extractObjectsFromArray(arrayContent string) []string { - objects := []string{} - - // Simple extraction - look for objects between { and } - braceCount := 0 - objectStart := -1 - - for i, char := range arrayContent { - if char == '{' { - if braceCount == 0 { - objectStart = i - } - braceCount++ - } else if char == '}' { - braceCount-- - if braceCount == 0 && objectStart != -1 { - objects = append(objects, arrayContent[objectStart:i+1]) - objectStart = -1 - } - } - } - - return objects -} - -// parseToolObject parses a JavaScript tool object string to extract tool information -func (cd *CapabilityDiscovery) parseToolObject(objectStr string) MCPTool { - tool := MCPTool{} - - // Extract name - if nameMatch := cd.extractStringValue(objectStr, "name"); nameMatch != "" { - tool.Name = nameMatch - } - - // Extract description - if descMatch := cd.extractStringValue(objectStr, "description"); descMatch != "" { - tool.Description = descMatch - } - - return tool -} - -// parseResourceObject parses a JavaScript resource object string to extract resource information -func (cd *CapabilityDiscovery) parseResourceObject(objectStr string) MCPResource { - resource := MCPResource{} - - // Extract name - if nameMatch := cd.extractStringValue(objectStr, "name"); nameMatch != "" { - resource.Name = nameMatch - } - - // Extract description - if descMatch := cd.extractStringValue(objectStr, "description"); descMatch != "" { - resource.Description = descMatch - } - - return resource -} - -// extractStringValue extracts a string value for a given key from a JavaScript object string -func (cd *CapabilityDiscovery) extractStringValue(objectStr, key string) string { - // Look for key: "value" or key: 'value' - patterns := []string{ - key + `: "`, - key + `: "`, - key + `: '`, - key + `:'`, - } - - for _, pattern := range patterns { - startIdx := strings.Index(objectStr, pattern) - if startIdx != -1 { - valueStart := startIdx + len(pattern) - quote := objectStr[valueStart-1] - - // Find the closing quote - for i := valueStart; i < len(objectStr); i++ { - if objectStr[i] == quote && (i == 0 || objectStr[i-1] != '\\') { - return objectStr[valueStart:i] - } - } - } - } - - return "" -} - -// updateConfigWithTransport updates the server config file with detected transport type -func (cd *CapabilityDiscovery) updateConfigWithTransport(serverAlias, transport string) error { - configPath := filepath.Join(cd.projectPath, "packages", "mcp", serverAlias, "config.json") - - // Read existing config - configData, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("failed to read config: %w", err) - } - - // Parse as raw JSON to preserve any extra fields - var configMap map[string]interface{} - if err := json.Unmarshal(configData, &configMap); err != nil { - return fmt.Errorf("failed to parse config: %w", err) - } - - // Update transport field - configMap["transport"] = transport - - // Save updated config with proper formatting - updatedData, err := json.MarshalIndent(configMap, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - if err := os.WriteFile(configPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed to write config: %w", err) - } - - return nil -} diff --git a/control-plane/internal/mcp/errors.go b/control-plane/internal/mcp/errors.go deleted file mode 100644 index 8404552f0..000000000 --- a/control-plane/internal/mcp/errors.go +++ /dev/null @@ -1,260 +0,0 @@ -package mcp - -import ( - "fmt" - "strings" -) - -// MCPOperationError represents different types of MCP-related errors with context -type MCPOperationError struct { - Type MCPOperationErrorType - Operation string - ServerID string - Message string - Cause error - Context map[string]string - Stdout string - Stderr string -} - -// MCPOperationErrorType represents the category of MCP error -type MCPOperationErrorType string - -const ( - OpErrorTypeInstallation MCPOperationErrorType = "installation" - OpErrorTypeBuild MCPOperationErrorType = "build" - OpErrorTypeStartup MCPOperationErrorType = "startup" - OpErrorTypeCapabilityDiscovery MCPOperationErrorType = "capability_discovery" - OpErrorTypeValidation MCPOperationErrorType = "validation" - OpErrorTypeConfiguration MCPOperationErrorType = "configuration" - OpErrorTypeTemplate MCPOperationErrorType = "template" - OpErrorTypeProtocol MCPOperationErrorType = "protocol" - OpErrorTypeEnvironment MCPOperationErrorType = "environment" -) - -// Error implements the error interface -func (e *MCPOperationError) Error() string { - var parts []string - - if e.ServerID != "" { - parts = append(parts, fmt.Sprintf("server '%s'", e.ServerID)) - } - - if e.Operation != "" { - parts = append(parts, fmt.Sprintf("operation '%s'", e.Operation)) - } - - parts = append(parts, string(e.Type), "failed") - - if e.Message != "" { - parts = append(parts, "-", e.Message) - } - - result := strings.Join(parts, " ") - - if e.Cause != nil { - result += fmt.Sprintf(": %v", e.Cause) - } - - return result -} - -// Unwrap returns the underlying cause for error unwrapping -func (e *MCPOperationError) Unwrap() error { - return e.Cause -} - -// DetailedError returns a detailed error message including stdout/stderr and context -func (e *MCPOperationError) DetailedError() string { - var details []string - - details = append(details, e.Error()) - - if len(e.Context) > 0 { - details = append(details, "\nContext:") - for key, value := range e.Context { - details = append(details, fmt.Sprintf(" %s: %s", key, value)) - } - } - - if e.Stdout != "" { - details = append(details, "\nStdout:") - details = append(details, e.Stdout) - } - - if e.Stderr != "" { - details = append(details, "\nStderr:") - details = append(details, e.Stderr) - } - - return strings.Join(details, "\n") -} - -// GetSuggestion returns a helpful suggestion based on the error type and content -func (e *MCPOperationError) GetSuggestion() string { - switch e.Type { - case OpErrorTypeEnvironment: - if strings.Contains(e.Message, "ALLOWED_DIR") { - return "Set the ALLOWED_DIR environment variable to specify the directory the server can access" - } - if strings.Contains(e.Message, "NODE_ENV") { - return "Set NODE_ENV environment variable (e.g., --env NODE_ENV=production)" - } - return "Check that all required environment variables are set" - - case OpErrorTypeStartup: - if strings.Contains(e.Message, "permission denied") { - return "Check file permissions and ensure the executable is accessible" - } - if strings.Contains(e.Message, "command not found") { - return "Ensure the required runtime (node, python, etc.) is installed and in PATH" - } - return "Check server configuration and ensure all dependencies are installed" - - case OpErrorTypeInstallation: - if strings.Contains(e.Message, "npm install") { - return "Try running 'npm install' manually in the server directory" - } - if strings.Contains(e.Message, "pip install") { - return "Try running 'pip install' manually in the server directory" - } - return "Check network connectivity and package availability" - - case OpErrorTypeBuild: - if strings.Contains(e.Message, "typescript") || strings.Contains(e.Message, "tsc") { - return "Ensure TypeScript is installed and tsconfig.json is valid" - } - return "Check build configuration and ensure all build dependencies are available" - - case OpErrorTypeCapabilityDiscovery: - return "Server may require specific environment variables or configuration to start properly" - - default: - return "Check the detailed error output above for more information" - } -} - -// NewMCPOperationError creates a new MCP error with the given type and message -func NewMCPOperationError(errorType MCPOperationErrorType, operation, serverID, message string) *MCPOperationError { - return &MCPOperationError{ - Type: errorType, - Operation: operation, - ServerID: serverID, - Message: message, - Context: make(map[string]string), - } -} - -// NewMCPOperationErrorWithCause creates a new MCP error wrapping an existing error -func NewMCPOperationErrorWithCause(errorType MCPOperationErrorType, operation, serverID, message string, cause error) *MCPOperationError { - return &MCPOperationError{ - Type: errorType, - Operation: operation, - ServerID: serverID, - Message: message, - Cause: cause, - Context: make(map[string]string), - } -} - -// WithContext adds context information to the error -func (e *MCPOperationError) WithContext(key, value string) *MCPOperationError { - if e.Context == nil { - e.Context = make(map[string]string) - } - e.Context[key] = value - return e -} - -// WithOutput adds command output to the error -func (e *MCPOperationError) WithOutput(stdout, stderr string) *MCPOperationError { - e.Stdout = stdout - e.Stderr = stderr - return e -} - -// CommandExecutionError creates an error for command execution failures -func CommandExecutionError(operation, serverID, command string, cause error, stdout, stderr string) *MCPOperationError { - errorType := OpErrorTypeInstallation - if strings.Contains(operation, "build") { - errorType = OpErrorTypeBuild - } else if strings.Contains(operation, "start") { - errorType = OpErrorTypeStartup - } - - return NewMCPOperationErrorWithCause(errorType, operation, serverID, fmt.Sprintf("command failed: %s", command), cause). - WithContext("command", command). - WithOutput(stdout, stderr) -} - -// EnvironmentError creates an error for environment-related issues -func EnvironmentError(serverID, message string) *MCPOperationError { - return NewMCPOperationError(OpErrorTypeEnvironment, "environment_check", serverID, message) -} - -// CapabilityDiscoveryError creates an error for capability discovery failures -func CapabilityDiscoveryError(serverID, message string, cause error) *MCPOperationError { - return NewMCPOperationErrorWithCause(OpErrorTypeCapabilityDiscovery, "capability_discovery", serverID, message, cause) -} - -// TemplateError creates an error for template processing failures -func TemplateError(serverID, template, message string, cause error) *MCPOperationError { - return NewMCPOperationErrorWithCause(OpErrorTypeTemplate, "template_processing", serverID, message, cause). - WithContext("template", template) -} - -// MCPValidationError creates an error for validation failures -func MCPValidationError(serverID, field, message string) *MCPOperationError { - return NewMCPOperationError(OpErrorTypeValidation, "validation", serverID, message). - WithContext("field", field) -} - -// ProtocolError creates an error for MCP protocol communication failures -func ProtocolError(serverID, message string, cause error) *MCPOperationError { - return NewMCPOperationErrorWithCause(OpErrorTypeProtocol, "protocol_communication", serverID, message, cause) -} - -// ErrorFormatter provides different formatting options for errors -type ErrorFormatter struct { - Verbose bool - Colors bool -} - -// NewErrorFormatter creates a new error formatter -func NewErrorFormatter(verbose, colors bool) *ErrorFormatter { - return &ErrorFormatter{ - Verbose: verbose, - Colors: colors, - } -} - -// Format formats an error for display -func (f *ErrorFormatter) Format(err error) string { - mcpErr, ok := err.(*MCPOperationError) - if !ok { - return err.Error() - } - - if f.Verbose { - result := mcpErr.DetailedError() - suggestion := mcpErr.GetSuggestion() - if suggestion != "" { - result += "\n\nSuggestion: " + suggestion - } - return result - } - - result := mcpErr.Error() - suggestion := mcpErr.GetSuggestion() - if suggestion != "" { - result += "\nSuggestion: " + suggestion - } - return result -} - -// FormatWithColors formats an error with color codes (if supported) -func (f *ErrorFormatter) FormatWithColors(err error) string { - // For now, just return the formatted error - // Color formatting can be added later using a color library - return f.Format(err) -} diff --git a/control-plane/internal/mcp/interfaces.go b/control-plane/internal/mcp/interfaces.go deleted file mode 100644 index 0cb4ea6fe..000000000 --- a/control-plane/internal/mcp/interfaces.go +++ /dev/null @@ -1,174 +0,0 @@ -package mcp - -import ( - "context" - "encoding/json" - "io" - "os/exec" - "time" -) - -// MCPServerConfig represents the simplified MCP configuration -type MCPServerConfig struct { - // Core identification - Alias string `yaml:"alias" json:"alias"` - Description string `yaml:"description,omitempty" json:"description,omitempty"` - - // Connection (mutually exclusive) - URL string `yaml:"url,omitempty" json:"url,omitempty"` // For remote MCPs - RunCmd string `yaml:"run,omitempty" json:"run,omitempty"` // For local MCPs - - // Transport type (stdio or http) - Transport string `yaml:"transport,omitempty" json:"transport,omitempty"` - - // Setup (optional - runs once during add) - SetupCmds []string `yaml:"setup,omitempty" json:"setup,omitempty"` - - // Runtime configuration - WorkingDir string `yaml:"working_dir,omitempty" json:"working_dir,omitempty"` - Env map[string]string `yaml:"environment,omitempty" json:"environment,omitempty"` - Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty"` - - // Health & Monitoring - HealthCheck string `yaml:"health_check,omitempty" json:"health_check,omitempty"` - Port int `yaml:"port,omitempty" json:"port,omitempty"` // Auto-assigned if 0 - - // Metadata - Version string `yaml:"version,omitempty" json:"version,omitempty"` - Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"` - - // Installation options - Force bool `yaml:"-" json:"-"` // Force reinstall, not persisted - - // Internal runtime fields - PID int `yaml:"-" json:"pid,omitempty"` - Status string `yaml:"-" json:"status,omitempty"` - StartedAt *time.Time `yaml:"-" json:"started_at,omitempty"` -} - -// MCPProcess represents a running MCP server -type MCPProcess struct { - Config MCPServerConfig `json:"config"` - Cmd *exec.Cmd `json:"-"` - Stdin io.WriteCloser `json:"-"` - Stdout io.ReadCloser `json:"-"` - Stderr io.ReadCloser `json:"-"` - Context context.Context `json:"-"` - Cancel context.CancelFunc `json:"-"` - LogFile string `json:"log_file"` -} - -// MCPServerStatus represents the status of an MCP server -type MCPServerStatus string - -const ( - StatusStopped MCPServerStatus = "stopped" - StatusStarting MCPServerStatus = "starting" - StatusRunning MCPServerStatus = "running" - StatusError MCPServerStatus = "error" -) - -// MCPServerInfo represents information about an MCP server for status/list operations -type MCPServerInfo struct { - Alias string `json:"alias"` - Description string `json:"description,omitempty"` - Status MCPServerStatus `json:"status"` - URL string `json:"url,omitempty"` - RunCmd string `json:"run_cmd,omitempty"` - Port int `json:"port,omitempty"` - PID int `json:"pid,omitempty"` - StartedAt *time.Time `json:"started_at,omitempty"` - Version string `json:"version,omitempty"` - Tags []string `json:"tags,omitempty"` -} - -// MCPTool represents an MCP tool definition -type MCPTool struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]interface{} `json:"inputSchema"` - OutputSchema map[string]interface{} `json:"outputSchema,omitempty"` -} - -// MCPResource represents an MCP resource definition -type MCPResource struct { - URI string `json:"uri"` - Name string `json:"name"` - Description string `json:"description"` - MimeType string `json:"mime_type,omitempty"` -} - -// MCPManifest represents the capabilities discovered from an MCP server -type MCPManifest struct { - Tools []MCPTool `json:"tools,omitempty"` - Resources []MCPResource `json:"resources,omitempty"` - Version string `json:"version,omitempty"` -} - -// MCPRequest represents a JSON-RPC request to an MCP server -type MCPRequest struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` -} - -// MCPNotification represents a JSON-RPC notification to an MCP server (no ID field) -type MCPNotification struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` -} - -// MCPResponse represents a JSON-RPC response from an MCP server -type MCPResponse struct { - JSONRPC string `json:"jsonrpc"` - ID int `json:"id"` - Result json.RawMessage `json:"result,omitempty"` - Error *MCPError `json:"error,omitempty"` -} - -// MCPError represents an error in MCP protocol -type MCPError struct { - Code int `json:"code"` - Message string `json:"message"` - Data interface{} `json:"data,omitempty"` -} - -// InitializeParams represents MCP initialization parameters -type InitializeParams struct { - ProtocolVersion string `json:"protocolVersion"` - Capabilities map[string]interface{} `json:"capabilities"` - ClientInfo ClientInfo `json:"clientInfo"` -} - -// ClientInfo represents client information -type ClientInfo struct { - Name string `json:"name"` - Version string `json:"version"` -} - -// MCPToolsListResponse represents the response from tools/list request -type MCPToolsListResponse struct { - Tools []MCPToolDefinition `json:"tools"` -} - -// MCPResourcesListResponse represents the response from resources/list request -type MCPResourcesListResponse struct { - Resources []MCPResourceDefinition `json:"resources"` -} - -// MCPToolDefinition represents a tool definition from MCP protocol -type MCPToolDefinition struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - InputSchema map[string]interface{} `json:"inputSchema"` -} - -// MCPResourceDefinition represents a resource definition from MCP protocol -type MCPResourceDefinition struct { - URI string `json:"uri"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - MimeType string `json:"mimeType,omitempty"` -} diff --git a/control-plane/internal/mcp/manager.go b/control-plane/internal/mcp/manager.go deleted file mode 100644 index d8ee0ee0a..000000000 --- a/control-plane/internal/mcp/manager.go +++ /dev/null @@ -1,785 +0,0 @@ -package mcp - -import ( - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/config" - "gopkg.in/yaml.v3" -) - -// MCPManager handles MCP server lifecycle and management using simplified command-based architecture -type MCPManager struct { - projectDir string - processes map[string]*MCPProcess - template *TemplateProcessor - processManager *ProcessManager - verbose bool -} - -// NewMCPManager creates a new MCP manager instance -func NewMCPManager(cfg *config.Config, projectDir string, verbose bool) *MCPManager { - template := NewTemplateProcessor(projectDir, verbose) - return &MCPManager{ - projectDir: projectDir, - processes: make(map[string]*MCPProcess), - template: template, - processManager: NewProcessManager(projectDir, template, verbose), - verbose: verbose, - } -} - -// Add adds a new MCP server with the given configuration -func (m *MCPManager) Add(config MCPServerConfig) error { - // Validate configuration - if config.Alias == "" { - return fmt.Errorf("alias is required") - } - - if config.URL == "" && config.RunCmd == "" { - return fmt.Errorf("either URL or run command is required") - } - - if config.URL != "" && config.RunCmd != "" { - return fmt.Errorf("URL and run command are mutually exclusive") - } - - if m.verbose { - fmt.Printf("Adding MCP server: %s\n", config.Alias) - if config.URL != "" { - fmt.Printf("Remote URL: %s\n", config.URL) - } else { - fmt.Printf("Run command: %s\n", config.RunCmd) - } - } - - // Create server directory - serverDir := filepath.Join(m.projectDir, "packages", "mcp", config.Alias) - - // Handle force flag - remove existing directory if it exists - if config.Force { - if _, err := os.Stat(serverDir); err == nil { - if m.verbose { - fmt.Printf("Force flag enabled, removing existing directory: %s\n", serverDir) - } - if err := os.RemoveAll(serverDir); err != nil { - return fmt.Errorf("failed to remove existing server directory: %w", err) - } - } - } - - if err := os.MkdirAll(serverDir, 0755); err != nil { - return fmt.Errorf("failed to create server directory: %w", err) - } - - // Execute setup commands if provided - if len(config.SetupCmds) > 0 { - if err := m.processManager.ExecuteSetupCommands(config); err != nil { - return fmt.Errorf("setup commands failed: %w", err) - } - } - - // Store configuration - configPath := filepath.Join(serverDir, "config.json") - if err := m.saveConfig(configPath, config); err != nil { - return fmt.Errorf("failed to save configuration: %w", err) - } - - // Update agentfield.yaml - if err := m.updateAgentFieldYAML(config); err != nil { - return fmt.Errorf("failed to update agentfield.yaml: %w", err) - } - - // Attempt to start and discover capabilities - _, err := m.Start(config.Alias) - if err != nil { - if m.verbose { - fmt.Printf("Warning: failed to start server for capability discovery: %v\n", err) - } - } else { - // Discover capabilities and generate skills - cd := NewCapabilityDiscovery(nil, m.projectDir) - capability, err := cd.discoverServerCapability(config.Alias) - if err != nil { - if m.verbose { - fmt.Printf("Warning: failed to discover capabilities: %v\n", err) - } - } else if capability != nil && m.verbose { - fmt.Printf("Updated config with transport type: %s\n", capability.Transport) - } - - // Stop the server after discovery (it will be started again when needed) - if err := m.Stop(config.Alias); err != nil { - if m.verbose { - fmt.Printf("Warning: failed to stop server after discovery: %v\n", err) - } - } - } - - if m.verbose { - fmt.Printf("Successfully added MCP server: %s\n", config.Alias) - } - - return nil -} - -// Start starts an MCP server -func (m *MCPManager) Start(alias string) (*MCPProcess, error) { - // Check if already running - if process, exists := m.processes[alias]; exists { - if m.processManager.IsProcessRunning(process) { - return process, fmt.Errorf("MCP server %s is already running", alias) - } - // Remove stale process reference - delete(m.processes, alias) - } - - // Load configuration - config, err := m.loadConfig(alias) - if err != nil { - return nil, fmt.Errorf("failed to load configuration: %w", err) - } - - if m.verbose { - fmt.Printf("Starting MCP server: %s\n", alias) - } - - var process *MCPProcess - - if config.URL != "" { - // Remote MCP - just validate connectivity - process, err = m.processManager.ConnectRemoteMCP(*config) - } else { - // Local MCP - execute run command - process, err = m.processManager.StartLocalMCP(*config) - } - - if err != nil { - return nil, fmt.Errorf("failed to start MCP server: %w", err) - } - - // Track the process - m.processes[alias] = process - - // Update configuration with runtime info - now := time.Now() - config.PID = process.Config.PID - config.Status = string(StatusRunning) - config.StartedAt = &now - - if err := m.saveConfig(filepath.Join(m.projectDir, "packages", "mcp", alias, "config.json"), *config); err != nil { - if m.verbose { - fmt.Printf("Warning: failed to update configuration: %v\n", err) - } - } - - if m.verbose { - fmt.Printf("Successfully started MCP server: %s\n", alias) - } - - return process, nil -} - -// Stop stops an MCP server -func (m *MCPManager) Stop(alias string) error { - process, exists := m.processes[alias] - if !exists { - return fmt.Errorf("MCP server %s is not running", alias) - } - - if m.verbose { - fmt.Printf("Stopping MCP server: %s\n", alias) - } - - if err := m.processManager.StopProcess(process); err != nil { - return fmt.Errorf("failed to stop MCP server: %w", err) - } - - // Remove from tracking - delete(m.processes, alias) - - // Update configuration - config, err := m.loadConfig(alias) - if err == nil { - config.PID = 0 - config.Status = string(StatusStopped) - config.StartedAt = nil - if err := m.saveConfig(filepath.Join(m.projectDir, "packages", "mcp", alias, "config.json"), *config); err != nil { - return fmt.Errorf("failed to persist MCP server config: %w", err) - } - } else if m.verbose { - fmt.Printf("WARN: Unable to load MCP config for %s during stop: %v\n", alias, err) - } - - if m.verbose { - fmt.Printf("Successfully stopped MCP server: %s\n", alias) - } - - return nil -} - -// Remove removes an MCP server -func (m *MCPManager) Remove(alias string) error { - // Stop if running - if _, exists := m.processes[alias]; exists { - if err := m.Stop(alias); err != nil { - return fmt.Errorf("failed to stop server before removal: %w", err) - } - } - - serverDir := filepath.Join(m.projectDir, "packages", "mcp", alias) - - // Check if server exists - if _, err := os.Stat(serverDir); os.IsNotExist(err) { - return fmt.Errorf("MCP server %s not found", alias) - } - - if m.verbose { - fmt.Printf("Removing MCP server: %s\n", alias) - } - - // Remove directory - if err := os.RemoveAll(serverDir); err != nil { - return fmt.Errorf("failed to remove server directory: %w", err) - } - - // Update agentfield.yaml - if err := m.removeMCPFromAgentFieldYAML(alias); err != nil { - return fmt.Errorf("failed to update agentfield.yaml: %w", err) - } - - if m.verbose { - fmt.Printf("Successfully removed MCP server: %s\n", alias) - } - - return nil -} - -// Status returns the status of all MCP servers -func (m *MCPManager) Status() ([]MCPServerInfo, error) { - mcpDir := filepath.Join(m.projectDir, "packages", "mcp") - - var servers []MCPServerInfo - - // Check if MCP directory exists - if _, err := os.Stat(mcpDir); os.IsNotExist(err) { - return servers, nil - } - - // Read all MCP server directories - entries, err := os.ReadDir(mcpDir) - if err != nil { - return nil, fmt.Errorf("failed to read MCP directory: %w", err) - } - - for _, entry := range entries { - if !entry.IsDir() { - continue - } - - alias := entry.Name() - serverInfo, err := m.getServerInfo(alias) - if err != nil { - if m.verbose { - fmt.Printf("Warning: failed to get info for server %s: %v\n", alias, err) - } - continue - } - - servers = append(servers, *serverInfo) - } - - return servers, nil -} - -// GetProcess returns the process for a given alias -func (m *MCPManager) GetProcess(alias string) (*MCPProcess, error) { - process, exists := m.processes[alias] - if !exists { - return nil, fmt.Errorf("MCP server %s is not running", alias) - } - return process, nil -} - -// List returns a list of all installed MCP servers -func (m *MCPManager) List() ([]MCPServerInfo, error) { - return m.Status() -} - -// Restart restarts an MCP server -func (m *MCPManager) Restart(alias string) error { - // Stop if running - if _, exists := m.processes[alias]; exists { - if err := m.Stop(alias); err != nil { - return fmt.Errorf("failed to stop server: %w", err) - } - } - - // Start again - _, err := m.Start(alias) - return err -} - -// Logs returns a reader for the server logs -func (m *MCPManager) Logs(alias string, follow bool, lines int) (io.ReadCloser, error) { - logFile := filepath.Join(m.projectDir, "packages", "mcp", alias, fmt.Sprintf("%s.log", alias)) - - if _, err := os.Stat(logFile); os.IsNotExist(err) { - return nil, fmt.Errorf("log file not found for server %s", alias) - } - - // For now, just return the file reader - // TODO: Implement follow and lines functionality - file, err := os.Open(logFile) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - - return file, nil -} - -// Helper methods - -// saveConfig saves configuration to a file -func (m *MCPManager) saveConfig(path string, config MCPServerConfig) error { - data, err := json.MarshalIndent(config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal configuration: %w", err) - } - - if err := os.WriteFile(path, data, 0644); err != nil { - return fmt.Errorf("failed to write configuration: %w", err) - } - - return nil -} - -// loadConfig loads configuration from a file -func (m *MCPManager) loadConfig(alias string) (*MCPServerConfig, error) { - configPath := filepath.Join(m.projectDir, "packages", "mcp", alias, "config.json") - - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("failed to read configuration: %w", err) - } - - var config MCPServerConfig - if err := json.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse configuration: %w", err) - } - - return &config, nil -} - -// getServerInfo gets detailed server information for status reporting -func (m *MCPManager) getServerInfo(alias string) (*MCPServerInfo, error) { - config, err := m.loadConfig(alias) - if err != nil { - return nil, err - } - - info := &MCPServerInfo{ - Alias: config.Alias, - Description: config.Description, - Status: StatusStopped, - URL: config.URL, - RunCmd: config.RunCmd, - Port: config.Port, - Version: config.Version, - Tags: config.Tags, - } - - // Check if process is running - if process, exists := m.processes[alias]; exists && m.processManager.IsProcessRunning(process) { - info.Status = StatusRunning - info.PID = process.Config.PID - info.StartedAt = config.StartedAt - } - - return info, nil -} - -// updateAgentFieldYAML updates the agentfield.yaml file with the new MCP server -func (m *MCPManager) updateAgentFieldYAML(config MCPServerConfig) error { - agentfieldYAMLPath := filepath.Join(m.projectDir, "agentfield.yaml") - - // Read existing agentfield.yaml - data, err := os.ReadFile(agentfieldYAMLPath) - if err != nil { - return fmt.Errorf("failed to read agentfield.yaml: %w", err) - } - - // Parse YAML - var yamlConfig map[string]interface{} - if err := yaml.Unmarshal(data, &yamlConfig); err != nil { - return fmt.Errorf("failed to parse agentfield.yaml: %w", err) - } - - // Ensure dependencies section exists - if yamlConfig["dependencies"] == nil { - yamlConfig["dependencies"] = make(map[string]interface{}) - } - - dependencies := yamlConfig["dependencies"].(map[string]interface{}) - - // Ensure mcp_servers section exists - if dependencies["mcp_servers"] == nil { - dependencies["mcp_servers"] = make(map[string]interface{}) - } - - mcpServers := dependencies["mcp_servers"].(map[string]interface{}) - - // Build server configuration - serverConfig := make(map[string]interface{}) - - if config.URL != "" { - serverConfig["url"] = config.URL - } - - if config.RunCmd != "" { - serverConfig["run"] = config.RunCmd - } - - if len(config.SetupCmds) > 0 { - serverConfig["setup"] = config.SetupCmds - } - - if config.WorkingDir != "" { - serverConfig["working_dir"] = config.WorkingDir - } - - if len(config.Env) > 0 { - serverConfig["environment"] = config.Env - } - - if config.HealthCheck != "" { - serverConfig["health_check"] = config.HealthCheck - } - - if config.Description != "" { - serverConfig["description"] = config.Description - } - - if config.Version != "" { - serverConfig["version"] = config.Version - } - - if len(config.Tags) > 0 { - serverConfig["tags"] = config.Tags - } - - // Add the new MCP server - mcpServers[config.Alias] = serverConfig - - // Write back to file - updatedData, err := yaml.Marshal(yamlConfig) - if err != nil { - return fmt.Errorf("failed to marshal agentfield.yaml: %w", err) - } - - if err := os.WriteFile(agentfieldYAMLPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed to write agentfield.yaml: %w", err) - } - - return nil -} - -// removeMCPFromAgentFieldYAML removes an MCP server from agentfield.yaml -func (m *MCPManager) removeMCPFromAgentFieldYAML(alias string) error { - agentfieldYAMLPath := filepath.Join(m.projectDir, "agentfield.yaml") - - // Read existing agentfield.yaml - data, err := os.ReadFile(agentfieldYAMLPath) - if err != nil { - return fmt.Errorf("failed to read agentfield.yaml: %w", err) - } - - // Parse YAML - var config map[string]interface{} - if err := yaml.Unmarshal(data, &config); err != nil { - return fmt.Errorf("failed to parse agentfield.yaml: %w", err) - } - - // Navigate to mcp_servers section - if dependencies, ok := config["dependencies"].(map[string]interface{}); ok { - if mcpServers, ok := dependencies["mcp_servers"].(map[string]interface{}); ok { - delete(mcpServers, alias) - } - } - - // Write back to file - updatedData, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("failed to marshal agentfield.yaml: %w", err) - } - - if err := os.WriteFile(agentfieldYAMLPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed to write agentfield.yaml: %w", err) - } - - return nil -} - -// loadMCPConfigsFromYAML loads MCP configurations from agentfield.yaml -// -//nolint:unused // Reserved for future YAML config support -func (m *MCPManager) loadMCPConfigsFromYAML() (map[string]MCPServerConfig, error) { - agentfieldYAMLPath := filepath.Join(m.projectDir, "agentfield.yaml") - - data, err := os.ReadFile(agentfieldYAMLPath) - if err != nil { - return nil, fmt.Errorf("failed to read agentfield.yaml: %w", err) - } - - var config map[string]interface{} - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse agentfield.yaml: %w", err) - } - - configs := make(map[string]MCPServerConfig) - - // Navigate to mcp_servers section - if dependencies, ok := config["dependencies"].(map[string]interface{}); ok { - if mcpServers, ok := dependencies["mcp_servers"].(map[string]interface{}); ok { - for alias, serverData := range mcpServers { - if serverMap, ok := serverData.(map[string]interface{}); ok { - serverConfig := MCPServerConfig{ - Alias: alias, - } - - if url, ok := serverMap["url"].(string); ok { - serverConfig.URL = url - } - if runCmd, ok := serverMap["run"].(string); ok { - serverConfig.RunCmd = runCmd - } - if workingDir, ok := serverMap["working_dir"].(string); ok { - serverConfig.WorkingDir = workingDir - } - if description, ok := serverMap["description"].(string); ok { - serverConfig.Description = description - } - if version, ok := serverMap["version"].(string); ok { - serverConfig.Version = version - } - if healthCheck, ok := serverMap["health_check"].(string); ok { - serverConfig.HealthCheck = healthCheck - } - - // Parse setup commands - if setup, ok := serverMap["setup"].([]interface{}); ok { - for _, cmd := range setup { - if cmdStr, ok := cmd.(string); ok { - serverConfig.SetupCmds = append(serverConfig.SetupCmds, cmdStr) - } - } - } - - // Parse tags - if tags, ok := serverMap["tags"].([]interface{}); ok { - for _, tag := range tags { - if tagStr, ok := tag.(string); ok { - serverConfig.Tags = append(serverConfig.Tags, tagStr) - } - } - } - - // Parse environment variables - if env, ok := serverMap["environment"].(map[string]interface{}); ok { - serverConfig.Env = make(map[string]string) - for key, value := range env { - if valueStr, ok := value.(string); ok { - serverConfig.Env[key] = valueStr - } - } - } - - configs[alias] = serverConfig - } - } - } - } - - return configs, nil -} - -// DiscoverCapabilities discovers capabilities for an MCP server -func (m *MCPManager) DiscoverCapabilities(alias string) (*MCPManifest, error) { - if m.verbose { - fmt.Printf("Discovering capabilities for MCP server: %s\n", alias) - } - - // Load configuration - config, err := m.loadConfig(alias) - if err != nil { - return nil, fmt.Errorf("failed to load configuration: %w", err) - } - - var manifest *MCPManifest - - if config.URL != "" { - // URL-based MCP - manifest, err = m.discoverFromURL(*config) - } else { - // Local MCP - manifest, err = m.discoverFromLocalProcess(*config) - } - - if err != nil { - return nil, fmt.Errorf("capability discovery failed: %w", err) - } - - // Cache discovered capabilities - if err := m.cacheCapabilities(alias, manifest); err != nil { - if m.verbose { - fmt.Printf("Warning: failed to cache capabilities: %v\n", err) - } - } - - if m.verbose { - fmt.Printf("Successfully discovered capabilities for %s: %d tools, %d resources\n", - alias, len(manifest.Tools), len(manifest.Resources)) - fmt.Printf("Note: MCP skills will be auto-registered by AgentField SDK when agent starts\n") - } - - return manifest, nil -} - -// discoverFromURL discovers capabilities from a remote MCP server URL -func (m *MCPManager) discoverFromURL(config MCPServerConfig) (*MCPManifest, error) { - if m.verbose { - fmt.Printf("Discovering capabilities from URL: %s\n", config.URL) - } - - // Create MCP protocol client - client := NewMCPProtocolClient(m.verbose) - - // Discover capabilities from the URL - tools, resources, err := client.DiscoverCapabilitiesFromURL(config.URL) - if err != nil { - return nil, fmt.Errorf("failed to discover from URL: %w", err) - } - - manifest := &MCPManifest{ - Tools: tools, - Resources: resources, - Version: config.Version, - } - - return manifest, nil -} - -// discoverFromLocalProcess discovers capabilities from a local MCP process -func (m *MCPManager) discoverFromLocalProcess(config MCPServerConfig) (*MCPManifest, error) { - if m.verbose { - fmt.Printf("Discovering capabilities from local process: %s\n", config.Alias) - } - - // Create capability discovery instance - cd := NewCapabilityDiscovery(nil, m.projectDir) // Pass nil for config since we don't need it - - // Use the proper discovery logic that tries both stdio and HTTP - tools, resources, err := cd.discoverFromLocalProcess(config.Alias, config) - if err != nil { - return nil, fmt.Errorf("failed to discover capabilities: %w", err) - } - - manifest := &MCPManifest{ - Tools: tools, - Resources: resources, - Version: config.Version, - } - - return manifest, nil -} - -// connectAndDiscover connects to an MCP server endpoint and discovers capabilities -// -//nolint:unused // Reserved for future HTTP-based MCP discovery -func (m *MCPManager) connectAndDiscover(endpoint string) (*MCPManifest, error) { - if m.verbose { - fmt.Printf("Connecting to MCP server at: %s\n", endpoint) - } - - // Create MCP protocol client - client := NewMCPProtocolClient(m.verbose) - - // Discover capabilities from the endpoint - tools, resources, err := client.DiscoverCapabilitiesFromURL(endpoint) - if err != nil { - return nil, fmt.Errorf("failed to connect and discover: %w", err) - } - - manifest := &MCPManifest{ - Tools: tools, - Resources: resources, - } - - return manifest, nil -} - -// parseCapabilityResponse parses a raw capability response into an MCPManifest -// -//nolint:unused // Reserved for future HTTP-based MCP discovery -func (m *MCPManager) parseCapabilityResponse(response []byte) (*MCPManifest, error) { - var manifest MCPManifest - if err := json.Unmarshal(response, &manifest); err != nil { - return nil, fmt.Errorf("failed to parse capability response: %w", err) - } - return &manifest, nil -} - -// cacheCapabilities caches discovered capabilities to disk -func (m *MCPManager) cacheCapabilities(alias string, manifest *MCPManifest) error { - serverDir := filepath.Join(m.projectDir, "packages", "mcp", alias) - capabilitiesPath := filepath.Join(serverDir, "capabilities.json") - - // Add timestamp to cached data - cachedData := struct { - *MCPManifest - UpdatedAt int64 `json:"updated_at"` - }{ - MCPManifest: manifest, - UpdatedAt: time.Now().Unix(), - } - - data, err := json.MarshalIndent(cachedData, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal capabilities: %w", err) - } - - if err := os.WriteFile(capabilitiesPath, data, 0644); err != nil { - return fmt.Errorf("failed to write capabilities cache: %w", err) - } - - return nil -} - -// GenerateSkills generates Python skills based on discovered capabilities -func (m *MCPManager) GenerateSkills(alias string, manifest *MCPManifest) error { - if m.verbose { - fmt.Printf("Generating skills for MCP server: %s\n", alias) - } - - // Use the new SkillGenerator instead of the old template-based approach - generator := NewSkillGenerator(m.projectDir, m.verbose) - - result, err := generator.GenerateSkillsForServer(alias) - if err != nil { - return fmt.Errorf("failed to generate skills: %w", err) - } - - if m.verbose { - if result.Generated { - fmt.Printf("Generated consolidated skill file: %s (%d tools)\n", result.FilePath, result.ToolCount) - } else { - fmt.Printf("Skill generation result: %s\n", result.Message) - } - } - - return nil -} diff --git a/control-plane/internal/mcp/process.go b/control-plane/internal/mcp/process.go deleted file mode 100644 index 24e2065d7..000000000 --- a/control-plane/internal/mcp/process.go +++ /dev/null @@ -1,435 +0,0 @@ -package mcp - -import ( - "context" - "fmt" - "io" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "time" -) - -// ProcessManager handles MCP server process lifecycle management -type ProcessManager struct { - projectDir string - template *TemplateProcessor - verbose bool -} - -// NewProcessManager creates a new process manager instance -func NewProcessManager(projectDir string, template *TemplateProcessor, verbose bool) *ProcessManager { - return &ProcessManager{ - projectDir: projectDir, - template: template, - verbose: verbose, - } -} - -// StartLocalMCP starts a local MCP server using the run command -func (pm *ProcessManager) StartLocalMCP(config MCPServerConfig) (*MCPProcess, error) { - // Find available port - port, err := pm.FindAvailablePort(3001) - if err != nil { - return nil, fmt.Errorf("failed to find available port: %w", err) - } - - // Create template variables - vars := pm.template.CreateTemplateVars(config, port) - - // Process run command - processedCmd, err := pm.template.ProcessCommand(config.RunCmd, vars) - if err != nil { - return nil, fmt.Errorf("failed to process run command: %w", err) - } - - // Set working directory - workingDir := vars.ServerDir - if config.WorkingDir != "" { - processedWorkingDir, err := pm.template.ProcessCommand(config.WorkingDir, vars) - if err != nil { - return nil, fmt.Errorf("failed to process working directory: %w", err) - } - workingDir = processedWorkingDir - } - - if pm.verbose { - fmt.Printf("Executing run command: %s\n", processedCmd) - fmt.Printf("Working directory: %s\n", workingDir) - fmt.Printf("Port: %d\n", port) - } - - // Create context for process management - ctx, cancel := context.WithCancel(context.Background()) - - // Execute run command - cmd := exec.CommandContext(ctx, "sh", "-c", processedCmd) - cmd.Dir = workingDir - - // Set environment variables - if len(config.Env) > 0 { - processedEnv, err := pm.template.ProcessEnvironment(config.Env, vars) - if err != nil { - cancel() - return nil, fmt.Errorf("failed to process environment variables: %w", err) - } - - for key, value := range processedEnv { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) - } - } - - // Set up pipes - stdin, err := cmd.StdinPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create stdin pipe: %w", err) - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create stdout pipe: %w", err) - } - - stderr, err := cmd.StderrPipe() - if err != nil { - cancel() - return nil, fmt.Errorf("failed to create stderr pipe: %w", err) - } - - // Start the process - if err := cmd.Start(); err != nil { - cancel() - return nil, fmt.Errorf("failed to start process: %w", err) - } - - // Create process structure - process := &MCPProcess{ - Config: config, - Cmd: cmd, - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - Context: ctx, - Cancel: cancel, - LogFile: vars.LogFile, - } - - // Update config with process info - process.Config.Port = port - process.Config.PID = cmd.Process.Pid - process.Config.Status = string(StatusRunning) - - return process, nil -} - -// ConnectRemoteMCP connects to a remote MCP server -func (pm *ProcessManager) ConnectRemoteMCP(config MCPServerConfig) (*MCPProcess, error) { - if pm.verbose { - fmt.Printf("Connecting to remote MCP: %s\n", config.URL) - } - - // Validate URL connectivity - if err := pm.validateRemoteURL(config.URL); err != nil { - return nil, fmt.Errorf("failed to connect to remote MCP: %w", err) - } - - // Create a minimal process structure for remote MCPs - process := &MCPProcess{ - Config: config, - LogFile: filepath.Join(pm.projectDir, "packages", "mcp", config.Alias, fmt.Sprintf("%s.log", config.Alias)), - } - - // Update config - process.Config.Status = string(StatusRunning) - - return process, nil -} - -// MonitorProcess monitors a running process -func (pm *ProcessManager) MonitorProcess(process *MCPProcess, onExit func(alias string, err error)) { - if process.Cmd == nil { - // Remote MCP - no process to monitor - return - } - - // Wait for process to finish - err := process.Cmd.Wait() - - // Update status - if err != nil { - process.Config.Status = string(StatusError) - if pm.verbose { - fmt.Printf("Process %s exited with error: %v\n", process.Config.Alias, err) - } - } else { - process.Config.Status = string(StatusStopped) - if pm.verbose { - fmt.Printf("Process %s exited normally\n", process.Config.Alias) - } - } - - // Call exit callback - if onExit != nil { - onExit(process.Config.Alias, err) - } -} - -// ExecuteSetupCommands executes setup commands for an MCP server -func (pm *ProcessManager) ExecuteSetupCommands(config MCPServerConfig) error { - if len(config.SetupCmds) == 0 { - return nil - } - - serverDir := filepath.Join(pm.projectDir, "packages", "mcp", config.Alias) - - // Create template variables (port 0 for setup) - vars := pm.template.CreateTemplateVars(config, 0) - - // Process setup commands - processedCmds, err := pm.template.ProcessCommands(config.SetupCmds, vars) - if err != nil { - return fmt.Errorf("failed to process setup commands: %w", err) - } - - workingDir := serverDir - if config.WorkingDir != "" { - processedWorkingDir, err := pm.template.ProcessCommand(config.WorkingDir, vars) - if err != nil { - return fmt.Errorf("failed to process working directory: %w", err) - } - workingDir = processedWorkingDir - } - - if pm.verbose { - fmt.Printf("Executing setup commands in %s:\n", workingDir) - } - - for i, cmd := range processedCmds { - if pm.verbose { - fmt.Printf(" [%d/%d] %s\n", i+1, len(processedCmds), cmd) - } - - execCmd := exec.Command("sh", "-c", cmd) - execCmd.Dir = workingDir - - // Set environment variables - if len(config.Env) > 0 { - processedEnv, err := pm.template.ProcessEnvironment(config.Env, vars) - if err != nil { - return fmt.Errorf("failed to process environment variables: %w", err) - } - - for key, value := range processedEnv { - execCmd.Env = append(execCmd.Env, fmt.Sprintf("%s=%s", key, value)) - } - } - - output, err := execCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("setup command failed: %s\nOutput: %s", err, string(output)) - } - - if pm.verbose && len(output) > 0 { - fmt.Printf(" Output: %s\n", strings.TrimSpace(string(output))) - } - } - - return nil -} - -// ExecuteRunCommand executes the run command for an MCP server -func (pm *ProcessManager) ExecuteRunCommand(config MCPServerConfig, port int) (*exec.Cmd, error) { - // Create template variables - vars := pm.template.CreateTemplateVars(config, port) - - // Process run command - processedCmd, err := pm.template.ProcessCommand(config.RunCmd, vars) - if err != nil { - return nil, fmt.Errorf("failed to process run command: %w", err) - } - - // Set working directory - workingDir := vars.ServerDir - if config.WorkingDir != "" { - processedWorkingDir, err := pm.template.ProcessCommand(config.WorkingDir, vars) - if err != nil { - return nil, fmt.Errorf("failed to process working directory: %w", err) - } - workingDir = processedWorkingDir - } - - // Create command - cmd := exec.Command("sh", "-c", processedCmd) - cmd.Dir = workingDir - - // Set environment variables - if len(config.Env) > 0 { - processedEnv, err := pm.template.ProcessEnvironment(config.Env, vars) - if err != nil { - return nil, fmt.Errorf("failed to process environment variables: %w", err) - } - - for key, value := range processedEnv { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) - } - } - - return cmd, nil -} - -// FindAvailablePort finds an available port starting from the given port -func (pm *ProcessManager) FindAvailablePort(startPort int) (int, error) { - for port := startPort; port < startPort+100; port++ { - if pm.IsPortAvailable(port) { - return port, nil - } - } - return 0, fmt.Errorf("no available ports found in range %d-%d", startPort, startPort+99) -} - -// IsPortAvailable checks if a port is available -func (pm *ProcessManager) IsPortAvailable(port int) bool { - address := fmt.Sprintf(":%d", port) - listener, err := net.Listen("tcp", address) - if err != nil { - return false - } - defer listener.Close() - return true -} - -// HealthCheck performs a health check on an MCP server -func (pm *ProcessManager) HealthCheck(config MCPServerConfig, port int) error { - if config.HealthCheck == "" { - // No health check configured - assume healthy - return nil - } - - // Create template variables - vars := pm.template.CreateTemplateVars(config, port) - - // Process health check command - processedCmd, err := pm.template.ProcessCommand(config.HealthCheck, vars) - if err != nil { - return fmt.Errorf("failed to process health check command: %w", err) - } - - if pm.verbose { - fmt.Printf("Executing health check: %s\n", processedCmd) - } - - // Set timeout for health check - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - // Execute health check with timeout - cmd := exec.CommandContext(ctx, "sh", "-c", processedCmd) - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("health check failed: %s\nOutput: %s", err, string(output)) - } - - if pm.verbose { - fmt.Printf("Health check passed\n") - } - - return nil -} - -// StopProcess stops a running process -func (pm *ProcessManager) StopProcess(process *MCPProcess) error { - if process.Cancel != nil { - process.Cancel() - } - - if process.Cmd != nil && process.Cmd.Process != nil { - // Try graceful shutdown first - if err := process.Cmd.Process.Signal(os.Interrupt); err != nil { - // Force kill if graceful shutdown fails - return process.Cmd.Process.Kill() - } - - // Wait a bit for graceful shutdown - time.Sleep(2 * time.Second) - - // Check if still running - if pm.IsProcessRunning(process) { - return process.Cmd.Process.Kill() - } - } - - return nil -} - -// RestartProcess restarts a running process -func (pm *ProcessManager) RestartProcess(process *MCPProcess) (*MCPProcess, error) { - // Stop the current process - if err := pm.StopProcess(process); err != nil { - return nil, fmt.Errorf("failed to stop process: %w", err) - } - - // Wait a moment for cleanup - time.Sleep(1 * time.Second) - - // Start a new process - if process.Config.URL != "" { - return pm.ConnectRemoteMCP(process.Config) - } else { - return pm.StartLocalMCP(process.Config) - } -} - -// IsProcessRunning checks if a process is still running -func (pm *ProcessManager) IsProcessRunning(process *MCPProcess) bool { - if process.Cmd == nil || process.Cmd.Process == nil { - return false - } - - // Try to signal the process with signal 0 (test if process exists) - err := process.Cmd.Process.Signal(syscall.Signal(0)) - return err == nil -} - -// GetProcessLogs returns a reader for the process logs -func (pm *ProcessManager) GetProcessLogs(alias string, follow bool, lines int) (io.ReadCloser, error) { - logFile := filepath.Join(pm.projectDir, "packages", "mcp", alias, fmt.Sprintf("%s.log", alias)) - - if _, err := os.Stat(logFile); os.IsNotExist(err) { - return nil, fmt.Errorf("log file not found for server %s", alias) - } - - // For now, just return the file reader - // TODO: Implement follow and lines functionality - file, err := os.Open(logFile) - if err != nil { - return nil, fmt.Errorf("failed to open log file: %w", err) - } - - return file, nil -} - -// validateRemoteURL validates that a remote URL is accessible -func (pm *ProcessManager) validateRemoteURL(url string) error { - client := &http.Client{ - Timeout: 10 * time.Second, - } - - resp, err := client.Get(url) - if err != nil { - return fmt.Errorf("failed to connect to URL: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return fmt.Errorf("URL returned error status: %d", resp.StatusCode) - } - - return nil -} diff --git a/control-plane/internal/mcp/protocol_client.go b/control-plane/internal/mcp/protocol_client.go deleted file mode 100644 index 179ba8cd2..000000000 --- a/control-plane/internal/mcp/protocol_client.go +++ /dev/null @@ -1,435 +0,0 @@ -package mcp - -import ( - "bufio" - "encoding/json" - "fmt" - "io" - "net/http" - "os/exec" - "strings" - "time" -) - -// MCPProtocolClient handles communication with MCP servers using the MCP protocol -type MCPProtocolClient struct { - verbose bool -} - -// NewMCPProtocolClient creates a new MCP protocol client -func NewMCPProtocolClient(verbose bool) *MCPProtocolClient { - return &MCPProtocolClient{ - verbose: verbose, - } -} - -// DiscoverCapabilitiesFromProcess discovers capabilities from a running MCP process -func (client *MCPProtocolClient) DiscoverCapabilitiesFromProcess(process *MCPProcess) ([]MCPTool, []MCPResource, error) { - if client.verbose { - fmt.Printf("Discovering capabilities from process on port %d\n", process.Config.Port) - } - - // For stdio-based MCP servers, we need to communicate via stdin/stdout - if process.Cmd != nil { - return client.discoverFromStdio(process) - } - - // For HTTP-based servers, communicate via HTTP - if process.Config.Port > 0 { - return client.discoverFromHTTP(process.Config.Port) - } - - return nil, nil, fmt.Errorf("unsupported MCP server transport") -} - -// DiscoverCapabilitiesFromURL discovers capabilities from a remote MCP server URL -func (client *MCPProtocolClient) DiscoverCapabilitiesFromURL(url string) ([]MCPTool, []MCPResource, error) { - if client.verbose { - fmt.Printf("Discovering capabilities from URL: %s\n", url) - } - - // Parse URL to determine transport method - if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { - // HTTP/WebSocket transport - return client.discoverFromHTTPURL(url) - } - - return nil, nil, fmt.Errorf("unsupported URL transport: %s", url) -} - -// discoverFromStdio discovers capabilities from a stdio-based MCP server -func (client *MCPProtocolClient) discoverFromStdio(process *MCPProcess) ([]MCPTool, []MCPResource, error) { - if process.Stdin == nil || process.Stdout == nil { - return nil, nil, fmt.Errorf("stdin/stdout not available for MCP communication") - } - - // Send initialize request first - if err := client.sendInitialize(process.Stdin); err != nil { - return nil, nil, fmt.Errorf("failed to initialize MCP session: %w", err) - } - - // Wait for initialize response - if err := client.waitForInitializeResponse(process.Stdout); err != nil { - return nil, nil, fmt.Errorf("failed to receive initialize response: %w", err) - } - - // Discover tools - tools, err := client.requestToolsList(process.Stdin, process.Stdout) - if err != nil { - if client.verbose { - fmt.Printf("Warning: failed to discover tools: %v\n", err) - } - tools = []MCPTool{} - } - - // Discover resources - resources, err := client.requestResourcesList(process.Stdin, process.Stdout) - if err != nil { - if client.verbose { - fmt.Printf("Warning: failed to discover resources: %v\n", err) - } - resources = []MCPResource{} - } - - return tools, resources, nil -} - -// discoverFromHTTP discovers capabilities from an HTTP-based MCP server -func (client *MCPProtocolClient) discoverFromHTTP(port int) ([]MCPTool, []MCPResource, error) { - baseURL := fmt.Sprintf("http://localhost:%d", port) - return client.discoverFromHTTPURL(baseURL) -} - -// discoverFromHTTPURL discovers capabilities from an HTTP URL -func (client *MCPProtocolClient) discoverFromHTTPURL(baseURL string) ([]MCPTool, []MCPResource, error) { - // Try common MCP HTTP endpoints - endpoints := []string{ - baseURL + "/mcp", - baseURL + "/api/mcp", - baseURL, - } - - var lastErr error - for _, endpoint := range endpoints { - tools, resources, err := client.tryHTTPEndpoint(endpoint) - if err == nil { - return tools, resources, nil - } - lastErr = err - if client.verbose { - fmt.Printf("Failed to connect to %s: %v\n", endpoint, err) - } - } - - return nil, nil, fmt.Errorf("failed to connect to any HTTP endpoint: %w", lastErr) -} - -// tryHTTPEndpoint tries to discover capabilities from a specific HTTP endpoint -func (client *MCPProtocolClient) tryHTTPEndpoint(endpoint string) ([]MCPTool, []MCPResource, error) { - // Create HTTP client with timeout - httpClient := &http.Client{ - Timeout: 10 * time.Second, - } - - // Send tools/list request - tools, err := client.sendHTTPToolsRequest(httpClient, endpoint) - if err != nil { - return nil, nil, err - } - - // Send resources/list request - resources, err := client.sendHTTPResourcesRequest(httpClient, endpoint) - if err != nil { - // Resources are optional, so don't fail if they're not available - if client.verbose { - fmt.Printf("Warning: failed to get resources from %s: %v\n", endpoint, err) - } - resources = []MCPResource{} - } - - return tools, resources, nil -} - -// sendInitialize sends the MCP initialize request -func (client *MCPProtocolClient) sendInitialize(stdin io.Writer) error { - initRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 1, - Method: "initialize", - Params: map[string]interface{}{ - "protocolVersion": "2024-11-05", - "capabilities": map[string]interface{}{ - "tools": map[string]interface{}{}, - "resources": map[string]interface{}{}, - }, - "clientInfo": map[string]interface{}{ - "name": "agentfield-mcp-client", - "version": "1.0.0", - }, - }, - } - - return client.sendJSONRPCRequest(stdin, initRequest) -} - -// waitForInitializeResponse waits for the initialize response -func (client *MCPProtocolClient) waitForInitializeResponse(stdout io.Reader) error { - scanner := bufio.NewScanner(stdout) - scanner.Scan() - - var response MCPResponse - if err := json.Unmarshal(scanner.Bytes(), &response); err != nil { - return fmt.Errorf("failed to parse initialize response: %w", err) - } - - if response.Error != nil { - return fmt.Errorf("initialize error: %s", response.Error.Message) - } - - return nil -} - -// requestToolsList requests the list of tools from MCP server -func (client *MCPProtocolClient) requestToolsList(stdin io.Writer, stdout io.Reader) ([]MCPTool, error) { - toolsRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 2, - Method: "tools/list", - } - - if err := client.sendJSONRPCRequest(stdin, toolsRequest); err != nil { - return nil, err - } - - // Read response - scanner := bufio.NewScanner(stdout) - if !scanner.Scan() { - return nil, fmt.Errorf("no response received for tools/list") - } - - var response MCPResponse - if err := json.Unmarshal(scanner.Bytes(), &response); err != nil { - return nil, fmt.Errorf("failed to parse tools/list response: %w", err) - } - - if response.Error != nil { - return nil, fmt.Errorf("tools/list error: %s", response.Error.Message) - } - - var toolsResponse MCPToolsListResponse - if err := json.Unmarshal(response.Result, &toolsResponse); err != nil { - return nil, fmt.Errorf("failed to parse tools list: %w", err) - } - - // Convert to our internal format - tools := make([]MCPTool, len(toolsResponse.Tools)) - for i, tool := range toolsResponse.Tools { - tools[i] = MCPTool{ - Name: tool.Name, - Description: tool.Description, - InputSchema: tool.InputSchema, - } - } - - return tools, nil -} - -// requestResourcesList requests the list of resources from MCP server -func (client *MCPProtocolClient) requestResourcesList(stdin io.Writer, stdout io.Reader) ([]MCPResource, error) { - resourcesRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 3, - Method: "resources/list", - } - - if err := client.sendJSONRPCRequest(stdin, resourcesRequest); err != nil { - return nil, err - } - - // Read response - scanner := bufio.NewScanner(stdout) - if !scanner.Scan() { - return nil, fmt.Errorf("no response received for resources/list") - } - - var response MCPResponse - if err := json.Unmarshal(scanner.Bytes(), &response); err != nil { - return nil, fmt.Errorf("failed to parse resources/list response: %w", err) - } - - if response.Error != nil { - return nil, fmt.Errorf("resources/list error: %s", response.Error.Message) - } - - var resourcesResponse MCPResourcesListResponse - if err := json.Unmarshal(response.Result, &resourcesResponse); err != nil { - return nil, fmt.Errorf("failed to parse resources list: %w", err) - } - - // Convert to our internal format - resources := make([]MCPResource, len(resourcesResponse.Resources)) - for i, resource := range resourcesResponse.Resources { - resources[i] = MCPResource(resource) - } - - return resources, nil -} - -// sendHTTPToolsRequest sends tools/list request via HTTP -func (client *MCPProtocolClient) sendHTTPToolsRequest(httpClient *http.Client, endpoint string) ([]MCPTool, error) { - toolsRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 1, - Method: "tools/list", - } - - response, err := client.sendHTTPRequest(httpClient, endpoint, toolsRequest) - if err != nil { - return nil, err - } - - var toolsResponse MCPToolsListResponse - if err := json.Unmarshal([]byte(response.Result), &toolsResponse); err != nil { - return nil, fmt.Errorf("failed to parse tools list: %w", err) - } - - // Convert to our internal format - tools := make([]MCPTool, len(toolsResponse.Tools)) - for i, tool := range toolsResponse.Tools { - tools[i] = MCPTool{ - Name: tool.Name, - Description: tool.Description, - InputSchema: tool.InputSchema, - } - } - - return tools, nil -} - -// sendHTTPResourcesRequest sends resources/list request via HTTP -func (client *MCPProtocolClient) sendHTTPResourcesRequest(httpClient *http.Client, endpoint string) ([]MCPResource, error) { - resourcesRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 2, - Method: "resources/list", - } - - response, err := client.sendHTTPRequest(httpClient, endpoint, resourcesRequest) - if err != nil { - return nil, err - } - - var resourcesResponse MCPResourcesListResponse - if err := json.Unmarshal([]byte(response.Result), &resourcesResponse); err != nil { - return nil, fmt.Errorf("failed to parse resources list: %w", err) - } - - // Convert to our internal format - resources := make([]MCPResource, len(resourcesResponse.Resources)) - for i, resource := range resourcesResponse.Resources { - resources[i] = MCPResource(resource) - } - - return resources, nil -} - -// sendHTTPRequest sends an HTTP request to MCP server -func (client *MCPProtocolClient) sendHTTPRequest(httpClient *http.Client, endpoint string, request MCPRequest) (*MCPResponse, error) { - requestBytes, err := json.Marshal(request) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - resp, err := httpClient.Post(endpoint, "application/json", strings.NewReader(string(requestBytes))) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP error: %d %s", resp.StatusCode, resp.Status) - } - - responseBytes, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var response MCPResponse - if err := json.Unmarshal(responseBytes, &response); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != nil { - return nil, fmt.Errorf("MCP error: %s", response.Error.Message) - } - - return &response, nil -} - -// sendJSONRPCRequest sends a JSON-RPC request via stdin -func (client *MCPProtocolClient) sendJSONRPCRequest(stdin io.Writer, request MCPRequest) error { - requestBytes, err := json.Marshal(request) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // Add newline for line-based communication - requestBytes = append(requestBytes, '\n') - - if _, err := stdin.Write(requestBytes); err != nil { - return fmt.Errorf("failed to write request: %w", err) - } - - return nil -} - -// StartMCPServerForDiscovery starts an MCP server temporarily for capability discovery -func (client *MCPProtocolClient) StartMCPServerForDiscovery(config MCPServerConfig, template *TemplateProcessor) (*exec.Cmd, error) { - if config.RunCmd == "" { - return nil, fmt.Errorf("no run command specified for MCP server") - } - - // Process template variables in the run command - vars := template.CreateTemplateVars(config, 0) // Port 0 for stdio-based servers - processedCmd, err := template.ProcessCommand(config.RunCmd, vars) - if err != nil { - return nil, fmt.Errorf("failed to process run command template: %w", err) - } - - // Set working directory - workingDir := vars.ServerDir - if config.WorkingDir != "" { - processedWorkingDir, err := template.ProcessCommand(config.WorkingDir, vars) - if err != nil { - return nil, fmt.Errorf("failed to process working directory: %w", err) - } - workingDir = processedWorkingDir - } - - // Create command using shell to handle complex commands - cmd := exec.Command("sh", "-c", processedCmd) - cmd.Dir = workingDir - - // Set environment variables - if len(config.Env) > 0 { - processedEnv, err := template.ProcessEnvironment(config.Env, vars) - if err != nil { - return nil, fmt.Errorf("failed to process environment variables: %w", err) - } - - for key, value := range processedEnv { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) - } - } - - // Start the command - if err := cmd.Start(); err != nil { - return nil, fmt.Errorf("failed to start MCP server: %w", err) - } - - // Give the server a moment to start - time.Sleep(1 * time.Second) - - return cmd, nil -} diff --git a/control-plane/internal/mcp/skill_generator.go b/control-plane/internal/mcp/skill_generator.go deleted file mode 100644 index 5584bd033..000000000 --- a/control-plane/internal/mcp/skill_generator.go +++ /dev/null @@ -1,611 +0,0 @@ -package mcp - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "text/template" - "time" - - "github.com/Agent-Field/agentfield/control-plane/internal/config" -) - -// SkillGenerator handles the generation of Python skill files from MCP tools -type SkillGenerator struct { - projectDir string - verbose bool -} - -// NewSkillGenerator creates a new skill generator instance -func NewSkillGenerator(projectDir string, verbose bool) *SkillGenerator { - return &SkillGenerator{ - projectDir: projectDir, - verbose: verbose, - } -} - -// SkillGenerationResult represents the result of skill generation -type SkillGenerationResult struct { - Generated bool - FilePath string - ToolCount int - Message string -} - -// GenerateSkillsForServer generates a Python skill file for an MCP server -func (sg *SkillGenerator) GenerateSkillsForServer(serverAlias string) (*SkillGenerationResult, error) { - if sg.verbose { - fmt.Printf("=== DEBUG: Starting skill generation for: %s ===\n", serverAlias) - } - - // Discover server capabilities using the new simplified architecture - // Load config for capability discovery - cfg, err := config.LoadConfig(filepath.Join(sg.projectDir, "agentfield.yaml")) - if err != nil { - // Fallback to current directory - cfg, err = config.LoadConfig("agentfield.yaml") - if err != nil { - return nil, fmt.Errorf("failed to load af configuration: %w", err) - } - } - - discovery := NewCapabilityDiscovery(cfg, sg.projectDir) - capability, err := discovery.GetServerCapability(serverAlias) - if err != nil { - return nil, fmt.Errorf("failed to discover server capabilities: %w", err) - } - - // DEBUG: Check what we discovered - if sg.verbose { - fmt.Printf("=== DEBUG: Discovered capability ===\n") - fmt.Printf("ServerAlias: %s\n", capability.ServerAlias) - fmt.Printf("ServerName: %s\n", capability.ServerName) - fmt.Printf("Version: %s\n", capability.Version) - fmt.Printf("Transport: %s\n", capability.Transport) - fmt.Printf("Endpoint: %s\n", capability.Endpoint) - fmt.Printf("Tools count: %d\n", len(capability.Tools)) - for i, tool := range capability.Tools { - fmt.Printf("Tool %d: %s - %s\n", i+1, tool.Name, tool.Description) - if tool.InputSchema != nil { - fmt.Printf(" InputSchema keys: %v\n", getMapKeys(tool.InputSchema)) - } - } - fmt.Printf("Resources count: %d\n", len(capability.Resources)) - for i, resource := range capability.Resources { - fmt.Printf("Resource %d: %s - %s\n", i+1, resource.Name, resource.Description) - } - fmt.Printf("=== DEBUG: End capability info ===\n") - } - - if len(capability.Tools) == 0 { - message := fmt.Sprintf("No tools found for server %s, skipping skill generation", serverAlias) - if sg.verbose { - fmt.Printf("%s\n", message) - } - return &SkillGenerationResult{ - Generated: false, - FilePath: "", - ToolCount: 0, - Message: message, - }, nil - } - - // Generate the skill file - if sg.verbose { - fmt.Printf("=== DEBUG: Generating skill file content ===\n") - } - skillContent, err := sg.generateSkillFileContent(capability) - if err != nil { - if sg.verbose { - fmt.Printf("=== DEBUG: Failed to generate skill content: %v ===\n", err) - } - return nil, fmt.Errorf("failed to generate skill content: %w", err) - } - - if sg.verbose { - fmt.Printf("=== DEBUG: Generated content length: %d ===\n", len(skillContent)) - fmt.Printf("=== DEBUG: First 500 chars of content ===\n") - if len(skillContent) > 500 { - fmt.Printf("%s...\n", skillContent[:500]) - } else { - fmt.Printf("%s\n", skillContent) - } - } - - // Write the skill file - skillsDir := filepath.Join(sg.projectDir, "skills") - if err := os.MkdirAll(skillsDir, 0755); err != nil { - return nil, fmt.Errorf("failed to create skills directory: %w", err) - } - - skillFileName := fmt.Sprintf("mcp_%s.py", serverAlias) - skillFilePath := filepath.Join(skillsDir, skillFileName) - - if sg.verbose { - fmt.Printf("=== DEBUG: Writing skill file to: %s ===\n", skillFilePath) - } - - if err := os.WriteFile(skillFilePath, []byte(skillContent), 0644); err != nil { - if sg.verbose { - fmt.Printf("=== DEBUG: Failed to write skill file: %v ===\n", err) - } - return nil, fmt.Errorf("failed to write skill file: %w", err) - } - - message := fmt.Sprintf("Generated skill file: %s (%d tools)", skillFilePath, len(capability.Tools)) - if sg.verbose { - fmt.Printf("=== DEBUG: SUCCESS: %s ===\n", message) - } - - return &SkillGenerationResult{ - Generated: true, - FilePath: skillFilePath, - ToolCount: len(capability.Tools), - Message: message, - }, nil -} - -// Helper function to get map keys for debugging -func getMapKeys(m map[string]interface{}) []string { - if m == nil { - return []string{} - } - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} - -// GenerateSkillsForAllServers generates skill files for all installed MCP servers -func (sg *SkillGenerator) GenerateSkillsForAllServers() error { - // Load config for capability discovery - cfg, err := config.LoadConfig(filepath.Join(sg.projectDir, "agentfield.yaml")) - if err != nil { - // Fallback to current directory - cfg, err = config.LoadConfig("agentfield.yaml") - if err != nil { - return fmt.Errorf("failed to load af configuration: %w", err) - } - } - - discovery := NewCapabilityDiscovery(cfg, sg.projectDir) - capabilities, err := discovery.DiscoverCapabilities() - if err != nil { - return fmt.Errorf("failed to discover capabilities: %w", err) - } - - var errors []string - for _, capability := range capabilities { - result, err := sg.GenerateSkillsForServer(capability.ServerAlias) - if err != nil { - errors = append(errors, fmt.Sprintf("%s: %v", capability.ServerAlias, err)) - } else if sg.verbose && result != nil { - fmt.Printf("Server %s: %s\n", capability.ServerAlias, result.Message) - } - } - - if len(errors) > 0 { - return fmt.Errorf("failed to generate skills for some servers: %v", errors) - } - - if sg.verbose { - fmt.Printf("Successfully processed skills for %d MCP servers\n", len(capabilities)) - } - - return nil -} - -// RemoveSkillsForServer removes the generated skill file for an MCP server -func (sg *SkillGenerator) RemoveSkillsForServer(serverAlias string) error { - skillFileName := fmt.Sprintf("mcp_%s.py", serverAlias) - skillFilePath := filepath.Join(sg.projectDir, "skills", skillFileName) - - if _, err := os.Stat(skillFilePath); os.IsNotExist(err) { - // File doesn't exist, nothing to remove - return nil - } - - if err := os.Remove(skillFilePath); err != nil { - return fmt.Errorf("failed to remove skill file: %w", err) - } - - if sg.verbose { - fmt.Printf("Removed skill file: %s\n", skillFilePath) - } - - return nil -} - -// generateSkillFileContent generates the Python content for a skill file -func (sg *SkillGenerator) generateSkillFileContent(capability *MCPCapability) (string, error) { - // Prepare template data with escaped tools - escapedTools := make([]map[string]interface{}, len(capability.Tools)) - for i, tool := range capability.Tools { - escapedTools[i] = map[string]interface{}{ - "Name": tool.Name, - "Description": sg.escapeForPython(tool.Description), - "InputSchema": tool.InputSchema, - } - } - - templateData := struct { - ServerAlias string - ServerName string - Version string - Tools []map[string]interface{} - GeneratedAt string - SkillFunctions []SkillFunction - }{ - ServerAlias: capability.ServerAlias, - ServerName: capability.ServerName, - Version: capability.Version, - Tools: escapedTools, - GeneratedAt: time.Now().Format(time.RFC3339), - } - - // Generate skill functions from tools - for _, tool := range capability.Tools { - skillFunc, err := sg.generateSkillFunction(tool, capability.ServerAlias) - if err != nil { - return "", fmt.Errorf("failed to generate skill function for tool %s: %w", tool.Name, err) - } - templateData.SkillFunctions = append(templateData.SkillFunctions, skillFunc) - } - - // Execute template with custom functions - tmpl, err := template.New("skill").Funcs(template.FuncMap{ - "escapeForPython": sg.escapeForPython, - "jsonFormat": func(v interface{}) string { - // Simple JSON formatting for input schema - return fmt.Sprintf("%+v", v) - }, - }).Parse(skillFileTemplate) - if err != nil { - return "", fmt.Errorf("failed to parse skill template: %w", err) - } - - var content strings.Builder - if err := tmpl.Execute(&content, templateData); err != nil { - return "", fmt.Errorf("failed to execute skill template: %w", err) - } - - return content.String(), nil -} - -// SkillFunction represents a generated skill function -type SkillFunction struct { - Name string - ToolName string - Description string - Parameters []SkillParameter - DocString string -} - -// SkillParameter represents a skill function parameter -type SkillParameter struct { - Name string - Type string - Required bool - Description string - Default string -} - -// generateSkillFunction generates a skill function from an MCP tool -func (sg *SkillGenerator) generateSkillFunction(tool MCPTool, serverAlias string) (SkillFunction, error) { - // Generate function name: serveralias_toolname - functionName := sg.generateFunctionName(serverAlias, tool.Name) - - // Parse input schema to extract parameters - parameters, err := sg.parseInputSchema(tool.InputSchema) - if err != nil { - return SkillFunction{}, fmt.Errorf("failed to parse input schema: %w", err) - } - - // Generate docstring - docString := sg.generateDocString(tool, parameters) - - return SkillFunction{ - Name: functionName, - ToolName: tool.Name, - Description: tool.Description, - Parameters: parameters, - DocString: docString, - }, nil -} - -// generateFunctionName generates a valid Python function name -func (sg *SkillGenerator) generateFunctionName(serverAlias, toolName string) string { - // Convert to snake_case and ensure it's a valid Python identifier - // First normalize the server alias - normalizedAlias := strings.ReplaceAll(serverAlias, "-", "_") - normalizedAlias = strings.ReplaceAll(normalizedAlias, ".", "_") - normalizedAlias = strings.ReplaceAll(normalizedAlias, "/", "_") - - // Then normalize the tool name - normalizedTool := strings.ReplaceAll(toolName, "-", "_") - normalizedTool = strings.ReplaceAll(normalizedTool, ".", "_") - normalizedTool = strings.ReplaceAll(normalizedTool, "/", "_") - - name := fmt.Sprintf("%s_%s", normalizedAlias, normalizedTool) - - // Ensure it starts with a letter or underscore - if len(name) > 0 && (name[0] >= '0' && name[0] <= '9') { - name = "_" + name - } - - return name -} - -// parseInputSchema parses JSON schema to extract function parameters -func (sg *SkillGenerator) parseInputSchema(schema map[string]interface{}) ([]SkillParameter, error) { - var parameters []SkillParameter - - // Handle JSON Schema format - if properties, ok := schema["properties"].(map[string]interface{}); ok { - required := make(map[string]bool) - if requiredList, ok := schema["required"].([]interface{}); ok { - for _, req := range requiredList { - if reqStr, ok := req.(string); ok { - required[reqStr] = true - } - } - } - - for paramName, paramDef := range properties { - if paramDefMap, ok := paramDef.(map[string]interface{}); ok { - param := SkillParameter{ - Name: paramName, - Required: required[paramName], - } - - // Extract type - if paramType, ok := paramDefMap["type"].(string); ok { - param.Type = sg.mapJSONTypeToPython(paramType) - } else { - param.Type = "Any" - } - - // Extract description - if desc, ok := paramDefMap["description"].(string); ok { - param.Description = desc - } - - // Extract default value - if defaultVal, ok := paramDefMap["default"]; ok { - param.Default = sg.formatDefaultValue(defaultVal) - } - - parameters = append(parameters, param) - } - } - } - - return parameters, nil -} - -// mapJSONTypeToPython maps JSON Schema types to Python types -func (sg *SkillGenerator) mapJSONTypeToPython(jsonType string) string { - switch jsonType { - case "string": - return "str" - case "integer": - return "int" - case "number": - return "float" - case "boolean": - return "bool" - case "array": - return "List[Any]" - case "object": - return "Dict[str, Any]" - default: - return "Any" - } -} - -// formatDefaultValue formats a default value for Python code -func (sg *SkillGenerator) formatDefaultValue(value interface{}) string { - switch v := value.(type) { - case string: - return fmt.Sprintf(`"%s"`, v) - case bool: - if v { - return "True" - } - return "False" - case nil: - return "None" - default: - // For numbers and other types, convert to string - return fmt.Sprintf("%v", v) - } -} - -// escapeForPython escapes a string for safe use in Python code -func (sg *SkillGenerator) escapeForPython(s string) string { - // Replace problematic characters for Python strings - s = strings.ReplaceAll(s, `\`, `\\`) - s = strings.ReplaceAll(s, `"`, `\"`) - s = strings.ReplaceAll(s, "\n", "\\n") - s = strings.ReplaceAll(s, "\r", "\\r") - s = strings.ReplaceAll(s, "\t", "\\t") - return s -} - -// escapeForDocstring escapes a string for safe use in Python docstrings -func (sg *SkillGenerator) escapeForDocstring(s string) string { - // For docstrings, we need to handle triple quotes and preserve formatting - s = strings.ReplaceAll(s, `"""`, `\"\"\"`) - // Replace any problematic characters but preserve newlines for readability - s = strings.ReplaceAll(s, "\r\n", "\n") - s = strings.ReplaceAll(s, "\r", "\n") - return s -} - -// generateDocString generates a Python docstring for the skill function -func (sg *SkillGenerator) generateDocString(tool MCPTool, parameters []SkillParameter) string { - var docString strings.Builder - - // Escape the description for safe use in docstring - escapedDescription := sg.escapeForDocstring(tool.Description) - - docString.WriteString(fmt.Sprintf(`"""%s - - This is an auto-generated skill function that wraps the MCP tool '%s'. - - Args:`, escapedDescription, tool.Name)) - - for _, param := range parameters { - required := "" - if !param.Required { - required = ", optional" - } - - defaultInfo := "" - if param.Default != "" { - defaultInfo = fmt.Sprintf(", defaults to %s", param.Default) - } - - // Escape parameter description - escapedParamDesc := sg.escapeForDocstring(param.Description) - - docString.WriteString(fmt.Sprintf(` - %s (%s%s): %s%s`, param.Name, param.Type, required, escapedParamDesc, defaultInfo)) - } - - docString.WriteString(` - execution_context (ExecutionContext, optional): AgentField execution context for workflow tracking - - Returns: - Any: The result from the MCP tool execution - - Raises: - MCPError: If the MCP server is not available or the tool execution fails - """`) - - return docString.String() -} - -// skillFileTemplate is the template for generating Python skill files -const skillFileTemplate = `""" -Auto-generated MCP skill file for server: {{.ServerAlias}} -Generated at: {{.GeneratedAt}} -Server: {{.ServerName}} ({{.Version}}) - -This file contains auto-generated skill functions that wrap MCP tools. -Do not modify this file manually - it will be regenerated when the MCP server is updated. -""" - -from typing import Any, Dict, List, Optional -from agentfield import app -from agentfield.execution_context import ExecutionContext -from agentfield.mcp.client import MCPClient -from agentfield.mcp.exceptions import ( - MCPError, MCPConnectionError, MCPToolError, MCPTimeoutError -) -from agentfield.agent import Agent - -# MCP server configuration -MCP_ALIAS = "{{.ServerAlias}}" -MCP_SERVER_NAME = "{{.ServerName}}" -MCP_VERSION = "{{.Version}}" - -# Tool definitions for validation -MCP_TOOLS = [ -{{- range .Tools}} - { - "name": "{{.Name}}", - "description": "{{.Description}}", - "input_schema": {{.InputSchema | jsonFormat}}, - }, -{{- end}} -] - -async def _get_mcp_client(execution_context: Optional[ExecutionContext] = None) -> MCPClient: - """Get or create MCP client for this server with execution context.""" - try: - # Get client from registry - client = MCPClient.get_or_create(MCP_ALIAS) - - # Validate server health - is_healthy = await client.validate_server_health() - if not is_healthy: - raise MCPConnectionError( - f"MCP server '{MCP_ALIAS}' is not healthy. Please check server status with: af mcp status {MCP_ALIAS}", - endpoint=f"mcp://{MCP_ALIAS}" - ) - - # Set execution context for workflow tracking - if execution_context: - client.set_execution_context(execution_context) - elif Agent.get_current(): - # Try to get execution context from current agent - current_agent = Agent.get_current() - if hasattr(current_agent, '_current_execution_context') and current_agent._current_execution_context: - client.set_execution_context(current_agent._current_execution_context) - - return client - - except ValueError as e: - # Handle unregistered alias - raise MCPConnectionError( - f"MCP server '{MCP_ALIAS}' is not configured. Please install it with: af add --mcp {MCP_ALIAS}", - endpoint=f"mcp://{MCP_ALIAS}" - ) from e - except Exception as e: - # Handle other connection errors - raise MCPConnectionError( - f"Failed to connect to MCP server '{MCP_ALIAS}': {str(e)}", - endpoint=f"mcp://{MCP_ALIAS}" - ) from e - -{{range .SkillFunctions}} -@app.skill(tags=["mcp", "{{$.ServerAlias}}"]) -async def {{.Name}}({{range $i, $param := .Parameters}}{{if $i}}, {{end}}{{$param.Name}}: {{$param.Type}}{{if not $param.Required}}{{if $param.Default}} = {{$param.Default}}{{else}} = None{{end}}{{end}}{{end}}{{if .Parameters}}, {{end}}execution_context: Optional[ExecutionContext] = None) -> Any: - {{.DocString}} - - try: - # Get MCP client with execution context - client = await _get_mcp_client(execution_context) - - # Prepare arguments, filtering out None values for optional parameters - kwargs = {} - {{range .Parameters}} - if {{.Name}} is not None: - kwargs["{{.Name}}"] = {{.Name}} - {{end}} - - # Call the MCP tool - result = await client.call_tool("{{.ToolName}}", kwargs) - return result - - except MCPConnectionError: - # Re-raise connection errors as-is (they have helpful messages) - raise - except MCPToolError as e: - # Re-raise tool errors as-is (they have specific error details) - raise - except MCPTimeoutError as e: - # Re-raise timeout errors with context - raise MCPTimeoutError( - f"MCP tool '{{.ToolName}}' timed out after {e.timeout}s", - timeout=e.timeout - ) from e - except Exception as e: - # Wrap unexpected errors - raise MCPError(f"Unexpected error calling MCP tool '{{.ToolName}}': {str(e)}") from e - -{{end}} - -# Register all tools for discovery -def _register_mcp_tools(): - """Register MCP tools for runtime discovery.""" - # This function is called automatically when the module is imported - # All tools are already registered via @app.skill decorators above - pass - -# Auto-register tools when module is imported -_register_mcp_tools() -` diff --git a/control-plane/internal/mcp/stdio_client.go b/control-plane/internal/mcp/stdio_client.go deleted file mode 100644 index 6749079eb..000000000 --- a/control-plane/internal/mcp/stdio_client.go +++ /dev/null @@ -1,336 +0,0 @@ -package mcp - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "os/exec" - "time" -) - -// StdioMCPClient handles communication with stdio-based MCP servers -type StdioMCPClient struct { - verbose bool -} - -// NewStdioMCPClient creates a new stdio MCP client -func NewStdioMCPClient(verbose bool) *StdioMCPClient { - return &StdioMCPClient{ - verbose: verbose, - } -} - -// DiscoverCapabilitiesFromProcess discovers capabilities from a stdio-based MCP server process -func (c *StdioMCPClient) DiscoverCapabilitiesFromProcess(config MCPServerConfig) ([]MCPTool, []MCPResource, error) { - if c.verbose { - fmt.Printf("Starting stdio-based capability discovery for: %s\n", config.Alias) - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Start the MCP server process - cmd := exec.CommandContext(ctx, "sh", "-c", config.RunCmd) - - // Set working directory if specified - if config.WorkingDir != "" { - cmd.Dir = config.WorkingDir - } - - // Set environment variables - if len(config.Env) > 0 { - for key, value := range config.Env { - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) - } - } - - // Create pipes - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, nil, fmt.Errorf("failed to create stdin pipe: %w", err) - } - defer stdin.Close() - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, nil, fmt.Errorf("failed to create stdout pipe: %w", err) - } - defer stdout.Close() - - stderr, err := cmd.StderrPipe() - if err != nil { - return nil, nil, fmt.Errorf("failed to create stderr pipe: %w", err) - } - defer stderr.Close() - - // Start the process - if err := cmd.Start(); err != nil { - return nil, nil, fmt.Errorf("failed to start MCP server process: %w", err) - } - - // Ensure process cleanup - defer func() { - if cmd.Process != nil { - if killErr := cmd.Process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { - fmt.Printf("WARN: failed to terminate MCP stdio process: %v\n", killErr) - } - } - }() - - if c.verbose { - fmt.Printf("MCP server process started, performing handshake...\n") - } - - // Perform MCP handshake and discovery - tools, resources, err := c.performDiscovery(stdin, stdout, stderr) - if err != nil { - return nil, nil, fmt.Errorf("discovery failed: %w", err) - } - - if c.verbose { - fmt.Printf("Discovery completed: %d tools, %d resources\n", len(tools), len(resources)) - } - - return tools, resources, nil -} - -// performDiscovery performs the MCP handshake and capability discovery -func (c *StdioMCPClient) performDiscovery(stdin io.WriteCloser, stdout io.ReadCloser, stderr io.ReadCloser) ([]MCPTool, []MCPResource, error) { - // Create JSON-RPC communication channels - encoder := json.NewEncoder(stdin) - decoder := json.NewDecoder(stdout) - - // Monitor stderr for debugging - go func() { - if c.verbose { - scanner := bufio.NewScanner(stderr) - for scanner.Scan() { - fmt.Printf("MCP stderr: %s\n", scanner.Text()) - } - } - }() - - // Step 1: Send initialize request - initRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 1, - Method: "initialize", - Params: InitializeParams{ - ProtocolVersion: "2024-11-05", - Capabilities: map[string]interface{}{ - "roots": map[string]interface{}{ - "listChanged": true, - }, - }, - ClientInfo: ClientInfo{ - Name: "agentfield-mcp-client", - Version: "1.0.0", - }, - }, - } - - if c.verbose { - fmt.Printf("Sending initialize request...\n") - } - - if err := encoder.Encode(initRequest); err != nil { - return nil, nil, fmt.Errorf("failed to send initialize request: %w", err) - } - - // Read initialize response - var initResponse MCPResponse - if err := decoder.Decode(&initResponse); err != nil { - return nil, nil, fmt.Errorf("failed to read initialize response: %w", err) - } - - if initResponse.Error != nil { - return nil, nil, fmt.Errorf("initialize failed: %s", initResponse.Error.Message) - } - - if c.verbose { - fmt.Printf("Initialize successful, sending initialized notification...\n") - } - - // Step 2: Send initialized notification (must be a notification, not a request - no ID field) - initializedNotification := MCPNotification{ - JSONRPC: "2.0", - Method: "notifications/initialized", - Params: map[string]interface{}{}, - } - - if err := encoder.Encode(initializedNotification); err != nil { - return nil, nil, fmt.Errorf("failed to send initialized notification: %w", err) - } - - // Step 3: Request tools list - if c.verbose { - fmt.Printf("Requesting tools list...\n") - } - - toolsRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 2, - Method: "tools/list", - } - - if err := encoder.Encode(toolsRequest); err != nil { - return nil, nil, fmt.Errorf("failed to send tools/list request: %w", err) - } - - // Read tools response - var toolsResponse MCPResponse - if err := decoder.Decode(&toolsResponse); err != nil { - return nil, nil, fmt.Errorf("failed to read tools response: %w", err) - } - - if toolsResponse.Error != nil { - return nil, nil, fmt.Errorf("tools/list failed: %s", toolsResponse.Error.Message) - } - - // Parse tools from response - tools, err := c.parseToolsResponse(toolsResponse.Result) - if err != nil { - return nil, nil, fmt.Errorf("failed to parse tools response: %w", err) - } - - // Step 4: Request resources list - if c.verbose { - fmt.Printf("Requesting resources list...\n") - } - - resourcesRequest := MCPRequest{ - JSONRPC: "2.0", - ID: 3, - Method: "resources/list", - } - - if err := encoder.Encode(resourcesRequest); err != nil { - return nil, nil, fmt.Errorf("failed to send resources/list request: %w", err) - } - - // Read resources response - var resourcesResponse MCPResponse - if err := decoder.Decode(&resourcesResponse); err != nil { - // Resources might not be supported, that's okay - if c.verbose { - fmt.Printf("Resources not supported or failed to read response: %v\n", err) - } - return tools, []MCPResource{}, nil - } - - if resourcesResponse.Error != nil { - // Resources might not be supported, that's okay - if c.verbose { - fmt.Printf("Resources not supported: %s\n", resourcesResponse.Error.Message) - } - return tools, []MCPResource{}, nil - } - - // Parse resources from response - resources, err := c.parseResourcesResponse(resourcesResponse.Result) - if err != nil { - if c.verbose { - fmt.Printf("Failed to parse resources response: %v\n", err) - } - return tools, []MCPResource{}, nil - } - - return tools, resources, nil -} - -// parseToolsResponse parses the tools/list response -func (c *StdioMCPClient) parseToolsResponse(result interface{}) ([]MCPTool, error) { - resultMap, ok := result.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid tools response format") - } - - toolsInterface, exists := resultMap["tools"] - if !exists { - return []MCPTool{}, nil - } - - toolsList, ok := toolsInterface.([]interface{}) - if !ok { - return nil, fmt.Errorf("tools is not an array") - } - - var tools []MCPTool - for _, toolInterface := range toolsList { - toolMap, ok := toolInterface.(map[string]interface{}) - if !ok { - continue - } - - tool := MCPTool{} - - if name, ok := toolMap["name"].(string); ok { - tool.Name = name - } - - if description, ok := toolMap["description"].(string); ok { - tool.Description = description - } - - if inputSchema, ok := toolMap["inputSchema"].(map[string]interface{}); ok { - tool.InputSchema = inputSchema - } - - tools = append(tools, tool) - } - - return tools, nil -} - -// parseResourcesResponse parses the resources/list response -func (c *StdioMCPClient) parseResourcesResponse(result interface{}) ([]MCPResource, error) { - resultMap, ok := result.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid resources response format") - } - - resourcesInterface, exists := resultMap["resources"] - if !exists { - return []MCPResource{}, nil - } - - resourcesList, ok := resourcesInterface.([]interface{}) - if !ok { - return nil, fmt.Errorf("resources is not an array") - } - - var resources []MCPResource - for _, resourceInterface := range resourcesList { - resourceMap, ok := resourceInterface.(map[string]interface{}) - if !ok { - continue - } - - resource := MCPResource{} - - if uri, ok := resourceMap["uri"].(string); ok { - resource.URI = uri - } - - if name, ok := resourceMap["name"].(string); ok { - resource.Name = name - } - - if description, ok := resourceMap["description"].(string); ok { - resource.Description = description - } - - if mimeType, ok := resourceMap["mimeType"].(string); ok { - resource.MimeType = mimeType - } - - resources = append(resources, resource) - } - - return resources, nil -} diff --git a/control-plane/internal/mcp/storage.go b/control-plane/internal/mcp/storage.go deleted file mode 100644 index 18544e5bd..000000000 --- a/control-plane/internal/mcp/storage.go +++ /dev/null @@ -1,216 +0,0 @@ -package mcp - -import ( - "fmt" - "os" - "path/filepath" - "sync" - - "gopkg.in/yaml.v3" -) - -// ConfigStorage defines the interface for storing and retrieving MCP server configurations. -// This allows for different backend implementations (e.g., YAML file, database). -type ConfigStorage interface { - // LoadMCPServerConfig retrieves the configuration for a specific MCP server. - LoadMCPServerConfig(alias string) (*MCPServerConfig, error) - // SaveMCPServerConfig saves the configuration for a specific MCP server. - // This should overwrite any existing configuration for the given alias. - SaveMCPServerConfig(alias string, config *MCPServerConfig) error - // DeleteMCPServerConfig removes the configuration for a specific MCP server. - DeleteMCPServerConfig(alias string) error - // LoadAllMCPServerConfigs retrieves all stored MCP server configurations. - LoadAllMCPServerConfigs() (map[string]*MCPServerConfig, error) - // ListMCPServerAliases retrieves a list of all configured MCP server aliases. - ListMCPServerAliases() ([]string, error) - // UpdateConfig atomically updates a configuration. - // The updateFn receives the current config (or nil if it doesn't exist) - // and should return the new config to be saved. - // If updateFn returns an error, the transaction is rolled back. - UpdateConfig(alias string, updateFn func(currentConfig *MCPServerConfig) (*MCPServerConfig, error)) error -} - -// YAMLConfigStorage implements ConfigStorage using a YAML file. -// It stores all MCP server configurations in a single agentfield.yaml file -// under the dependencies.mcp_servers key. -type YAMLConfigStorage struct { - ProjectDir string - filePath string - mu sync.RWMutex // Protects access to the YAML file -} - -// NewYAMLConfigStorage creates a new YAMLConfigStorage. -// projectDir is the root directory of the agentfield project. -func NewYAMLConfigStorage(projectDir string) *YAMLConfigStorage { - return &YAMLConfigStorage{ - ProjectDir: projectDir, - filePath: filepath.Join(projectDir, "agentfield.yaml"), - } -} - -// agentfieldYAML represents the structure of the agentfield.yaml file. -// We only care about the mcp_servers part for this storage. -type agentfieldYAML struct { - Dependencies struct { - MCPServers map[string]*MCPServerConfig `yaml:"mcp_servers,omitempty"` - } `yaml:"dependencies,omitempty"` - // Other fields in agentfield.yaml are preserved but not directly managed here. - OtherFields map[string]interface{} `yaml:",inline"` -} - -func (s *YAMLConfigStorage) loadAgentFieldYAML() (*agentfieldYAML, error) { - data, err := os.ReadFile(s.filePath) - if err != nil { - if os.IsNotExist(err) { - // If agentfield.yaml doesn't exist, return an empty structure - return &agentfieldYAML{ - Dependencies: struct { - MCPServers map[string]*MCPServerConfig `yaml:"mcp_servers,omitempty"` - }{ - MCPServers: make(map[string]*MCPServerConfig), - }, - OtherFields: make(map[string]interface{}), - }, nil - } - return nil, fmt.Errorf("failed to read agentfield.yaml: %w", err) - } - - var cfg agentfieldYAML - // Initialize maps to avoid nil panics if sections are missing - cfg.Dependencies.MCPServers = make(map[string]*MCPServerConfig) - cfg.OtherFields = make(map[string]interface{}) - - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("failed to unmarshal agentfield.yaml: %w", err) - } - return &cfg, nil -} - -func (s *YAMLConfigStorage) saveAgentFieldYAML(cfg *agentfieldYAML) error { - data, err := yaml.Marshal(cfg) - if err != nil { - return fmt.Errorf("failed to marshal agentfield.yaml: %w", err) - } - return os.WriteFile(s.filePath, data, 0644) -} - -// LoadMCPServerConfig retrieves the configuration for a specific MCP server. -func (s *YAMLConfigStorage) LoadMCPServerConfig(alias string) (*MCPServerConfig, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - cfg, err := s.loadAgentFieldYAML() - if err != nil { - return nil, err - } - - serverConfig, ok := cfg.Dependencies.MCPServers[alias] - if !ok { - return nil, fmt.Errorf("MCP server config with alias '%s' not found", alias) - } - return serverConfig, nil -} - -// SaveMCPServerConfig saves the configuration for a specific MCP server. -func (s *YAMLConfigStorage) SaveMCPServerConfig(alias string, config *MCPServerConfig) error { - s.mu.Lock() - defer s.mu.Unlock() - - cfg, err := s.loadAgentFieldYAML() - if err != nil { - return err - } - - if cfg.Dependencies.MCPServers == nil { - cfg.Dependencies.MCPServers = make(map[string]*MCPServerConfig) - } - cfg.Dependencies.MCPServers[alias] = config - - return s.saveAgentFieldYAML(cfg) -} - -// DeleteMCPServerConfig removes the configuration for a specific MCP server. -func (s *YAMLConfigStorage) DeleteMCPServerConfig(alias string) error { - s.mu.Lock() - defer s.mu.Unlock() - - cfg, err := s.loadAgentFieldYAML() - if err != nil { - return err - } - - if _, ok := cfg.Dependencies.MCPServers[alias]; !ok { - return fmt.Errorf("MCP server config with alias '%s' not found for deletion", alias) - } - delete(cfg.Dependencies.MCPServers, alias) - - return s.saveAgentFieldYAML(cfg) -} - -// LoadAllMCPServerConfigs retrieves all stored MCP server configurations. -func (s *YAMLConfigStorage) LoadAllMCPServerConfigs() (map[string]*MCPServerConfig, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - cfg, err := s.loadAgentFieldYAML() - if err != nil { - return nil, err - } - // Return a copy to prevent modification of the internal map - configsCopy := make(map[string]*MCPServerConfig) - for k, v := range cfg.Dependencies.MCPServers { - configsCopy[k] = v - } - return configsCopy, nil -} - -// UpdateConfig atomically updates a configuration. -func (s *YAMLConfigStorage) UpdateConfig(alias string, updateFn func(currentConfig *MCPServerConfig) (*MCPServerConfig, error)) error { - s.mu.Lock() - defer s.mu.Unlock() - - cfg, err := s.loadAgentFieldYAML() - if err != nil { - return err - } - - currentConfig := cfg.Dependencies.MCPServers[alias] // currentConfig will be nil if not found - - newConfig, err := updateFn(currentConfig) - if err != nil { - return fmt.Errorf("update function failed: %w", err) // Rollback: don't save - } - - if newConfig == nil { // Indicates a desire to delete - if _, ok := cfg.Dependencies.MCPServers[alias]; ok { - delete(cfg.Dependencies.MCPServers, alias) - } else { - // Nothing to delete, no change needed - return nil - } - } else { - if cfg.Dependencies.MCPServers == nil { - cfg.Dependencies.MCPServers = make(map[string]*MCPServerConfig) - } - cfg.Dependencies.MCPServers[alias] = newConfig - } - - return s.saveAgentFieldYAML(cfg) -} - -// ListMCPServerAliases retrieves a list of all configured MCP server aliases. -func (s *YAMLConfigStorage) ListMCPServerAliases() ([]string, error) { - s.mu.RLock() - defer s.mu.RUnlock() - - cfg, err := s.loadAgentFieldYAML() - if err != nil { - return nil, err - } - - aliases := make([]string, 0, len(cfg.Dependencies.MCPServers)) - for alias := range cfg.Dependencies.MCPServers { - aliases = append(aliases, alias) - } - return aliases, nil -} diff --git a/control-plane/internal/mcp/template.go b/control-plane/internal/mcp/template.go deleted file mode 100644 index 3f46c9539..000000000 --- a/control-plane/internal/mcp/template.go +++ /dev/null @@ -1,111 +0,0 @@ -package mcp - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" -) - -// TemplateProcessor handles template variable processing for MCP commands -type TemplateProcessor struct { - projectDir string - dataDir string - verbose bool -} - -// TemplateVars holds all available template variables -type TemplateVars struct { - Port int `json:"port"` - DataDir string `json:"data_dir"` - ConfigFile string `json:"config_file"` - LogFile string `json:"log_file"` - ServerDir string `json:"server_dir"` - ProjectDir string `json:"project_dir"` - Alias string `json:"alias"` -} - -// NewTemplateProcessor creates a new template processor -func NewTemplateProcessor(projectDir string, verbose bool) *TemplateProcessor { - dataDir := filepath.Join(projectDir, "packages", "mcp") - return &TemplateProcessor{ - projectDir: projectDir, - dataDir: dataDir, - verbose: verbose, - } -} - -// ProcessCommand processes template variables in a single command string -func (tp *TemplateProcessor) ProcessCommand(command string, vars TemplateVars) (string, error) { - if command == "" { - return "", nil - } - - processed := command - - // Replace all template variables - processed = strings.ReplaceAll(processed, "{{port}}", strconv.Itoa(vars.Port)) - processed = strings.ReplaceAll(processed, "{{data_dir}}", vars.DataDir) - processed = strings.ReplaceAll(processed, "{{config_file}}", vars.ConfigFile) - processed = strings.ReplaceAll(processed, "{{log_file}}", vars.LogFile) - processed = strings.ReplaceAll(processed, "{{server_dir}}", vars.ServerDir) - processed = strings.ReplaceAll(processed, "{{project_dir}}", vars.ProjectDir) - processed = strings.ReplaceAll(processed, "{{alias}}", vars.Alias) - - if tp.verbose { - fmt.Printf("Template processing: %s -> %s\n", command, processed) - } - - return processed, nil -} - -// ProcessCommands processes template variables in multiple command strings -func (tp *TemplateProcessor) ProcessCommands(commands []string, vars TemplateVars) ([]string, error) { - if len(commands) == 0 { - return nil, nil - } - - processed := make([]string, len(commands)) - for i, command := range commands { - processedCmd, err := tp.ProcessCommand(command, vars) - if err != nil { - return nil, fmt.Errorf("error processing command %d: %w", i, err) - } - processed[i] = processedCmd - } - - return processed, nil -} - -// CreateTemplateVars creates template variables for the given configuration and port -func (tp *TemplateProcessor) CreateTemplateVars(config MCPServerConfig, port int) TemplateVars { - serverDir := filepath.Join(tp.dataDir, config.Alias) - - return TemplateVars{ - Port: port, - DataDir: tp.dataDir, - ConfigFile: filepath.Join(serverDir, "config.json"), - LogFile: filepath.Join(serverDir, fmt.Sprintf("%s.log", config.Alias)), - ServerDir: serverDir, - ProjectDir: tp.projectDir, - Alias: config.Alias, - } -} - -// ProcessEnvironment processes template variables in environment variable map -func (tp *TemplateProcessor) ProcessEnvironment(env map[string]string, vars TemplateVars) (map[string]string, error) { - if len(env) == 0 { - return nil, nil - } - - processed := make(map[string]string, len(env)) - for key, value := range env { - processedValue, err := tp.ProcessCommand(value, vars) - if err != nil { - return nil, fmt.Errorf("error processing environment variable %s: %w", key, err) - } - processed[key] = processedValue - } - - return processed, nil -} diff --git a/control-plane/internal/server/knowledgebase/content.go b/control-plane/internal/server/knowledgebase/content.go index 5e94d11e9..fe7a44da0 100644 --- a/control-plane/internal/server/knowledgebase/content.go +++ b/control-plane/internal/server/knowledgebase/content.go @@ -837,27 +837,6 @@ result = await app.harness( `, }) - kb.Add(Article{ - ID: "sdk/mcp-integration", Topic: "sdk", Title: "Model Context Protocol (MCP) Integration", - Summary: "Using MCP servers to extend agent capabilities", - Difficulty: "intermediate", - Tags: []string{"mcp", "integration", "tools", "model-context-protocol"}, - Content: `# MCP Integration - -AgentField supports Model Context Protocol servers for extending agent tool sets. - -## Management -- af mcp add — Add MCP server -- af mcp remove — Remove MCP server -- af mcp list — List MCP servers - -## API -- GET /api/ui/v1/mcp/status — System-wide MCP status -- GET /api/ui/v1/nodes/:nodeId/mcp/health — Node MCP health -- GET /api/ui/v1/nodes/:nodeId/mcp/servers/:alias/tools — List MCP tools -`, - }) - // --- Examples --- kb.Add(Article{ ID: "examples/sec-af", Topic: "examples", Title: "sec-af — Security Auditor Agent", diff --git a/control-plane/internal/server/server.go b/control-plane/internal/server/server.go index 5d05d5ac4..055e1e9b5 100644 --- a/control-plane/internal/server/server.go +++ b/control-plane/internal/server/server.go @@ -60,7 +60,7 @@ type AgentFieldServer struct { presenceManager *services.PresenceManager statusManager *services.StatusManager // Add StatusManager for unified status management agentService interfaces.AgentService // Add AgentService for lifecycle management - agentClient interfaces.AgentClient // Add AgentClient for MCP communication + agentClient interfaces.AgentClient // Add AgentClient for agent communication config *config.Config storageHealthOverride func(context.Context) gin.H cacheHealthOverride func(context.Context) gin.H @@ -1113,13 +1113,7 @@ func (s *AgentFieldServer) setupRoutes() { nodes.GET("/:nodeId/did", didHandler.GetNodeDIDHandler) nodes.GET("/:nodeId/vc-status", didHandler.GetNodeVCStatusHandler) - // MCP management endpoints for nodes - mcpHandler := ui.NewMCPHandler(s.uiService, s.agentClient) - nodes.GET("/:nodeId/mcp/health", mcpHandler.GetMCPHealthHandler) - nodes.GET("/:nodeId/mcp/events", mcpHandler.GetMCPEventsHandler) - nodes.GET("/:nodeId/mcp/metrics", mcpHandler.GetMCPMetricsHandler) - nodes.POST("/:nodeId/mcp/servers/:alias/restart", mcpHandler.RestartMCPServerHandler) - nodes.GET("/:nodeId/mcp/servers/:alias/tools", mcpHandler.GetMCPToolsHandler) + } // Executions management group @@ -1200,13 +1194,6 @@ func (s *AgentFieldServer) setupRoutes() { reasoners.POST("/:reasonerId/templates", reasonersHandler.SaveExecutionTemplateHandler) } - // MCP system-wide endpoints - mcp := uiAPI.Group("/mcp") - { - mcpHandler := ui.NewMCPHandler(s.uiService, s.agentClient) - mcp.GET("/status", mcpHandler.GetMCPStatusHandler) - } - // Dashboard endpoints dashboard := uiAPI.Group("/dashboard") { diff --git a/control-plane/internal/services/health_monitor.go b/control-plane/internal/services/health_monitor.go index 725b4af11..cd6b2bf58 100644 --- a/control-plane/internal/services/health_monitor.go +++ b/control-plane/internal/services/health_monitor.go @@ -6,7 +6,6 @@ import ( "sync" "time" - "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" "github.com/Agent-Field/agentfield/control-plane/internal/events" "github.com/Agent-Field/agentfield/control-plane/internal/logger" @@ -17,7 +16,7 @@ import ( // Health score constants for status updates. const ( // healthScoreActive is the score assigned when an HTTP health check passes. - // Below 100 to leave room for "excellent" states (e.g. agent + all MCP servers healthy). + // Below 100 to leave room for "excellent" states. healthScoreActive = 85 // healthScoreInactive is the score when an agent fails consecutive health checks. @@ -59,9 +58,6 @@ type HealthMonitor struct { activeAgents map[string]*ActiveAgent agentsMutex sync.RWMutex - // MCP health tracking - mcpHealthCache map[string]*domain.MCPSummaryData - mcpCacheMutex sync.RWMutex } // NewHealthMonitor creates a new HTTP-first health monitor service @@ -90,8 +86,6 @@ func NewHealthMonitor(storage storage.StorageProvider, config HealthMonitorConfi stopCh: make(chan struct{}), activeAgents: make(map[string]*ActiveAgent), agentsMutex: sync.RWMutex{}, - mcpHealthCache: make(map[string]*domain.MCPSummaryData), - mcpCacheMutex: sync.RWMutex{}, } } @@ -331,9 +325,8 @@ func (hm *HealthMonitor) checkAgentHealth(nodeID string) { hm.markAgentActive(nodeID) return } - // Already active, no status change needed — still refresh MCP health + // Already active, no status change needed hm.agentsMutex.Unlock() - hm.checkMCPHealthForNode(nodeID) } else { // FAILURE: Increment consecutive failure counter (capped to prevent unbounded growth) if activeAgent.ConsecutiveFailures < hm.config.ConsecutiveFailures+1 { @@ -407,8 +400,6 @@ func (hm *HealthMonitor) markAgentActive(nodeID string) { hm.presence.Touch(nodeID, "", time.Now()) } - // Check MCP health for active agents - hm.checkMCPHealthForNode(nodeID) } else { // Legacy fallback if err := hm.storage.UpdateAgentHealth(ctx, nodeID, types.HealthStatusActive); err != nil { @@ -428,7 +419,6 @@ func (hm *HealthMonitor) markAgentActive(nodeID string) { hm.uiService.OnNodeStatusChanged(updatedAgent) } } - hm.checkMCPHealthForNode(nodeID) } } @@ -471,102 +461,3 @@ func (hm *HealthMonitor) markAgentInactive(nodeID string, failCount int) { } } -// checkMCPHealthForNode checks MCP health for a specific node -func (hm *HealthMonitor) checkMCPHealthForNode(nodeID string) { - if hm.agentClient == nil { - return - } - - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Fetch MCP health from agent - healthResponse, err := hm.agentClient.GetMCPHealth(ctx, nodeID) - if err != nil { - // Silently continue - agent might not support MCP - return - } - - // Convert to domain model - newMCPSummary := &domain.MCPSummaryData{ - TotalServers: healthResponse.Summary.TotalServers, - RunningServers: healthResponse.Summary.RunningServers, - TotalTools: healthResponse.Summary.TotalTools, - OverallHealth: healthResponse.Summary.OverallHealth, - } - - // Check if MCP health has changed - if hm.hasMCPHealthChanged(nodeID, newMCPSummary) { - // Update cache - hm.updateMCPHealthCache(nodeID, newMCPSummary) - - // Transform for UI - uiSummary := &domain.MCPSummaryForUI{ - TotalServers: newMCPSummary.TotalServers, - RunningServers: newMCPSummary.RunningServers, - TotalTools: newMCPSummary.TotalTools, - OverallHealth: newMCPSummary.OverallHealth, - CapabilitiesAvailable: newMCPSummary.RunningServers > 0, - } - - if newMCPSummary.TotalServers > 0 { - uiSummary.HasIssues = newMCPSummary.RunningServers < newMCPSummary.TotalServers || newMCPSummary.OverallHealth < 0.8 - } - - // Set service status for user mode - if newMCPSummary.OverallHealth >= 0.9 { - uiSummary.ServiceStatus = string(domain.MCPServiceStatusReady) - } else if newMCPSummary.OverallHealth >= 0.5 { - uiSummary.ServiceStatus = string(domain.MCPServiceStatusDegraded) - } else { - uiSummary.ServiceStatus = string(domain.MCPServiceStatusUnavailable) - } - - // Broadcast MCP health change event - if hm.uiService != nil { - hm.uiService.OnMCPHealthChanged(nodeID, uiSummary) - } - - logger.Logger.Debug().Msgf("šŸ”§ MCP health changed for node %s: %d/%d servers running, health: %.2f", - nodeID, newMCPSummary.RunningServers, newMCPSummary.TotalServers, newMCPSummary.OverallHealth) - } -} - -// hasMCPHealthChanged checks if MCP health has changed for a node -func (hm *HealthMonitor) hasMCPHealthChanged(nodeID string, newSummary *domain.MCPSummaryData) bool { - hm.mcpCacheMutex.RLock() - defer hm.mcpCacheMutex.RUnlock() - - cached, exists := hm.mcpHealthCache[nodeID] - if !exists { - return true // First time checking this node - } - - // Compare key metrics - return cached.TotalServers != newSummary.TotalServers || - cached.RunningServers != newSummary.RunningServers || - cached.TotalTools != newSummary.TotalTools || - cached.OverallHealth != newSummary.OverallHealth -} - -// updateMCPHealthCache updates the cached MCP health data for a node -func (hm *HealthMonitor) updateMCPHealthCache(nodeID string, summary *domain.MCPSummaryData) { - hm.mcpCacheMutex.Lock() - defer hm.mcpCacheMutex.Unlock() - - hm.mcpHealthCache[nodeID] = summary -} - -// GetMCPHealthCache returns the current MCP health cache (for debugging/monitoring) -func (hm *HealthMonitor) GetMCPHealthCache() map[string]*domain.MCPSummaryData { - hm.mcpCacheMutex.RLock() - defer hm.mcpCacheMutex.RUnlock() - - // Return a copy to avoid race conditions - cache := make(map[string]*domain.MCPSummaryData) - for nodeID, summary := range hm.mcpHealthCache { - cache[nodeID] = summary - } - return cache -} diff --git a/control-plane/internal/services/health_monitor_test.go b/control-plane/internal/services/health_monitor_test.go index e5a456cca..38bca7065 100644 --- a/control-plane/internal/services/health_monitor_test.go +++ b/control-plane/internal/services/health_monitor_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" "github.com/Agent-Field/agentfield/control-plane/internal/storage" "github.com/Agent-Field/agentfield/control-plane/pkg/types" @@ -19,22 +18,16 @@ import ( // Mock AgentClient for testing type mockAgentClient struct { mu sync.RWMutex - statusResponses map[string]*interfaces.AgentStatusResponse - statusErrors map[string]error - mcpHealthResponses map[string]*interfaces.MCPHealthResponse - mcpHealthErrors map[string]error - getStatusCallCount map[string]int - getMCPHealthCallCount map[string]int + statusResponses map[string]*interfaces.AgentStatusResponse + statusErrors map[string]error + getStatusCallCount map[string]int } func newMockAgentClient() *mockAgentClient { return &mockAgentClient{ - statusResponses: make(map[string]*interfaces.AgentStatusResponse), - statusErrors: make(map[string]error), - mcpHealthResponses: make(map[string]*interfaces.MCPHealthResponse), - mcpHealthErrors: make(map[string]error), - getStatusCallCount: make(map[string]int), - getMCPHealthCallCount: make(map[string]int), + statusResponses: make(map[string]*interfaces.AgentStatusResponse), + statusErrors: make(map[string]error), + getStatusCallCount: make(map[string]int), } } @@ -55,31 +48,6 @@ func (m *mockAgentClient) GetAgentStatus(ctx context.Context, nodeID string) (*i return nil, errors.New("agent not found") } -func (m *mockAgentClient) GetMCPHealth(ctx context.Context, nodeID string) (*interfaces.MCPHealthResponse, error) { - m.mu.Lock() - m.getMCPHealthCallCount[nodeID]++ - m.mu.Unlock() - - m.mu.RLock() - defer m.mu.RUnlock() - - if err, ok := m.mcpHealthErrors[nodeID]; ok { - return nil, err - } - if resp, ok := m.mcpHealthResponses[nodeID]; ok { - return resp, nil - } - return nil, errors.New("MCP not available") -} - -func (m *mockAgentClient) RestartMCPServer(ctx context.Context, nodeID, alias string) error { - return nil -} - -func (m *mockAgentClient) GetMCPTools(ctx context.Context, nodeID, alias string) (*interfaces.MCPToolsResponse, error) { - return nil, nil -} - func (m *mockAgentClient) ShutdownAgent(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { return nil, nil } @@ -98,25 +66,12 @@ func (m *mockAgentClient) setStatusError(nodeID string, err error) { m.statusErrors[nodeID] = err } -func (m *mockAgentClient) setMCPHealthResponse(nodeID string, response *interfaces.MCPHealthResponse) { - m.mu.Lock() - defer m.mu.Unlock() - m.mcpHealthResponses[nodeID] = response -} - - func (m *mockAgentClient) getStatusCallCountFor(nodeID string) int { m.mu.RLock() defer m.mu.RUnlock() return m.getStatusCallCount[nodeID] } -func (m *mockAgentClient) getMCPHealthCallCountFor(nodeID string) int { - m.mu.RLock() - defer m.mu.RUnlock() - return m.getMCPHealthCallCount[nodeID] -} - func setupHealthMonitorTest(t *testing.T) (*HealthMonitor, storage.StorageProvider, *mockAgentClient, *StatusManager, *PresenceManager) { t.Helper() @@ -171,7 +126,6 @@ func TestHealthMonitor_NewHealthMonitor(t *testing.T) { require.NotNil(t, hm) assert.Equal(t, 10*time.Second, hm.config.CheckInterval) assert.NotNil(t, hm.activeAgents) - assert.NotNil(t, hm.mcpHealthCache) assert.NotNil(t, hm.stopCh) } @@ -474,222 +428,6 @@ func TestHealthMonitor_CheckAgentHealth_UnregisteredAgent(t *testing.T) { assert.Equal(t, 0, mockClient.getStatusCallCountFor(nodeID)) } -func TestHealthMonitor_MCP_CheckMCPHealth(t *testing.T) { - hm, provider, mockClient, _, _ := setupHealthMonitorTest(t) - ctx := context.Background() - - nodeID := "test-agent-mcp" - baseURL := "http://localhost:8001" - - // Register agent in storage - agent := &types.AgentNode{ - ID: nodeID, - BaseURL: baseURL, - } - err := provider.RegisterAgent(ctx, agent) - require.NoError(t, err) - - // Register in health monitor - hm.RegisterAgent(nodeID, baseURL) - - // Set mock MCP health response - mcpResponse := &interfaces.MCPHealthResponse{ - Summary: interfaces.MCPSummary{ - TotalServers: 3, - RunningServers: 3, - TotalTools: 15, - OverallHealth: 0.95, - }, - } - mockClient.setMCPHealthResponse(nodeID, mcpResponse) - - // Set agent as healthy first - mockClient.setStatusResponse(nodeID, "running") - - // Perform health check (should trigger MCP check for active agents) - - hm.checkAgentHealth(nodeID) - time.Sleep(200 * time.Millisecond) - - // Verify MCP health was checked - assert.Greater(t, mockClient.getMCPHealthCallCountFor(nodeID), 0, "MCP health should be checked for active agent") - - // Verify MCP health is cached - cache := hm.GetMCPHealthCache() - mcpData, exists := cache[nodeID] - require.True(t, exists, "MCP health should be cached") - assert.Equal(t, 3, mcpData.TotalServers) - assert.Equal(t, 3, mcpData.RunningServers) - assert.Equal(t, 15, mcpData.TotalTools) - assert.Equal(t, 0.95, mcpData.OverallHealth) -} - -func TestHealthMonitor_MCP_HealthChange(t *testing.T) { - hm, provider, mockClient, _, _ := setupHealthMonitorTest(t) - ctx := context.Background() - - nodeID := "test-agent-mcp-change" - baseURL := "http://localhost:8001" - - // Register agent in storage - agent := &types.AgentNode{ - ID: nodeID, - BaseURL: baseURL, - } - err := provider.RegisterAgent(ctx, agent) - require.NoError(t, err) - - // Register in health monitor - hm.RegisterAgent(nodeID, baseURL) - - // Set agent as healthy - mockClient.setStatusResponse(nodeID, "running") - - // First MCP health check - mcpResponse1 := &interfaces.MCPHealthResponse{ - Summary: interfaces.MCPSummary{ - TotalServers: 3, - RunningServers: 3, - TotalTools: 15, - OverallHealth: 0.95, - }, - } - mockClient.setMCPHealthResponse(nodeID, mcpResponse1) - - - hm.checkAgentHealth(nodeID) - time.Sleep(200 * time.Millisecond) - - // Change MCP health - mcpResponse2 := &interfaces.MCPHealthResponse{ - Summary: interfaces.MCPSummary{ - TotalServers: 3, - RunningServers: 2, // One server failed - TotalTools: 10, - OverallHealth: 0.67, - }, - } - mockClient.setMCPHealthResponse(nodeID, mcpResponse2) - - // Second health check - hm.checkAgentHealth(nodeID) - time.Sleep(200 * time.Millisecond) - - // Verify MCP health was updated - cache := hm.GetMCPHealthCache() - mcpData, exists := cache[nodeID] - require.True(t, exists) - assert.Equal(t, 2, mcpData.RunningServers, "MCP health should be updated") - assert.Equal(t, 0.67, mcpData.OverallHealth) -} - -func TestHealthMonitor_MCP_NoChange(t *testing.T) { - hm, provider, mockClient, _, _ := setupHealthMonitorTest(t) - ctx := context.Background() - - nodeID := "test-agent-mcp-no-change" - baseURL := "http://localhost:8001" - - // Register agent in storage - agent := &types.AgentNode{ - ID: nodeID, - BaseURL: baseURL, - } - err := provider.RegisterAgent(ctx, agent) - require.NoError(t, err) - - // Register in health monitor - hm.RegisterAgent(nodeID, baseURL) - - // Set agent as healthy - mockClient.setStatusResponse(nodeID, "running") - - // Set MCP health response - mcpResponse := &interfaces.MCPHealthResponse{ - Summary: interfaces.MCPSummary{ - TotalServers: 3, - RunningServers: 3, - TotalTools: 15, - OverallHealth: 0.95, - }, - } - mockClient.setMCPHealthResponse(nodeID, mcpResponse) - - - // First check - hm.checkAgentHealth(nodeID) - time.Sleep(200 * time.Millisecond) - - // Verify hasMCPHealthChanged returns false for same data - newSummary := &domain.MCPSummaryData{ - TotalServers: 3, - RunningServers: 3, - TotalTools: 15, - OverallHealth: 0.95, - } - hasChanged := hm.hasMCPHealthChanged(nodeID, newSummary) - assert.False(t, hasChanged, "Should detect no change in MCP health") -} - -func TestHealthMonitor_MCP_InactiveAgent(t *testing.T) { - hm, provider, mockClient, _, _ := setupHealthMonitorTest(t) - ctx := context.Background() - - nodeID := "test-agent-mcp-inactive" - baseURL := "http://localhost:8001" - - // Register agent in storage - agent := &types.AgentNode{ - ID: nodeID, - BaseURL: baseURL, - } - err := provider.RegisterAgent(ctx, agent) - require.NoError(t, err) - - // Register in health monitor - hm.RegisterAgent(nodeID, baseURL) - - // Set agent as inactive - mockClient.setStatusError(nodeID, errors.New("connection refused")) - - - hm.checkAgentHealth(nodeID) - time.Sleep(200 * time.Millisecond) - - // MCP health should NOT be checked for inactive agents - assert.Equal(t, 0, mockClient.getMCPHealthCallCountFor(nodeID), "MCP health should not be checked for inactive agent") -} - -func TestHealthMonitor_GetMCPHealthCache(t *testing.T) { - hm, _, _, _, _ := setupHealthMonitorTest(t) - - // Add some test data to cache - hm.mcpCacheMutex.Lock() - hm.mcpHealthCache["agent-1"] = &domain.MCPSummaryData{ - TotalServers: 3, - RunningServers: 3, - TotalTools: 15, - OverallHealth: 0.95, - } - hm.mcpHealthCache["agent-2"] = &domain.MCPSummaryData{ - TotalServers: 2, - RunningServers: 1, - TotalTools: 8, - OverallHealth: 0.50, - } - hm.mcpCacheMutex.Unlock() - - // Get cache - cache := hm.GetMCPHealthCache() - - // Verify cache contents - assert.Equal(t, 2, len(cache)) - assert.Contains(t, cache, "agent-1") - assert.Contains(t, cache, "agent-2") - assert.Equal(t, 3, cache["agent-1"].TotalServers) - assert.Equal(t, 1, cache["agent-2"].RunningServers) -} - func TestHealthMonitor_ConcurrentAccess(t *testing.T) { hm, provider, mockClient, _, _ := setupHealthMonitorTest(t) ctx := context.Background() @@ -731,15 +469,6 @@ func TestHealthMonitor_ConcurrentAccess(t *testing.T) { }(i) } - // Concurrent MCP cache access - for i := 0; i < 5; i++ { - wg.Add(1) - go func() { - defer wg.Done() - _ = hm.GetMCPHealthCache() - }() - } - wg.Wait() // Verify no race conditions @@ -1323,7 +1052,7 @@ func TestIntegration_NoFlapping_HeartbeatsDuringTransientFailures(t *testing.T) for i := 0; i < 30; i++ { // 30 heartbeats over ~3 seconds <-ticker.C readyStatus := types.AgentStatusReady - _ = statusManager.UpdateFromHeartbeat(ctx, nodeID, &readyStatus, nil, "") + _ = statusManager.UpdateFromHeartbeat(ctx, nodeID, &readyStatus, "") presenceManager.Touch(nodeID, "", time.Now()) // Record current state @@ -1559,7 +1288,7 @@ func TestIntegration_RecoveryAfterGenuineOutage(t *testing.T) { // Send a heartbeat to signal recovery readyStatus := types.AgentStatusReady - err = statusManager.UpdateFromHeartbeat(ctx, nodeID, &readyStatus, nil, "") + err = statusManager.UpdateFromHeartbeat(ctx, nodeID, &readyStatus, "") require.NoError(t, err) // Wait for health check cycle + debounce diff --git a/control-plane/internal/services/status_manager.go b/control-plane/internal/services/status_manager.go index 095eca466..30c101e93 100644 --- a/control-plane/internal/services/status_manager.go +++ b/control-plane/internal/services/status_manager.go @@ -57,11 +57,6 @@ func cloneAgentStatus(status *types.AgentStatus) *types.AgentStatus { clone := *status - if status.MCPStatus != nil { - mcpCopy := *status.MCPStatus - clone.MCPStatus = &mcpCopy - } - if status.StateTransition != nil { transitionCopy := *status.StateTransition clone.StateTransition = &transitionCopy @@ -380,10 +375,6 @@ func (sm *StatusManager) UpdateAgentStatus(ctx context.Context, nodeID string, u newStatus.LifecycleStatus = *update.LifecycleStatus } - if update.MCPStatus != nil { - newStatus.MCPStatus = update.MCPStatus - } - // Update metadata newStatus.LastUpdated = time.Now() newStatus.Source = update.Source @@ -439,7 +430,7 @@ func (sm *StatusManager) UpdateAgentStatus(ctx context.Context, nodeID string, u // Uses snapshot (not live health check) to avoid overriding admin-controlled states // and to prevent the heartbeat handler from contaminating the cache with HTTP check // results — the heartbeat itself is the proof of life. -func (sm *StatusManager) UpdateFromHeartbeat(ctx context.Context, nodeID string, lifecycleStatus *types.AgentLifecycleStatus, mcpStatus *types.MCPStatusInfo, version string) error { +func (sm *StatusManager) UpdateFromHeartbeat(ctx context.Context, nodeID string, lifecycleStatus *types.AgentLifecycleStatus, version string) error { currentStatus, err := sm.GetAgentStatusSnapshot(ctx, nodeID, nil) if err != nil { // If agent doesn't exist, create new status @@ -454,12 +445,11 @@ func (sm *StatusManager) UpdateFromHeartbeat(ctx context.Context, nodeID string, // so there is no need to suppress heartbeats here. // Update from heartbeat - currentStatus.UpdateFromHeartbeat(lifecycleStatus, mcpStatus) + currentStatus.UpdateFromHeartbeat(lifecycleStatus) // Persist changes — derive State from lifecycle so UpdateAgentStatus keeps them in sync. update := &types.AgentStatusUpdate{ LifecycleStatus: lifecycleStatus, - MCPStatus: mcpStatus, Source: types.StatusSourceHeartbeat, Reason: "heartbeat update", Version: version, diff --git a/control-plane/internal/services/status_manager_test.go b/control-plane/internal/services/status_manager_test.go index 8351c9b73..b219bae7a 100644 --- a/control-plane/internal/services/status_manager_test.go +++ b/control-plane/internal/services/status_manager_test.go @@ -38,18 +38,6 @@ func (f *fakeAgentClient) GetAgentStatus(ctx context.Context, nodeID string) (*i return f.statusResponse, nil } -func (f *fakeAgentClient) GetMCPHealth(ctx context.Context, nodeID string) (*interfaces.MCPHealthResponse, error) { - return nil, nil -} - -func (f *fakeAgentClient) RestartMCPServer(ctx context.Context, nodeID, alias string) error { - return nil -} - -func (f *fakeAgentClient) GetMCPTools(ctx context.Context, nodeID, alias string) (*interfaces.MCPToolsResponse, error) { - return nil, nil -} - func (f *fakeAgentClient) ShutdownAgent(ctx context.Context, nodeID string, graceful bool, timeoutSeconds int) (*interfaces.AgentShutdownResponse, error) { return nil, nil } @@ -522,7 +510,7 @@ func TestStatusManager_UpdateFromHeartbeat_NeverDropped(t *testing.T) { // Now send a heartbeat IMMEDIATELY (within what used to be the 10s drop window). // Previously this heartbeat would be silently ignored. Now it MUST be processed. readyStatus := types.AgentStatusReady - err = sm.UpdateFromHeartbeat(ctx, "node-heartbeat-priority", &readyStatus, nil, "") + err = sm.UpdateFromHeartbeat(ctx, "node-heartbeat-priority", &readyStatus, "") require.NoError(t, err, "Heartbeat should never be dropped") // Verify the heartbeat was processed — agent should no longer be inactive diff --git a/control-plane/internal/services/ui_service.go b/control-plane/internal/services/ui_service.go index 037889af4..928a88935 100644 --- a/control-plane/internal/services/ui_service.go +++ b/control-plane/internal/services/ui_service.go @@ -7,9 +7,7 @@ import ( "sync" "time" - "github.com/Agent-Field/agentfield/control-plane/internal/core/domain" "github.com/Agent-Field/agentfield/control-plane/internal/core/interfaces" - "github.com/Agent-Field/agentfield/control-plane/internal/events" "github.com/Agent-Field/agentfield/control-plane/internal/logger" "github.com/Agent-Field/agentfield/control-plane/internal/storage" "github.com/Agent-Field/agentfield/control-plane/pkg/types" @@ -69,8 +67,6 @@ type AgentNodeSummaryForUI struct { SkillCount int `json:"skill_count"` LastHeartbeat time.Time `json:"last_heartbeat"` - // New MCP fields - MCPSummary *domain.MCPSummaryForUI `json:"mcp_summary,omitempty"` } // GetNodesSummary retrieves a list of node summaries with robust status checking. @@ -104,8 +100,6 @@ func (s *UIService) GetNodesSummary(ctx context.Context) ([]AgentNodeSummaryForU LastHeartbeat: node.LastHeartbeat, } - // Enhance with MCP health data - s.enhanceNodeSummaryWithMCP(&summaries[i]) } return summaries, len(summaries), nil } @@ -401,141 +395,6 @@ func (s *UIService) OnAgentRemoved(nodeID string) { s.BroadcastEvent("node_removed", map[string]string{"id": nodeID}) } -// fetchMCPHealthForNode retrieves MCP health data for a specific node -func (s *UIService) fetchMCPHealthForNode(ctx context.Context, nodeID string, mode domain.MCPHealthMode) (*domain.MCPSummaryForUI, []domain.MCPServerHealthForUI, error) { - if s.agentClient == nil { - // Agent client not available, return empty data - return nil, nil, nil - } - - // Fetch MCP health from agent - healthResponse, err := s.agentClient.GetMCPHealth(ctx, nodeID) - if err != nil { - // Log error but don't fail - agent might not support MCP - logger.Logger.Warn().Err(err).Msgf("Failed to fetch MCP health for node %s", nodeID) - return nil, nil, nil - } - - // Transform to domain models - healthData := &domain.MCPHealthResponseData{ - Servers: make([]domain.MCPServerHealthData, len(healthResponse.Servers)), - Summary: domain.MCPSummaryData{ - TotalServers: healthResponse.Summary.TotalServers, - RunningServers: healthResponse.Summary.RunningServers, - TotalTools: healthResponse.Summary.TotalTools, - OverallHealth: healthResponse.Summary.OverallHealth, - }, - } - - for i, server := range healthResponse.Servers { - var startedAt, lastHealthCheck *time.Time - - // Convert FlexibleTime to *time.Time - if server.StartedAt != nil { - t := server.StartedAt.Time - startedAt = &t - } - if server.LastHealthCheck != nil { - t := server.LastHealthCheck.Time - lastHealthCheck = &t - } - - healthData.Servers[i] = domain.MCPServerHealthData{ - Alias: server.Alias, - Status: server.Status, - ToolCount: server.ToolCount, - StartedAt: startedAt, - LastHealthCheck: lastHealthCheck, - ErrorMessage: server.ErrorMessage, - Port: server.Port, - ProcessID: server.ProcessID, - SuccessRate: server.SuccessRate, - AvgResponseTime: server.AvgResponseTime, - } - } - - // Transform based on mode - summary, servers := domain.TransformMCPHealthForMode(healthData, mode) - return summary, servers, nil -} - -// GetNodeDetailsWithMCP retrieves full details for a specific node including MCP data -func (s *UIService) GetNodeDetailsWithMCP(ctx context.Context, nodeID string, mode domain.MCPHealthMode) (*domain.AgentNodeDetailsForUI, error) { - // Get base node details - node, err := s.storage.GetAgent(ctx, nodeID) - if err != nil { - return nil, err - } - - // Create base details - details := &domain.AgentNodeDetailsForUI{ - ID: node.ID, - TeamID: node.TeamID, - BaseURL: node.BaseURL, - Version: node.Version, - HealthStatus: string(node.HealthStatus), - LastHeartbeat: node.LastHeartbeat, - RegisteredAt: node.RegisteredAt, - } - - // Fetch MCP health data - mcpCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - mcpSummary, mcpServers, err := s.fetchMCPHealthForNode(mcpCtx, nodeID, mode) - if err != nil { - // Log error but continue without MCP data - logger.Logger.Warn().Err(err).Msgf("Failed to fetch MCP health for node details %s", nodeID) - } else { - details.MCPSummary = mcpSummary - if mode == domain.MCPHealthModeDeveloper { - details.MCPServers = mcpServers - } - } - - return details, nil -} - -// enhanceNodeSummaryWithMCP adds MCP health data to a node summary -func (s *UIService) enhanceNodeSummaryWithMCP(summary *AgentNodeSummaryForUI) { - if s.agentClient == nil { - return - } - - // Skip slow MCP lookups for nodes that are not currently active - if summary.HealthStatus != types.HealthStatusActive { - return - } - - // Use a short timeout so the nodes summary endpoint stays fast - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - - // Fetch MCP health in user mode for summary - mcpSummary, _, err := s.fetchMCPHealthForNode(ctx, summary.ID, domain.MCPHealthModeUser) - if err != nil { - // Silently continue without MCP data - return - } - - summary.MCPSummary = mcpSummary -} - -// OnMCPHealthChanged is a callback for when MCP health status changes -func (s *UIService) OnMCPHealthChanged(nodeID string, mcpSummary *domain.MCPSummaryForUI) { - mcpData := map[string]interface{}{ - "node_id": nodeID, - "mcp_summary": mcpSummary, - "timestamp": time.Now(), - } - - // Publish to dedicated node event bus for immediate updates - events.PublishNodeMCPHealthChanged(nodeID, mcpData) - - // Keep legacy broadcast for backward compatibility - s.BroadcastEvent("mcp_health_changed", mcpData) -} - // startHeartbeat starts the SSE heartbeat mechanism to keep connections alive func (s *UIService) startHeartbeat() { s.heartbeatTicker = time.NewTicker(30 * time.Second) // Send heartbeat every 30 seconds @@ -634,12 +493,6 @@ func (s *UIService) getEventCacheKey(event NodeEvent) string { if summary, ok := event.Node.(AgentNodeSummaryForUI); ok { return fmt.Sprintf("%s:%s", event.Type, summary.ID) } - case "mcp_health_changed": - if data, ok := event.Node.(map[string]interface{}); ok { - if nodeID, exists := data["node_id"]; exists { - return fmt.Sprintf("%s:%v", event.Type, nodeID) - } - } case "node_removed": if data, ok := event.Node.(map[string]string); ok { if nodeID, exists := data["id"]; exists { diff --git a/control-plane/pkg/types/agent_status_test.go b/control-plane/pkg/types/agent_status_test.go index b0d391a63..e149d8896 100644 --- a/control-plane/pkg/types/agent_status_test.go +++ b/control-plane/pkg/types/agent_status_test.go @@ -59,14 +59,12 @@ func TestUpdateFromHeartbeat(t *testing.T) { status := FromLegacyStatus(HealthStatusInactive, AgentStatusOffline, lastBeat) lifecycle := AgentStatusReady - mcp := &MCPStatusInfo{OverallHealth: 0.75} - status.UpdateFromHeartbeat(&lifecycle, mcp) + status.UpdateFromHeartbeat(&lifecycle) require.Equal(t, AgentStateActive, status.State) require.Equal(t, lifecycle, status.LifecycleStatus) require.Equal(t, HealthStatusActive, status.HealthStatus) require.Equal(t, StatusSourceHeartbeat, status.Source) - require.Equal(t, mcp, status.MCPStatus) require.GreaterOrEqual(t, status.HealthScore, 80) } diff --git a/control-plane/pkg/types/types.go b/control-plane/pkg/types/types.go index d9e767db3..d6e0b4815 100644 --- a/control-plane/pkg/types/types.go +++ b/control-plane/pkg/types/types.go @@ -273,9 +273,6 @@ type AgentStatus struct { LifecycleStatus AgentLifecycleStatus `json:"lifecycle_status"` // Backward compatibility HealthStatus HealthStatus `json:"health_status"` // Backward compatibility - // MCP status (optional) - MCPStatus *MCPStatusInfo `json:"mcp_status,omitempty"` // MCP server status if available - // Transition tracking StateTransition *StateTransition `json:"state_transition,omitempty"` // Current transition if any @@ -295,16 +292,6 @@ const ( AgentStateStopping AgentState = "stopping" // Agent is shutting down ) -// MCPStatusInfo represents MCP server status information -type MCPStatusInfo struct { - TotalServers int `json:"total_servers"` - RunningServers int `json:"running_servers"` - TotalTools int `json:"total_tools"` - OverallHealth float64 `json:"overall_health"` - ServiceStatus string `json:"service_status"` // "ready", "degraded", "unavailable" - LastChecked time.Time `json:"last_checked"` -} - // StateTransition represents an ongoing state transition type StateTransition struct { From AgentState `json:"from"` @@ -329,8 +316,7 @@ type AgentStatusUpdate struct { State *AgentState `json:"state,omitempty"` HealthScore *int `json:"health_score,omitempty"` LifecycleStatus *AgentLifecycleStatus `json:"lifecycle_status,omitempty"` - MCPStatus *MCPStatusInfo `json:"mcp_status,omitempty"` - Source StatusSource `json:"source"` + Source StatusSource `json:"source"` Reason string `json:"reason,omitempty"` Version string `json:"version,omitempty"` } @@ -452,7 +438,7 @@ func FromLegacyStatus(healthStatus HealthStatus, lifecycleStatus AgentLifecycleS } // UpdateFromHeartbeat updates the status based on heartbeat data -func (as *AgentStatus) UpdateFromHeartbeat(lifecycleStatus *AgentLifecycleStatus, mcpStatus *MCPStatusInfo) { +func (as *AgentStatus) UpdateFromHeartbeat(lifecycleStatus *AgentLifecycleStatus) { now := time.Now() as.LastSeen = now as.LastUpdated = now @@ -481,17 +467,6 @@ func (as *AgentStatus) UpdateFromHeartbeat(lifecycleStatus *AgentLifecycleStatus } } - // Update MCP status if provided - if mcpStatus != nil { - as.MCPStatus = mcpStatus - - // Adjust health score based on MCP health - if mcpStatus.OverallHealth > 0 { - mcpHealthContribution := int(mcpStatus.OverallHealth * 20) // Up to 20 points from MCP - as.HealthScore = min(100, as.HealthScore+mcpHealthContribution) - } - } - // Update backward compatibility fields as.HealthStatus = as.ToLegacyHealthStatus() } diff --git a/control-plane/web/client/src/components/AccessibilityEnhancements.tsx b/control-plane/web/client/src/components/AccessibilityEnhancements.tsx index b064d2d46..c48831251 100644 --- a/control-plane/web/client/src/components/AccessibilityEnhancements.tsx +++ b/control-plane/web/client/src/components/AccessibilityEnhancements.tsx @@ -312,19 +312,6 @@ export function ErrorAnnouncer({ ); } -/** - * MCP-specific accessibility enhancements - */ -export function MCPAccessibilityProvider({ children }: { children: React.ReactNode }) { - return ( -
- Skip to main content - Skip to MCP servers - Skip to MCP tools - {children} -
- ); -} /** * Hook for managing focus and announcements diff --git a/control-plane/web/client/src/components/ErrorBoundary.tsx b/control-plane/web/client/src/components/ErrorBoundary.tsx index ea64d05a9..da6e6cc88 100644 --- a/control-plane/web/client/src/components/ErrorBoundary.tsx +++ b/control-plane/web/client/src/components/ErrorBoundary.tsx @@ -22,7 +22,7 @@ interface State { } /** - * Error Boundary component for graceful error handling in MCP UI components + * Error Boundary component for graceful error handling in UI components * Provides fallback UI and error reporting capabilities */ export class ErrorBoundary extends Component { @@ -203,59 +203,3 @@ export function useErrorHandler() { }; } -/** - * MCP-specific error boundary with specialized error handling - */ -export function MCPErrorBoundary({ - children, - nodeId, - componentName -}: { - children: ReactNode; - nodeId?: string; - componentName?: string; -}) { - const handleError = (error: Error, errorInfo: ErrorInfo) => { - // Log MCP-specific error context - console.error(`MCP Error in ${componentName || 'Unknown Component'}:`, { - nodeId, - error: error.message, - stack: error.stack, - componentStack: errorInfo.componentStack, - timestamp: new Date().toISOString() - }); - }; - - const fallback = ( - - -
-

MCP Component Error

-

- {componentName ? `The ${componentName} component` : 'This MCP component'} - {' '}encountered an error and couldn't be displayed. - {nodeId && ` (Node: ${nodeId})`} -

- -
-
- ); - - return ( - - {children} - - ); -} diff --git a/control-plane/web/client/src/components/LoadingSkeleton.tsx b/control-plane/web/client/src/components/LoadingSkeleton.tsx index f848604d5..9aa096292 100644 --- a/control-plane/web/client/src/components/LoadingSkeleton.tsx +++ b/control-plane/web/client/src/components/LoadingSkeleton.tsx @@ -145,36 +145,6 @@ export function LoadingSkeleton({ ); } -/** - * MCP-specific loading skeletons - */ -export function MCPServerListSkeleton({ count = 3 }: { count?: number }) { - return ; -} - -export function MCPMetricsSkeleton() { - return ; -} - -export function MCPOverviewSkeleton() { - return ( -
- - - -
- ); -} - -export function MCPToolsSkeleton() { - return ( -
- - -
- ); -} - /** * Conditional loading wrapper that shows skeleton while loading */ diff --git a/control-plane/web/client/src/components/NodeCard.test.tsx b/control-plane/web/client/src/components/NodeCard.test.tsx index e043d2de5..660331fde 100644 --- a/control-plane/web/client/src/components/NodeCard.test.tsx +++ b/control-plane/web/client/src/components/NodeCard.test.tsx @@ -72,11 +72,6 @@ vi.mock("./did/DIDDisplay", () => ({ DIDIdentityBadge: ({ nodeId }: { nodeId: string }) => {`did:${nodeId}`}, })); -vi.mock("./mcp/MCPHealthIndicator", () => ({ - MCPHealthDot: ({ status }: { status: string }) => {`dot:${status}`}, - MCPHealthIndicator: ({ status }: { status: string }) => {`mcp:${status}`}, -})); - vi.mock("@/components/ui/AgentControlButton", () => ({ AgentControlButton: ({ agentId, @@ -119,15 +114,6 @@ const createNodeSummary = ( deployment_type: "serverless", reasoner_count: 5, skill_count: 4, - mcp_summary: { - service_status: "ready", - running_servers: 2, - total_servers: 3, - total_tools: 8, - overall_health: 92, - has_issues: true, - capabilities_available: true, - }, ...overrides, }); @@ -166,15 +152,12 @@ describe("NodeCard", () => { expect(screen.getByText("v1.2.3")).toBeInTheDocument(); expect(screen.getByText("Serverless")).toBeInTheDocument(); expect(screen.getByText("High capability")).toBeInTheDocument(); - expect(screen.getByText("Issues detected")).toBeInTheDocument(); expect(screen.getByText("1m ago")).toBeInTheDocument(); expect(screen.getByText("5 reasoners")).toBeInTheDocument(); expect(screen.getByText("4 skills")).toBeInTheDocument(); expect(screen.getByText("Team team-alpha")).toBeInTheDocument(); expect(screen.getByText("active:5")).toBeInTheDocument(); expect(screen.getByText("did:node-1")).toBeInTheDocument(); - expect(screen.getByText("dot:running")).toBeInTheDocument(); - expect(screen.getByText("mcp:running")).toBeInTheDocument(); }); it("navigates to the node detail page on click and keyboard activation", () => { @@ -199,7 +182,6 @@ describe("NodeCard", () => { healthStatus: "ready" as HealthStatus, expectedLabel: "Ready", expectedAction: mocks.stopAgent, - mcpSummary: createNodeSummary().mcp_summary, }, { name: "offline node", @@ -207,7 +189,6 @@ describe("NodeCard", () => { healthStatus: "inactive" as HealthStatus, expectedLabel: "Offline", expectedAction: mocks.startAgent, - mcpSummary: undefined, }, { name: "degraded node", @@ -215,25 +196,15 @@ describe("NodeCard", () => { healthStatus: "ready" as HealthStatus, expectedLabel: "Degraded", expectedAction: mocks.reconcileAgent, - mcpSummary: { - service_status: "degraded", - running_servers: 1, - total_servers: 3, - total_tools: 8, - overall_health: 40, - has_issues: true, - capabilities_available: false, - }, }, ])( "renders the correct status and handles the action button for a $name", - ({ lifecycleStatus, healthStatus, expectedLabel, expectedAction, mcpSummary }) => { + ({ lifecycleStatus, healthStatus, expectedLabel, expectedAction }) => { render( ); @@ -252,7 +223,6 @@ describe("NodeCard", () => { = 8; - // Convert MCP service status to MCPHealthStatus with defensive checks - const getMCPHealthStatus = (): MCPHealthStatus => { - // Comprehensive null/undefined checks to prevent Object.entries() errors - if (!nodeSummary?.mcp_summary || - typeof nodeSummary.mcp_summary !== 'object' || - nodeSummary.mcp_summary === null) { - return "unknown"; - } - - // Additional safety check for service_status property - const serviceStatus = nodeSummary.mcp_summary.service_status; - if (!serviceStatus || typeof serviceStatus !== 'string') { - return "unknown"; - } - - switch (serviceStatus) { - case "ready": - return "running"; - case "degraded": - return "error"; - case "unavailable": - return "stopped"; - default: - return "unknown"; - } - }; - // Format time ago with enhanced precision const formatTimeAgo = (date: Date | null) => { if (!date) return "Never"; @@ -208,16 +178,12 @@ const NodeCard = memo( if (actionLoading === 'stop') return 'stopping'; if (actionLoading === 'reconcile') return 'reconciling'; - // Check multiple sources for running state (more robust detection) const isRunning = lifecycleStatus === 'ready' || - lifecycleStatus === 'degraded' || - mcpSummary?.service_status === 'ready' || - (mcpSummary?.total_servers ?? 0) > 0; + lifecycleStatus === 'degraded'; if (isRunning) { - // Check for error/degraded states - if (lifecycleStatus === 'degraded' || mcpSummary?.service_status === 'degraded') { + if (lifecycleStatus === 'degraded') { return 'error'; } return 'running'; @@ -254,11 +220,6 @@ const NodeCard = memo( const teamId = nodeSummary.team_id || "unknown"; const deploymentType = nodeSummary.deployment_type || null; - const totalMcpServers = mcpSummary?.total_servers ?? 0; - const runningMcpServers = mcpSummary?.running_servers ?? 0; - const totalMcpTools = mcpSummary?.total_tools ?? 0; - const hasMcpIssues = Boolean(mcpSummary?.has_issues); - const capabilitiesAvailable = Boolean(mcpSummary?.capabilities_available); const containerPadding = density === "compact" @@ -364,15 +325,6 @@ const NodeCard = memo( High capability )} - {hasMcpIssues && ( - - Issues detected - - )}
)} - {mcpSummary && ( -
- - - {runningMcpServers}/{totalMcpServers} MCP servers - - {totalMcpTools > 0 && ( - - ({totalMcpTools} tools) - - )} - {capabilitiesAvailable && ( - - Capabilities ready - - )} -
- )}
@@ -460,14 +390,6 @@ const NodeCard = memo(
- {mcpSummary && ( - - )} {didStatus && didStatus.has_did && ( ) { - return null; -} - -export function MCPHealthIndicator(_props: Record) { - return null; -} diff --git a/control-plane/web/client/src/components/mcp/index.ts b/control-plane/web/client/src/components/mcp/index.ts deleted file mode 100644 index 0841333eb..000000000 --- a/control-plane/web/client/src/components/mcp/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -// MCP component stubs for NodeDetailPage - -interface MCPServerListProps { - servers: unknown[]; - nodeId: string; - onServerAction?: (action: string, serverAlias: string) => Promise; -} - -interface MCPServerControlsProps { - servers: unknown[]; - nodeId: string; - onBulkAction?: (action: string, serverAliases: string[]) => Promise; -} - -interface MCPToolExplorerProps { - tools: unknown[]; - serverAlias: string; - nodeId: string; -} - -interface MCPToolTesterProps { - tool: { - name: string; - description: string; - input_schema: { type: string; properties: Record }; - }; - serverAlias: string; - nodeId: string; -} - -export function MCPServerList(_props: MCPServerListProps) { - return null; -} - -export function MCPServerControls(_props: MCPServerControlsProps) { - return null; -} - -export function MCPToolExplorer(_props: MCPToolExplorerProps) { - return null; -} - -export function MCPToolTester(_props: MCPToolTesterProps) { - return null; -} diff --git a/control-plane/web/client/src/components/status/UnifiedStatusIndicator.tsx b/control-plane/web/client/src/components/status/UnifiedStatusIndicator.tsx index 32dfcf906..b75316d93 100644 --- a/control-plane/web/client/src/components/status/UnifiedStatusIndicator.tsx +++ b/control-plane/web/client/src/components/status/UnifiedStatusIndicator.tsx @@ -149,12 +149,6 @@ export function UnifiedStatusIndicator({ Health: {typeof status.health_score === 'number' ? `${status.health_score}%` : 'N/A'}
Last seen: {formatTimestamp(status.last_seen)}
- {status.mcp_status && ( -
- MCP: {status.mcp_status.running_servers}/ - {status.mcp_status.total_servers} servers -
- )} {getTransitionInfo()} )} diff --git a/control-plane/web/client/src/hooks/useSSE.ts b/control-plane/web/client/src/hooks/useSSE.ts index 9db077a2e..1de4b8f14 100644 --- a/control-plane/web/client/src/hooks/useSSE.ts +++ b/control-plane/web/client/src/hooks/useSSE.ts @@ -314,21 +314,6 @@ export function useSSE( }; } -/** - * Specialized hook for MCP health events - */ -export function useMCPHealthSSE(nodeId: string | null) { - const url = nodeId ? `/api/ui/v1/nodes/${nodeId}/mcp/events` : null; - - return useSSE(url, { - eventTypes: ['server_status_change', 'tool_execution', 'health_update', 'error'], - autoReconnect: true, - maxReconnectAttempts: 3, - reconnectDelayMs: 2000, - exponentialBackoff: true - }); -} - /** * Specialized hook for agent node events including status changes */ @@ -343,8 +328,6 @@ export function useNodeEventsSSE() { 'node_status_updated', 'node_health_changed', 'node_removed', - 'mcp_health_changed', - // New unified status events 'node_unified_status_changed', 'node_state_transition', 'node_status_refreshed', diff --git a/control-plane/web/client/src/mcp/README.md b/control-plane/web/client/src/mcp/README.md deleted file mode 100644 index 1dfc9b4f5..000000000 --- a/control-plane/web/client/src/mcp/README.md +++ /dev/null @@ -1,364 +0,0 @@ -# MCP UI System Documentation - -## Overview - -The MCP (Model Context Protocol) UI System is a comprehensive frontend implementation for managing and monitoring MCP servers, tools, and performance metrics. This system provides a complete interface for interacting with MCP infrastructure in both user and developer modes. - -## Architecture - -### Core Components - -The MCP UI system is built with a modular architecture consisting of: - -1. **Components** - Reusable UI components for MCP functionality -2. **Hooks** - Custom React hooks for state management and API integration -3. **Services** - API service layer for backend communication -4. **Utilities** - Helper functions for data processing and formatting -5. **Types** - TypeScript definitions for type safety - -### Component Hierarchy - -``` -MCP UI System -ā”œā”€ā”€ Components (agentfield/web/client/src/components/mcp/) -│ ā”œā”€ā”€ MCPHealthIndicator - Status indicators and health displays -│ ā”œā”€ā”€ MCPServerCard - Individual server information cards -│ ā”œā”€ā”€ MCPServerList - List of all MCP servers -│ ā”œā”€ā”€ MCPToolExplorer - Tool discovery and exploration -│ ā”œā”€ā”€ MCPToolTester - Interactive tool testing interface -│ └── MCPServerControls - Bulk server management controls -ā”œā”€ā”€ Hooks (agentfield/web/client/src/hooks/) -│ ā”œā”€ā”€ useMCPHealth - Health monitoring and real-time updates -│ ā”œā”€ā”€ useMCPServers - Server management operations -│ ā”œā”€ā”€ useMCPTools - Tool discovery and execution -│ ā”œā”€ā”€ useMCPMetrics - Performance metrics monitoring -│ └── useSSE - Server-Sent Events for real-time updates -ā”œā”€ā”€ Utilities (agentfield/web/client/src/utils/) -│ └── mcpUtils - Formatting, validation, and helper functions -└── Integration (agentfield/web/client/src/mcp/) - └── index.ts - Centralized exports and integration patterns -``` - -## Features - -### 1. Real-Time Health Monitoring - -- **Live Status Updates**: Real-time server status via Server-Sent Events -- **Health Indicators**: Visual status indicators with color coding -- **Mode-Aware Display**: Different information levels for user vs developer modes -- **Automatic Refresh**: Configurable auto-refresh intervals - -### 2. Server Management - -- **Individual Operations**: Start, stop, restart individual servers -- **Bulk Operations**: Manage multiple servers simultaneously -- **Status Tracking**: Track operation progress and results -- **Error Handling**: Comprehensive error reporting and retry logic - -### 3. Tool Discovery and Testing - -- **Dynamic Tool Loading**: Discover available tools from running servers -- **Interactive Testing**: Test tools with custom parameters -- **Parameter Validation**: Schema-based parameter validation -- **Execution History**: Track tool execution results and performance - -### 4. Performance Monitoring - -- **Real-Time Metrics**: Live performance data collection -- **Historical Data**: Trend analysis and historical metrics -- **Performance Alerts**: Configurable performance thresholds -- **Resource Monitoring**: CPU, memory, and response time tracking - -### 5. Accessibility and UX - -- **Screen Reader Support**: Full ARIA compliance and screen reader support -- **Keyboard Navigation**: Complete keyboard accessibility -- **Loading States**: Skeleton screens for better perceived performance -- **Error Boundaries**: Graceful error handling and recovery - -## Usage Examples - -### Basic Health Monitoring - -```typescript -import { useMCPHealth } from '@/mcp'; - -function MyComponent({ nodeId }: { nodeId: string }) { - const { summary, servers, loading, error } = useMCPHealth(nodeId, { - enableRealTime: true, - refreshInterval: 30000 - }); - - if (loading) return
Loading...
; - if (error) return
Error: {error}
; - - return ( -
-

Health: {summary?.overall_health}%

-

Running: {summary?.running_servers}/{summary?.total_servers}

-
- ); -} -``` - -### Server Management - -```typescript -import { useMCPServers } from '@/mcp'; - -function ServerManager({ nodeId }: { nodeId: string }) { - const { startServer, stopServer, restartServer, loading } = useMCPServers(nodeId); - - const handleRestart = async (serverId: string) => { - await restartServer(serverId, { - onComplete: (result) => console.log('Restart completed:', result), - onError: (error) => console.error('Restart failed:', error) - }); - }; - - return ( - - ); -} -``` - -### Tool Testing - -```typescript -import { useMCPTools } from '@/mcp'; - -function ToolTester({ nodeId, serverId }: { nodeId: string; serverId: string }) { - const { tools, executeTool, executions } = useMCPTools(nodeId, serverId); - - const handleExecute = async (toolName: string) => { - const result = await executeTool(toolName, { param1: 'value1' }, { - validateParams: true, - timeoutMs: 30000, - onComplete: (result) => console.log('Tool executed:', result) - }); - }; - - return ( -
- {tools.map(tool => ( - - ))} -
- ); -} -``` - -### Complete Integration - -```typescript -import { useMCPIntegration } from '@/mcp'; - -function MCPDashboard({ nodeId }: { nodeId: string }) { - const mcp = useMCPIntegration(nodeId, { - enableRealTime: true, - enableMetrics: true, - enableTools: true - }); - - return ( -
-

MCP Dashboard

- - {/* Health Overview */} -
- Health: {mcp.health.isHealthy ? 'Good' : 'Issues Detected'} - Servers: {mcp.health.runningCount}/{mcp.health.totalCount} -
- - {/* Quick Actions */} -
- - - -
- - {/* Metrics */} - {mcp.metrics && ( -
- Performance: {mcp.metrics.getPerformanceSummary()?.avgResponseTime}ms -
- )} -
- ); -} -``` - -## Configuration - -### Environment Variables - -```bash -# API Base URL -VITE_API_BASE_URL=/api/ui/v1 - -# Development mode -NODE_ENV=development -``` - -### Hook Options - -Most hooks accept configuration options: - -```typescript -interface MCPHealthOptions { - refreshInterval?: number; // Auto-refresh interval (ms) - enableRealTime?: boolean; // Enable SSE updates - cacheTtl?: number; // Cache TTL (ms) - sortByPriority?: boolean; // Sort servers by priority - onHealthChange?: (health: MCPSummaryForUI) => void; - onServerStatusChange?: (server: MCPServerHealthForUI) => void; -} -``` - -## Error Handling - -The system includes comprehensive error handling: - -### Error Boundaries - -```typescript -import { MCPErrorBoundary } from '@/components/ErrorBoundary'; - - - - -``` - -### API Error Handling - -- **Automatic Retry**: Configurable retry logic for transient failures -- **Error Classification**: Distinguish between retryable and permanent errors -- **User-Friendly Messages**: Mode-aware error message formatting - -### Accessibility Features - -- **Screen Reader Support**: Full ARIA compliance -- **Keyboard Navigation**: Tab order and keyboard shortcuts -- **Live Regions**: Status announcements for dynamic content -- **Focus Management**: Proper focus handling for modals and interactions - -## Performance Optimizations - -### Memoization - -Components use React.memo and useMemo for optimal re-rendering: - -```typescript -const MemoizedServerCard = React.memo(MCPServerCard); -``` - -### Loading States - -Skeleton screens provide better perceived performance: - -```typescript -import { LoadingWrapper, MCPServerListSkeleton } from '@/components/LoadingSkeleton'; - -} -> - - -``` - -### Caching - -- **Response Caching**: Configurable TTL for API responses -- **State Persistence**: Maintain state across component unmounts -- **Optimistic Updates**: Immediate UI updates with rollback on failure - -## Testing - -### Unit Tests - -```typescript -import { renderHook } from '@testing-library/react-hooks'; -import { useMCPHealth } from '@/mcp'; - -test('should fetch health data', async () => { - const { result, waitForNextUpdate } = renderHook(() => - useMCPHealth('test-node') - ); - - await waitForNextUpdate(); - - expect(result.current.summary).toBeDefined(); -}); -``` - -### Integration Tests - -```typescript -import { render, screen } from '@testing-library/react'; -import { MCPServerList } from '@/mcp'; - -test('should display server list', () => { - render(); - - expect(screen.getByText('Server 1')).toBeInTheDocument(); -}); -``` - -## Troubleshooting - -### Common Issues - -1. **SSE Connection Failures** - - Check network connectivity - - Verify API endpoint availability - - Check browser SSE support - -2. **Performance Issues** - - Reduce refresh intervals - - Disable real-time updates if not needed - - Check for memory leaks in long-running components - -3. **Type Errors** - - Ensure all MCP types are properly imported - - Check API response format matches type definitions - -### Debug Mode - -Enable debug logging in development: - -```typescript -// In development, detailed logs are available -if (process.env.NODE_ENV === 'development') { - console.log('MCP Debug Info:', debugData); -} -``` - -## Contributing - -### Adding New Components - -1. Create component in `agentfield/web/client/src/components/mcp/` -2. Add proper TypeScript types -3. Include accessibility features -4. Add error boundary support -5. Export from `agentfield/web/client/src/components/mcp/index.ts` - -### Adding New Hooks - -1. Create hook in `agentfield/web/client/src/hooks/` -2. Follow existing patterns for state management -3. Include proper cleanup and error handling -4. Add TypeScript documentation -5. Export from `agentfield/web/client/src/mcp/index.ts` - -## API Reference - -See the complete API reference in the individual component and hook files. Each export includes comprehensive JSDoc documentation with usage examples and parameter descriptions. diff --git a/control-plane/web/client/src/mcp/index.ts b/control-plane/web/client/src/mcp/index.ts deleted file mode 100644 index 2cfa1e6f8..000000000 --- a/control-plane/web/client/src/mcp/index.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * MCP (Model Context Protocol) Module Export - * - * NOTE: MCP UI components have been removed. This file retains exports for - * API services and types that may still be used by the control plane layer. - */ - -// ============================================================================ -// MCP API Services -// ============================================================================ -export { - getMCPHealth, - getMCPHealthModeAware, - restartMCPServer, - stopMCPServer, - startMCPServer, - getMCPTools, - getOverallMCPStatus, - getNodeDetailsWithMCP, - testMCPTool, - getMCPServerMetrics, - subscribeMCPHealthEvents, - getMCPHealthEvents, - bulkMCPServerAction, - getMCPServerConfig, - updateMCPServerConfig -} from '../services/api'; - -// ============================================================================ -// MCP Types -// ============================================================================ -export type { - MCPServerAction, - MCPSummaryForUI, - MCPServerHealthForUI, - MCPTool, - MCPToolTestRequest, - MCPToolTestResponse, - MCPHealthEvent, - MCPServerMetrics, - MCPNodeMetrics, - MCPErrorDetails, - MCPError, - AgentNodeDetailsForUI, - MCPHealthResponse, - MCPServerActionResponse, - MCPToolsResponse, - MCPOverallStatusResponse, - MCPServerMetricsResponse, - MCPHealthEventResponse, - MCPHealthResponseModeAware, - MCPHealthResponseUser, - MCPHealthResponseDeveloper, - AppMode -} from '../types/agentfield'; diff --git a/control-plane/web/client/src/pages/NodeDetailPage.tsx b/control-plane/web/client/src/pages/NodeDetailPage.tsx index f566d79de..46f292400 100644 --- a/control-plane/web/client/src/pages/NodeDetailPage.tsx +++ b/control-plane/web/client/src/pages/NodeDetailPage.tsx @@ -1,17 +1,10 @@ import { ErrorAnnouncer, - MCPAccessibilityProvider, StatusAnnouncer, useAccessibility, } from "@/components/AccessibilityEnhancements"; import { DIDInfoModal } from "@/components/did/DIDInfoModal"; import { EnvironmentVariableForm } from "@/components/forms/EnvironmentVariableForm"; -import { - MCPServerControls, - MCPServerList, - MCPToolExplorer, - MCPToolTester, -} from "@/components/mcp"; import { ReasonersSkillsTable } from "@/components/ReasonersSkillsTable"; import { StatusRefreshButton } from "@/components/status"; import { @@ -40,10 +33,8 @@ import { import { ResponsiveGrid } from "@/components/layout/ResponsiveGrid"; import { useMode } from "@/contexts/ModeContext"; import { useDIDInfo } from "@/hooks/useDIDInfo"; -import { useMCPHealthSSE, useNodeUnifiedStatusSSE } from "@/hooks/useSSE"; +import { useNodeUnifiedStatusSSE } from "@/hooks/useSSE"; import { - getMCPHealthModeAware, - getMCPServerMetrics, getNodeDetailsWithPackageInfo, getNodeStatus, } from "@/services/api"; @@ -63,9 +54,6 @@ import { cn } from "@/lib/utils"; import type { AgentNodeDetailsForUIWithPackage, AgentStatus, - MCPHealthResponseModeAware, - MCPServerHealthForUI, - MCPSummaryForUI, } from "@/types/agentfield"; import { useCallback, useEffect, useState } from "react"; import { useLocation, useNavigate, useParams } from "react-router-dom"; @@ -73,7 +61,7 @@ import { EnhancedNodeDetailHeader, NodeProcessLogsPanel } from "@/components/nod import { getNodeStatusPresentation } from "@/utils/node-status"; /** - * Comprehensive NodeDetailPage component with MCP management interface. + * Comprehensive NodeDetailPage component with agent management interface. * Features tabbed navigation, real-time updates, and mode-aware rendering. */ function NodeDetailPageContent() { @@ -92,9 +80,6 @@ function NodeDetailPageContent() { const [node, setNode] = useState( null ); - const [mcpHealth, setMcpHealth] = useState( - null - ); const [liveStatus, setLiveStatus] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -109,45 +94,11 @@ function NodeDetailPageContent() { const [showDIDModal, setShowDIDModal] = useState(false); // Real-time updates using optimized SSE hook - const { latestEvent } = useMCPHealthSSE(nodeId || null); const { latestEvent: unifiedStatusEvent } = useNodeUnifiedStatusSSE( nodeId || null ); const [lastUpdate, setLastUpdate] = useState(new Date()); - // Handle SSE events for real-time updates - useEffect(() => { - if (latestEvent && latestEvent.data) { - - // Update MCP health data based on event - if ( - latestEvent.type === "server_status_change" && - latestEvent.data.server_alias - ) { - setMcpHealth((prev) => { - if (!prev) return prev; - - const updatedServers = prev.mcp_servers?.map((server) => - server.alias === latestEvent.data.server_alias - ? { - ...server, - status: latestEvent.data.status || server.status, - } - : server - ); - - return { - ...prev, - mcp_servers: updatedServers, - timestamp: latestEvent.timestamp.toISOString(), - }; - }); - } - - setLastUpdate(new Date()); - } - }, [latestEvent]); - // Handle unified status events for real-time status updates useEffect(() => { if (!unifiedStatusEvent) return; @@ -220,9 +171,6 @@ function NodeDetailPageContent() { hash && [ "overview", - "mcp-servers", - "tools", - "performance", "configuration", "logs", ].includes(hash) @@ -240,7 +188,7 @@ function NodeDetailPageContent() { [navigate, location.pathname] ); - // Fetch node details and MCP data with progressive loading + // Fetch node details with progressive loading const fetchData = useCallback( async (showSpinner = true) => { if (!nodeId) { @@ -267,37 +215,14 @@ function NodeDetailPageContent() { setLoading(false); } - // Phase 2: Load MCP data and unified status in background (non-blocking) with shorter timeouts - Promise.allSettled([ - getMCPHealthModeAware(nodeId, mode), - getMCPServerMetrics(nodeId), - getNodeStatus(nodeId), - ]) - .then(([mcpData, metricsData, statusData]) => { - if (mcpData.status === "fulfilled") { - setMcpHealth(mcpData.value); - } else { - console.warn("Failed to fetch MCP health:", mcpData.reason); - } - - if (metricsData.status !== "fulfilled") { - console.warn("Failed to fetch MCP metrics:", metricsData.reason); - } - - if (statusData.status === "fulfilled") { - // Store the live status data for accurate status display - setLiveStatus(statusData.value); - } else { - console.warn( - "Failed to fetch unified status:", - statusData.reason - ); - } - + // Phase 2: Load unified status in background (non-blocking) + getNodeStatus(nodeId) + .then((statusData) => { + setLiveStatus(statusData); setLastUpdate(new Date()); }) .catch((err) => { - console.warn("Failed to load secondary MCP data:", err); + console.warn("Failed to fetch unified status:", err); }); } catch (err: any) { const errorMessage = err.message || "Failed to load node details."; @@ -450,13 +375,8 @@ function NodeDetailPageContent() { // PRIORITY 1: Use live status data from the unified status system (live health checks) if (liveStatus) { - // Map unified status lifecycle_status to AgentState switch (liveStatus.lifecycle_status) { case "ready": - // Check if MCP is degraded while lifecycle is ready - if (liveStatus.mcp_status?.service_status === "degraded") { - return "error"; - } return "running"; case "degraded": return "error"; @@ -465,24 +385,17 @@ function NodeDetailPageContent() { case "offline": return "stopped"; default: - // Fall through to legacy logic if status is unknown break; } } // FALLBACK: Legacy logic using cached data (for backward compatibility) const isRunning = - mcpSummary?.service_status === "ready" || node?.lifecycle_status === "ready" || - node?.lifecycle_status === "degraded" || - (mcpSummary?.total_servers && mcpSummary.total_servers > 0); + node?.lifecycle_status === "degraded"; if (isRunning) { - // Check for error/degraded states - if ( - mcpSummary?.service_status === "degraded" || - node?.lifecycle_status === "degraded" - ) { + if (node?.lifecycle_status === "degraded") { return "error"; } return "running"; @@ -519,78 +432,74 @@ function NodeDetailPageContent() { // Loading state with enhanced skeleton if (loading) { return ( - -
- - - {/* Header skeleton */} -
-
- -
- -
- - - -
+
+ + + {/* Header skeleton */} +
+
+ +
+ +
+ + +
-
- - -
+
+ + +
+
- {/* Tabs skeleton */} + {/* Tabs skeleton */} +
+
+ {["Overview", "Configuration", "Logs"].map( + (_, i) => ( + + ) + )} +
-
- {["Overview", "MCP Servers", "Tools", "Performance"].map( - (_, i) => ( - - ) - )} -
-
- - - - - - -
+ + + + + +
- +
); } // Error state with accessibility if (error) { return ( - -
- - - {error} - -
- - -
+
+ + + {error} + +
+ +
- +
); } @@ -609,19 +518,6 @@ function NodeDetailPageContent() { } const isDeveloperMode = mode === "developer"; - const mcpSummary: MCPSummaryForUI = mcpHealth?.mcp_summary || - node.mcp_summary || { - total_servers: 0, - running_servers: 0, - total_tools: 0, - overall_health: 0, - has_issues: false, - capabilities_available: false, - service_status: "unavailable", - }; - - const mcpServers: MCPServerHealthForUI[] = - mcpHealth?.mcp_servers || node.mcp_servers || []; const reasonerCount = node.reasoners?.length ?? 0; const skillCount = node.skills?.length ?? 0; @@ -646,10 +542,6 @@ function NodeDetailPageContent() { const headerMetadata: Array<{ label: string; value: string }> = [ { label: "Reasoners", value: String(reasonerCount) }, { label: "Skills", value: String(skillCount) }, - { - label: "MCP", - value: `${mcpSummary.running_servers}/${mcpSummary.total_servers} up`, - }, { label: "Mode", value: isDeveloperMode ? "Developer" : "User" }, ]; @@ -803,15 +695,6 @@ function NodeDetailPageContent() { Overview - - MCP Servers - - - Tools - - - Performance - Configuration @@ -929,96 +812,6 @@ function NodeDetailPageContent() {
- -
- -
- { - setTimeout(() => fetchData(false), 1000); - }} - /> -
-
- { - setTimeout(() => fetchData(false), 1000); - }} - /> -
-
-
-
- - -
- {mcpServers.length > 0 ? ( - mcpServers.map((server) => ( - - - - - )) - ) : ( -
-

- No MCP servers available for tool exploration. -

-
- )} -
-
- - -
-
-

- Performance metrics dashboard has been removed. -

-

- Detailed MCP server metrics are available in the MCP Servers tab. -

- -
-
-
- - - - + ); } diff --git a/control-plane/web/client/src/pages/NodesPage.tsx b/control-plane/web/client/src/pages/NodesPage.tsx index 03fb92c1f..28263cfca 100644 --- a/control-plane/web/client/src/pages/NodesPage.tsx +++ b/control-plane/web/client/src/pages/NodesPage.tsx @@ -175,25 +175,7 @@ export function NodesPage() { } break; - case "mcp_health_changed": - // Handle MCP health changes - if ( - eventData && - typeof eventData === "object" && - "node_id" in eventData - ) { - const mcpData = eventData as { node_id: string; mcp_summary: any }; - setNodes((prevNodes) => - prevNodes.map((node) => - node.id === mcpData.node_id - ? { ...node, mcp_summary: mcpData.mcp_summary } - : node - ) - ); - } - break; - - // New unified status events + // Unified status events case "node_unified_status_changed": if ( eventData && diff --git a/control-plane/web/client/src/services/api.ts b/control-plane/web/client/src/services/api.ts index 7ea4898fe..9e5b851a3 100644 --- a/control-plane/web/client/src/services/api.ts +++ b/control-plane/web/client/src/services/api.ts @@ -1,18 +1,7 @@ import type { AgentNode, AgentNodeSummary, - AgentNodeDetailsForUI, AgentNodeDetailsForUIWithPackage, - MCPHealthResponse, - MCPServerActionResponse, - MCPToolsResponse, - MCPOverallStatusResponse, - MCPToolTestRequest, - MCPToolTestResponse, - MCPServerMetricsResponse, - MCPHealthEventResponse, - MCPHealthResponseModeAware, - MCPError, AppMode, EnvResponse, SetEnvRequest, @@ -82,7 +71,7 @@ export function getGlobalAdminToken(): string | null { } /** - * Enhanced fetch wrapper with MCP-specific error handling, retry logic, and timeout support + * Enhanced fetch wrapper with retry logic and timeout support */ async function fetchWrapper(url: string, options?: RequestInit & { timeout?: number }): Promise { const { timeout = 10000, ...fetchOptions } = options || {}; @@ -110,16 +99,6 @@ async function fetchWrapper(url: string, options?: RequestInit & { timeout?: message: 'Request failed with status ' + response.status })); - // Create MCP-specific error if applicable - if (url.includes('/mcp/') && errorData.code) { - const mcpError = new Error(errorData.message || `HTTP error! status: ${response.status}`) as MCPError; - mcpError.code = errorData.code; - mcpError.details = errorData.details; - mcpError.isRetryable = errorData.is_retryable || false; - mcpError.retryAfterMs = errorData.retry_after_ms; - throw mcpError; - } - throw new Error(errorData.message || `HTTP error! status: ${response.status}`); } @@ -135,41 +114,6 @@ async function fetchWrapper(url: string, options?: RequestInit & { timeout?: } } -/** - * Retry wrapper for MCP operations with exponential backoff - */ -async function retryMCPOperation( - operation: () => Promise, - maxRetries: number = 3, - baseDelayMs: number = 1000 -): Promise { - let lastError: MCPError | Error; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - try { - return await operation(); - } catch (error) { - lastError = error as MCPError | Error; - - // Don't retry if it's not an MCP error or not retryable - if (!('isRetryable' in lastError) || !lastError.isRetryable) { - throw lastError; - } - - // Don't retry on last attempt - if (attempt === maxRetries) { - throw lastError; - } - - // Calculate delay with exponential backoff - const delay = lastError.retryAfterMs || (baseDelayMs * Math.pow(2, attempt)); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - - throw lastError!; -} - export async function getNodesSummary(): Promise<{ nodes: AgentNodeSummary[], count: number }> { return fetchWrapper<{ nodes: AgentNodeSummary[], count: number }>('/nodes/summary'); } @@ -186,219 +130,6 @@ export function streamNodeEvents(): EventSource { return new EventSource(url); } -// ============================================================================ -// MCP (Model Context Protocol) API Functions -// ============================================================================ - -// MCP Health API -export async function getMCPHealth( - nodeId: string, - mode: AppMode = 'user' -): Promise { - return fetchWrapper(`/nodes/${nodeId}/mcp/health?mode=${mode}`); -} - -// MCP Server Management -/** - * Restart a specific MCP server with retry logic - */ -export async function restartMCPServer( - nodeId: string, - serverId: string -): Promise { - return retryMCPOperation(() => - fetchWrapper(`/nodes/${nodeId}/mcp/servers/${serverId}/restart`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }) - ); -} - -/** - * Stop a specific MCP server - */ -export async function stopMCPServer( - nodeId: string, - serverId: string -): Promise { - return retryMCPOperation(() => - fetchWrapper(`/nodes/${nodeId}/mcp/servers/${serverId}/stop`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }) - ); -} - -/** - * Start a specific MCP server - */ -export async function startMCPServer( - nodeId: string, - serverId: string -): Promise { - return retryMCPOperation(() => - fetchWrapper(`/nodes/${nodeId}/mcp/servers/${serverId}/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }) - ); -} - -// MCP Tools API -export async function getMCPTools( - nodeId: string, - alias: string -): Promise { - return fetchWrapper(`/nodes/${nodeId}/mcp/servers/${alias}/tools`); -} - -// Overall MCP Status -export async function getOverallMCPStatus( - mode: AppMode = 'user' -): Promise { - return fetchWrapper(`/mcp/status?mode=${mode}`); -} - -// Enhanced Node Details with MCP -export async function getNodeDetailsWithMCP( - nodeId: string, - mode: AppMode = 'user' -): Promise { - return fetchWrapper(`/nodes/${nodeId}/details?include_mcp=true&mode=${mode}`, { - timeout: 8000 // 8 second timeout for node details - }); -} - -// ============================================================================ -// Enhanced MCP API Functions -// ============================================================================ - -/** - * Test MCP tool execution with parameters - */ -export async function testMCPTool( - nodeId: string, - serverId: string, - toolName: string, - params: Record, - timeoutMs?: number -): Promise { - const request: MCPToolTestRequest = { - node_id: nodeId, - server_alias: serverId, - tool_name: toolName, - parameters: params, - timeout_ms: timeoutMs - }; - - return retryMCPOperation(() => - fetchWrapper(`/nodes/${nodeId}/mcp/servers/${serverId}/tools/${toolName}/test`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request) - }) - ); -} - -/** - * Get MCP server performance metrics - */ -export async function getMCPServerMetrics( - nodeId: string, - serverId?: string -): Promise { - const endpoint = serverId - ? `/nodes/${nodeId}/mcp/servers/${serverId}/metrics` - : `/nodes/${nodeId}/mcp/metrics`; - - return fetchWrapper(endpoint); -} - -/** - * Subscribe to MCP health events via Server-Sent Events - */ -export function subscribeMCPHealthEvents(nodeId: string): EventSource { - const apiKey = getGlobalApiKey(); - const url = apiKey - ? `${API_BASE_URL}/nodes/${nodeId}/mcp/events?api_key=${encodeURIComponent(apiKey)}` - : `${API_BASE_URL}/nodes/${nodeId}/mcp/events`; - return new EventSource(url); -} - -/** - * Get recent MCP health events - */ -export async function getMCPHealthEvents( - nodeId: string, - limit: number = 50, - since?: string -): Promise { - const params = new URLSearchParams({ limit: limit.toString() }); - if (since) { - params.append('since', since); - } - - return fetchWrapper(`/nodes/${nodeId}/mcp/events/history?${params}`); -} - -/** - * Enhanced MCP health check with mode-aware responses - */ -export async function getMCPHealthModeAware( - nodeId: string, - mode: AppMode = 'user' -): Promise { - return fetchWrapper(`/nodes/${nodeId}/mcp/health?mode=${mode}`, { - timeout: 5000 // 5 second timeout for MCP health checks - }); -} - -/** - * Bulk MCP server actions (start/stop/restart multiple servers) - */ -export async function bulkMCPServerAction( - nodeId: string, - serverIds: string[], - action: 'start' | 'stop' | 'restart' -): Promise { - return retryMCPOperation(() => - fetchWrapper(`/nodes/${nodeId}/mcp/servers/bulk/${action}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ server_ids: serverIds }) - }) - ); -} - -/** - * Get MCP server configuration - */ -export async function getMCPServerConfig( - nodeId: string, - serverId: string -): Promise<{ config: Record; schema?: Record }> { - return fetchWrapper<{ config: Record; schema?: Record }>( - `/nodes/${nodeId}/mcp/servers/${serverId}/config` - ); -} - -/** - * Update MCP server configuration - */ -export async function updateMCPServerConfig( - nodeId: string, - serverId: string, - config: Record -): Promise { - return retryMCPOperation(() => - fetchWrapper(`/nodes/${nodeId}/mcp/servers/${serverId}/config`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ config }) - }) - ); -} - // ============================================================================ // Environment Variable Management API Functions // ============================================================================ @@ -450,7 +181,7 @@ export async function getNodeDetailsWithPackageInfo( nodeId: string, mode: AppMode = 'user' ): Promise { - return fetchWrapper(`/nodes/${nodeId}/details?include_mcp=true&mode=${mode}`, { + return fetchWrapper(`/nodes/${nodeId}/details?mode=${mode}`, { timeout: 8000 // 8 second timeout for node details }); } diff --git a/control-plane/web/client/src/types/agentfield.ts b/control-plane/web/client/src/types/agentfield.ts index 702fafa1f..16112a1ef 100644 --- a/control-plane/web/client/src/types/agentfield.ts +++ b/control-plane/web/client/src/types/agentfield.ts @@ -9,8 +9,6 @@ export interface AgentNode { registered_at?: string; deployment_type?: string; // "long_running" or "serverless" invocation_url?: string; // For serverless agents - mcp_summary?: MCPSummaryForUI; - mcp_servers?: MCPServerHealthForUI[]; reasoners?: ReasonerDefinition[]; skills?: SkillDefinition[]; } @@ -25,7 +23,6 @@ export interface AgentNodeSummary { last_heartbeat?: string; deployment_type?: string; // "long_running" or "serverless" invocation_url?: string; // For serverless agents - mcp_summary?: MCPSummaryForUI; reasoner_count: number; skill_count: number; } @@ -38,66 +35,6 @@ export interface AgentNodeDetailsForUIWithPackage extends AgentNode { }; } -export interface MCPHealthResponse { - status: string; - mcp_servers?: MCPServerHealthForUI[]; - mcp_summary?: MCPSummaryForUI; -} - -export interface MCPServerActionResponse { - status: string; - success?: boolean; - error_details?: MCPErrorDetails; - server_alias?: string; -} - -export interface MCPToolsResponse { - tools: MCPTool[]; -} - -export interface MCPOverallStatusResponse { - status: string; -} - -export interface MCPToolTestRequest { - node_id: string; - server_alias: string; - tool_name: string; - parameters: any; - timeout_ms?: number; -} - -export interface MCPToolTestResponse { - success?: boolean; - error?: string; - execution_time_ms?: number; - result?: any; -} - -export interface MCPServerMetricsResponse { - metrics: MCPServerMetrics | MCPNodeMetrics; - node_id?: string; - server_alias?: string; - timestamp: string; -} - -export interface MCPHealthEventResponse { - events: MCPHealthEvent[]; -} - -export interface MCPHealthResponseModeAware { - status: string; - mcp_servers?: MCPServerHealthForUI[]; - mcp_summary?: MCPSummaryForUI; -} - -export interface MCPError extends Error { - code: string; - details?: any; - isRetryable: boolean; - retryAfterMs?: number; -} - export type AppMode = 'user' | 'admin' | 'developer'; export interface EnvResponse { @@ -136,11 +73,6 @@ export interface AgentStatus { last_seen?: string; health_status?: HealthStatus; lifecycle_status?: LifecycleStatus; - mcp_status?: { - running_servers: number; - total_servers: number; - service_status?: string; - }; } export interface AgentStatusUpdate { @@ -150,7 +82,7 @@ export interface AgentStatusUpdate { last_heartbeat?: string; } -export type StatusSource = 'agent' | 'mcp' | 'system'; +export type StatusSource = 'agent' | 'system'; export type HealthStatus = 'starting' | 'ready' | 'degraded' | 'offline' | 'active' | 'inactive' | 'unknown'; @@ -164,75 +96,6 @@ export type LifecycleStatus = | 'error' | 'unknown'; -export type MCPServerAction = 'start' | 'stop' | 'restart'; - -export type MCPServerStatus = 'running' | 'stopped' | 'error' | 'starting' | 'unknown'; - -export interface MCPServerHealthForUI { - alias: string; - status: MCPServerStatus; - tool_count: number; - started_at?: string; - last_health_check?: string; - error_message?: string; - port?: number; - process_id?: number; - success_rate?: number; - avg_response_time_ms?: number; - status_icon?: string; - status_color?: string; - uptime_formatted?: string; -} - -export interface MCPSummaryForUI { - service_status: string; - running_servers: number; - total_servers: number; - total_tools: number; - overall_health: number; - has_issues: boolean; - capabilities_available: boolean; -} - -export interface MCPHealthEvent { - timestamp: string; - type: string; - server_alias?: string; - node_id?: string; - message: string; - details?: any; - data?: any; -} - -export interface MCPServerMetrics { - alias: string; - total_requests: number; - successful_requests: number; - failed_requests: number; - avg_response_time_ms: number; - peak_response_time_ms: number; - requests_per_minute: number; - uptime_seconds: number; - error_rate_percent: number; -} - -export interface MCPNodeMetrics { - node_id: string; - total_requests: number; - avg_response_time: number; - error_rate: number; - timestamp: string; - servers: MCPServerMetrics[]; - total_servers: number; - active_servers: number; - overall_health_score: number; -} - -export interface MCPErrorDetails { - message?: string; - code?: string; -} - export type AgentConfigurationStatus = 'configured' | 'not_configured' | 'partially_configured' | 'unknown'; export interface AgentPackage { @@ -314,26 +177,3 @@ export interface ConfigurationSchema { version?: string; } -export interface MCPTool { - name: string; - description?: string; - input_schema?: { - type: string; - properties: Record; - required?: string[]; - }; - inputSchema?: { - type: string; - properties: Record; - required?: string[]; - }; -} - -export interface MCPHealthResponseUser { - status: string; -} - -export interface MCPHealthResponseDeveloper { - mcp_servers: MCPServerHealthForUI[]; - mcp_summary: MCPSummaryForUI; -} diff --git a/control-plane/web/client/src/utils/mcpUtils.ts b/control-plane/web/client/src/utils/mcpUtils.ts deleted file mode 100644 index f5448b7f7..000000000 --- a/control-plane/web/client/src/utils/mcpUtils.ts +++ /dev/null @@ -1,387 +0,0 @@ -import type { - MCPSummaryForUI, - MCPServerHealthForUI, - MCPServerMetrics, - MCPServerStatus, - MCPHealthEvent, - AppMode -} from '../types/agentfield'; - -/** - * Status color mapping for MCP servers - */ -export const MCP_STATUS_COLORS: Record = { - running: '#10b981', // green-500 - stopped: '#6b7280', // gray-500 - error: '#ef4444', // red-500 - starting: '#f59e0b', // amber-500 - unknown: '#9ca3af' // gray-400 -} as const; - -/** - * Status icons for MCP servers - */ -export const MCP_STATUS_ICONS: Record = { - running: 'ā—', - stopped: 'ā—‹', - error: 'āœ•', - starting: '◐', - unknown: '?' -} as const; - -/** - * Get color for MCP server status - */ -export function getMCPStatusColor(status: string): string { - if (status in MCP_STATUS_COLORS) { - return MCP_STATUS_COLORS[status as MCPServerStatus]; - } - return MCP_STATUS_COLORS.unknown; -} - -/** - * Get icon for MCP server status - */ -export function getMCPStatusIcon(status: string): string { - if (status in MCP_STATUS_ICONS) { - return MCP_STATUS_ICONS[status as MCPServerStatus]; - } - return MCP_STATUS_ICONS.unknown; -} - -/** - * Calculate overall health score from server health data - */ -export function calculateOverallHealth(servers: MCPServerHealthForUI[]): number { - if (servers.length === 0) return 0; - - const weights: Record = { - running: 1.0, - starting: 0.7, - stopped: 0.3, - error: 0.0, - unknown: 0.1 - }; - - const totalWeight = servers.reduce((sum, server) => { - const weight = weights[server.status] || 0; - return sum + weight; - }, 0); - - return Math.round((totalWeight / servers.length) * 100); -} - -/** - * Aggregate MCP summary from multiple servers - */ -export function aggregateMCPSummary(servers: MCPServerHealthForUI[]): MCPSummaryForUI { - const runningServers = servers.filter(s => s.status === 'running').length; - const totalTools = servers.reduce((sum, server) => sum + (server.tool_count || 0), 0); - const overallHealth = calculateOverallHealth(servers); - const hasIssues = servers.some(s => s.status === 'error' || s.error_message); - - let serviceStatus: 'ready' | 'degraded' | 'unavailable' = 'ready'; - if (runningServers === 0) { - serviceStatus = 'unavailable'; - } else if (runningServers < servers.length || hasIssues) { - serviceStatus = 'degraded'; - } - - return { - total_servers: servers.length, - running_servers: runningServers, - total_tools: totalTools, - overall_health: overallHealth, - has_issues: hasIssues, - capabilities_available: runningServers > 0, - service_status: serviceStatus - }; -} - -/** - * Format uptime duration - */ -export function formatUptime(startedAt: string | undefined): string { - if (!startedAt) return 'Unknown'; - - const start = new Date(startedAt); - const now = new Date(); - const diffMs = now.getTime() - start.getTime(); - - if (diffMs < 0) return 'Unknown'; - - const seconds = Math.floor(diffMs / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - return `${days}d ${hours % 24}h`; - } else if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } else if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } else { - return `${seconds}s`; - } -} - -/** - * Format response time in human-readable format - */ -export function formatResponseTime(timeMs: number | undefined): string { - if (timeMs === undefined || timeMs === null) return 'N/A'; - - if (timeMs < 1000) { - return `${Math.round(timeMs)}ms`; - } else { - return `${(timeMs / 1000).toFixed(1)}s`; - } -} - -/** - * Format success rate as percentage - */ -export function formatSuccessRate(rate: number | undefined): string { - if (rate === undefined || rate === null) return 'N/A'; - return `${Math.round(rate * 100)}%`; -} - -/** - * Format error rate as percentage - */ -export function formatErrorRate(rate: number | undefined): string { - if (rate === undefined || rate === null) return 'N/A'; - return `${Math.round(rate)}%`; -} - -/** - * Format memory usage - */ -export function formatMemoryUsage(memoryMb: number | undefined): string { - if (memoryMb === undefined || memoryMb === null) return 'N/A'; - - if (memoryMb < 1024) { - return `${Math.round(memoryMb)}MB`; - } else { - return `${(memoryMb / 1024).toFixed(1)}GB`; - } -} - -/** - * Format CPU usage as percentage - */ -export function formatCpuUsage(cpuPercent: number | undefined): string { - if (cpuPercent === undefined || cpuPercent === null) return 'N/A'; - return `${Math.round(cpuPercent)}%`; -} - -/** - * Format timestamp for display - */ -export function formatTimestamp(timestamp: string | undefined, mode: AppMode = 'user'): string { - if (!timestamp) return 'Unknown'; - - const date = new Date(timestamp); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMinutes = Math.floor(diffMs / (1000 * 60)); - - if (mode === 'user') { - // User-friendly relative time - if (diffMinutes < 1) return 'Just now'; - if (diffMinutes < 60) return `${diffMinutes}m ago`; - - const diffHours = Math.floor(diffMinutes / 60); - if (diffHours < 24) return `${diffHours}h ago`; - - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) return `${diffDays}d ago`; - - return date.toLocaleDateString(); - } else { - // Developer mode - more precise - return date.toLocaleString(); - } -} - -/** - * Get health status text based on health score - */ -export function getHealthStatusText(healthScore: number): string { - if (healthScore >= 90) return 'Excellent'; - if (healthScore >= 75) return 'Good'; - if (healthScore >= 50) return 'Fair'; - if (healthScore >= 25) return 'Poor'; - return 'Critical'; -} - -/** - * Get health status color based on health score - */ -export function getHealthStatusColor(healthScore: number): string { - if (healthScore >= 90) return '#10b981'; // green - if (healthScore >= 75) return '#84cc16'; // lime - if (healthScore >= 50) return '#f59e0b'; // amber - if (healthScore >= 25) return '#f97316'; // orange - return '#ef4444'; // red -} - -/** - * Format error message for display based on mode - */ -export function formatErrorMessage( - error: string | undefined, - mode: AppMode = 'user' -): string { - if (!error) return ''; - - if (mode === 'user') { - // Simplify error messages for users - if (error.includes('connection refused')) return 'Connection failed'; - if (error.includes('timeout')) return 'Request timed out'; - if (error.includes('not found')) return 'Service not found'; - if (error.includes('permission denied')) return 'Access denied'; - if (error.includes('invalid')) return 'Invalid request'; - - // Return first sentence for other errors - const firstSentence = error.split('.')[0]; - return firstSentence.length > 100 - ? firstSentence.substring(0, 100) + '...' - : firstSentence; - } - - // Developer mode - return full error - return error; -} - -/** - * Calculate performance metrics from server metrics - */ -export function calculatePerformanceMetrics(metrics: MCPServerMetrics) { - const successRate = metrics.total_requests > 0 - ? metrics.successful_requests / metrics.total_requests - : 0; - - const errorRate = metrics.total_requests > 0 - ? (metrics.failed_requests / metrics.total_requests) * 100 - : 0; - - return { - successRate, - errorRate, - avgResponseTime: metrics.avg_response_time_ms, - peakResponseTime: metrics.peak_response_time_ms, - requestsPerMinute: metrics.requests_per_minute, - uptime: metrics.uptime_seconds - }; -} - -/** - * Determine if a server needs attention based on metrics - */ -export function serverNeedsAttention(server: MCPServerHealthForUI): boolean { - if (server.status === 'error') return true; - if (server.error_message) return true; - if (server.success_rate !== undefined && server.success_rate < 0.9) return true; - if (server.avg_response_time_ms !== undefined && server.avg_response_time_ms > 5000) return true; - - return false; -} - -/** - * Sort servers by priority (problematic servers first) - */ -export function sortServersByPriority(servers: MCPServerHealthForUI[]): MCPServerHealthForUI[] { - return [...servers].sort((a, b) => { - // Error status first - if (a.status === 'error' && b.status !== 'error') return -1; - if (b.status === 'error' && a.status !== 'error') return 1; - - // Then by attention needed - const aNeeds = serverNeedsAttention(a); - const bNeeds = serverNeedsAttention(b); - if (aNeeds && !bNeeds) return -1; - if (bNeeds && !aNeeds) return 1; - - // Then by status priority - const statusPriority: Record = { - error: 0, - starting: 1, - stopped: 2, - running: 3, - unknown: 4 - }; - const aPriority = statusPriority[a.status] ?? 4; - const bPriority = statusPriority[b.status] ?? 4; - if (aPriority !== bPriority) return aPriority - bPriority; - - // Finally by name - return a.alias.localeCompare(b.alias); - }); -} - -/** - * Filter health events by type - */ -export function filterHealthEventsByType( - events: MCPHealthEvent[], - eventType: string -): MCPHealthEvent[] { - return events.filter(event => event.type === eventType); -} - -/** - * Get recent health events (last N events) - */ -export function getRecentHealthEvents( - events: MCPHealthEvent[], - count: number = 10 -): MCPHealthEvent[] { - return events - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, count); -} - -/** - * Validate MCP tool parameters against schema - */ -export function validateToolParameters( - parameters: Record, - schema: any -): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!schema || !schema.properties) { - return { valid: true, errors: [] }; - } - - // Check required fields - if (schema.required) { - for (const field of schema.required) { - if (!(field in parameters) || parameters[field] === undefined || parameters[field] === '') { - errors.push(`Required field '${field}' is missing`); - } - } - } - - // Basic type checking - for (const [key, value] of Object.entries(parameters)) { - const fieldSchema = schema.properties[key]; - if (!fieldSchema) continue; - - if (fieldSchema.type === 'number' && typeof value !== 'number') { - if (isNaN(Number(value))) { - errors.push(`Field '${key}' must be a number`); - } - } - - if (fieldSchema.type === 'boolean' && typeof value !== 'boolean') { - if (value !== 'true' && value !== 'false') { - errors.push(`Field '${key}' must be a boolean`); - } - } - } - - return { valid: errors.length === 0, errors }; -} diff --git a/examples/benchmarks/100k-scale/python-bench/benchmark.py b/examples/benchmarks/100k-scale/python-bench/benchmark.py index 647ac6407..483e6fcdb 100644 --- a/examples/benchmarks/100k-scale/python-bench/benchmark.py +++ b/examples/benchmarks/100k-scale/python-bench/benchmark.py @@ -79,7 +79,7 @@ def benchmark_agent_init(iterations: int, warmup: int, verbose: bool) -> list[fl node_id=f"init-bench-{i}", agentfield_server="http://localhost:8080", auto_register=False, - enable_mcp=False, # MCP disabled by default + ) elapsed_ms = (time.perf_counter() - start) * 1000 @@ -111,7 +111,7 @@ def benchmark_handler_registration(num_handlers: int, iterations: int, warmup: i node_id=f"handler-bench-{i}", agentfield_server="http://localhost:8080", auto_register=False, - enable_mcp=False, # MCP disabled by default + ) # Measure ONLY handler registration @@ -156,7 +156,7 @@ def benchmark_agent_memory(iterations: int, warmup: int, verbose: bool) -> list[ node_id=f"agent-mem-{i}", agentfield_server="http://localhost:8080", auto_register=False, - enable_mcp=False, # MCP disabled by default + ) gc.collect() @@ -193,7 +193,7 @@ def benchmark_handler_memory(num_handlers: int, iterations: int, warmup: int, ve node_id=f"handler-mem-{i}", agentfield_server="http://localhost:8080", auto_register=False, - enable_mcp=False, # MCP disabled by default + ) gc.collect() @@ -244,7 +244,7 @@ def benchmark_cold_start(iterations: int, warmup: int, verbose: bool) -> list[fl node_id=f"cold-{i}", agentfield_server="http://localhost:8080", auto_register=False, - enable_mcp=False, # MCP disabled by default + ) @agent.reasoner("ping") diff --git a/sdk/python/.gitignore b/sdk/python/.gitignore new file mode 100644 index 000000000..a96d3e8c4 --- /dev/null +++ b/sdk/python/.gitignore @@ -0,0 +1 @@ +.hypothesis/ diff --git a/sdk/python/.hypothesis/constants/04e16d9065458ebe b/sdk/python/.hypothesis/constants/04e16d9065458ebe deleted file mode 100644 index b2209c8e2..000000000 --- a/sdk/python/.hypothesis/constants/04e16d9065458ebe +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/providers/_base.py -# hypothesis_version: 6.151.9 - -['RawResult'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/0dd4899b6bf290f2 b/sdk/python/.hypothesis/constants/0dd4899b6bf290f2 deleted file mode 100644 index 061e1c856..000000000 --- a/sdk/python/.hypothesis/constants/0dd4899b6bf290f2 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/pydantic_utils.py -# hypothesis_version: 6.151.9 - -['execution_context', 'self'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/107f616f1a367d0c b/sdk/python/.hypothesis/constants/107f616f1a367d0c deleted file mode 100644 index 5961a3095..000000000 --- a/sdk/python/.hypothesis/constants/107f616f1a367d0c +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/_runner.py -# hypothesis_version: 6.151.9 - -[0.0, 0.25, 0.5, 1.0, 2.0, 5.0, 30.0, 200, 1000, '.', '.secaf-out-', '500', '502', '503', '504', 'Max retries exceeded', '_original_prompt', 'backoff_factor', 'codex_bin', 'connection refused', 'connection reset', 'cwd', 'env', 'gemini_bin', 'initial_delay', 'max_budget_usd', 'max_delay', 'max_retries', 'max_turns', 'model', 'opencode_bin', 'opencode_server', 'overloaded', 'permission_mode', 'project_dir', 'provider', 'rate limit', 'rate_limit', 'resume_session_id', 'schema_max_retries', 'service unavailable', 'system_prompt', 'timed out', 'timeout', 'tools'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/1220142270ddf88a b/sdk/python/.hypothesis/constants/1220142270ddf88a deleted file mode 100644 index 4ba3c628d..000000000 --- a/sdk/python/.hypothesis/constants/1220142270ddf88a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/http_connection_manager.py -# hypothesis_version: 6.151.9 - -[0.0, 100, 300, 'Accept', 'Connector is closed', 'Content-Type', 'Session is closed', 'User-Agent', 'application/json', 'method', 'url'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/1877247a23b69ad6 b/sdk/python/.hypothesis/constants/1877247a23b69ad6 deleted file mode 100644 index bc67e4ba4..000000000 --- a/sdk/python/.hypothesis/constants/1877247a23b69ad6 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/status.py -# hypothesis_version: 6.151.9 - -['CANONICAL_STATUSES', 'CANONICAL_STATUS_SET', 'TERMINAL_STATUSES', 'approval_pending', 'awaiting_approval', 'awaiting_human', 'cancel', 'canceled', 'cancelled', 'complete', 'completed', 'done', 'error', 'errored', 'failed', 'failure', 'in_progress', 'is_terminal', 'normalize_status', 'ok', 'pending', 'processing', 'queued', 'running', 'succeeded', 'success', 'successful', 'timed_out', 'timeout', 'unknown', 'wait', 'waiting'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/1f4e15980171a61c b/sdk/python/.hypothesis/constants/1f4e15980171a61c deleted file mode 100644 index 2035ceffc..000000000 --- a/sdk/python/.hypothesis/constants/1f4e15980171a61c +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/multimodal_response.py -# hypothesis_version: 6.151.9 - -[',', '.', 'MIME type of file', 'Suggested filename', 'URL to file', 'URL to image', '_', '__dict__', 'audio', 'audio_data', 'b64_json', 'choices', 'completion_tokens', 'content', 'data', 'data:image', 'dict', 'format', 'image_url', 'images', 'model', 'model_dump', 'output', 'png', 'prompt_tokens', 'revised_prompt', 'text', 'total_tokens', 'url', 'usage', 'utf-8', 'w', 'wav', 'wb'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/23e3f0b5dfd9bc3b b/sdk/python/.hypothesis/constants/23e3f0b5dfd9bc3b deleted file mode 100644 index 6e6e2c8ff..000000000 --- a/sdk/python/.hypothesis/constants/23e3f0b5dfd9bc3b +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_cli.py -# hypothesis_version: 6.151.9 - -['\nExample:', '=', 'Arguments:', 'Available commands', 'Call a function', 'Function name', 'Interactive shell', 'Invalid arguments', 'List all functions', 'No description', '_original_func', 'agent', 'asyncio', 'call', 'command', 'execution_context', 'function', 'help', 'id', 'list', 'optional', 'reasoner', 'required', 'self', 'shell', 'skill', 'store_true', 'type'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/35226a2f9b6cb1eb b/sdk/python/.hypothesis/constants/35226a2f9b6cb1eb deleted file mode 100644 index 1b1626505..000000000 --- a/sdk/python/.hypothesis/constants/35226a2f9b6cb1eb +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/did_manager.py -# hypothesis_version: 6.151.9 - -[200, '/', 'Content-Type', 'Unknown error', 'X-API-Key', 'agent_did', 'agent_node_id', 'agentfield_server_id', 'application/json', 'component_type', 'derivation_path', 'did', 'enabled', 'error', 'function_name', 'identity_package', 'message', 'private_key_jwk', 'public_key_jwk', 'reasoner_count', 'reasoner_dids', 'reasoners', 'skill_count', 'skill_dids', 'skills', 'success'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/3b45a354d2ca8572 b/sdk/python/.hypothesis/constants/3b45a354d2ca8572 deleted file mode 100644 index 214eccfb8..000000000 --- a/sdk/python/.hypothesis/constants/3b45a354d2ca8572 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/_result.py -# hypothesis_version: 6.151.9 - -['api_error', 'crash', 'no_output', 'none', 'schema', 'timeout'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/4131d5d89e2dad0b b/sdk/python/.hypothesis/constants/4131d5d89e2dad0b deleted file mode 100644 index 6323e57c5..000000000 --- a/sdk/python/.hypothesis/constants/4131d5d89e2dad0b +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/providers/__init__.py -# hypothesis_version: 6.151.9 - -['HarnessProvider', 'build_provider'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/49f0f3e596a28c92 b/sdk/python/.hypothesis/constants/49f0f3e596a28c92 deleted file mode 100644 index 5b7539308..000000000 --- a/sdk/python/.hypothesis/constants/49f0f3e596a28c92 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_field_handler.py -# hypothesis_version: 6.151.9 - -[10.0, 200, 300, 'Content-Type', 'GET', 'Registration failed', 'SIGINT', 'SIGTERM', 'X-API-Key', 'application/json', 'failed', 'internal', 'lifecycle_status', 'pending_approval', 'pending_tags', 'railway.internal', 'sent', 'status', 'version'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/51f4bafec1bebfef b/sdk/python/.hypothesis/constants/51f4bafec1bebfef deleted file mode 100644 index 0529f561f..000000000 --- a/sdk/python/.hypothesis/constants/51f4bafec1bebfef +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/dynamic_skills.py -# hypothesis_version: 6.151.9 - -['args', 'default', 'description', 'dict', 'error', 'id', 'inputSchema', 'input_data', 'input_schema', 'mcp', 'name', 'properties', 'request', 'required', 'result', 'return', 'server', 'server_alias', 'status', 'string', 'success', 'tags', 'tool', 'tool_name', 'type'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/52ea9e5dc03ed370 b/sdk/python/.hypothesis/constants/52ea9e5dc03ed370 deleted file mode 100644 index 4497211e6..000000000 --- a/sdk/python/.hypothesis/constants/52ea9e5dc03ed370 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/providers/_factory.py -# hypothesis_version: 6.151.9 - -['HarnessConfig', 'HarnessProvider', 'claude-code', 'codex', 'codex_bin', 'gemini', 'gemini_bin', 'opencode', 'opencode_bin', 'opencode_server'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/5afb1609b2c49025 b/sdk/python/.hypothesis/constants/5afb1609b2c49025 deleted file mode 100644 index 39d17f2e1..000000000 --- a/sdk/python/.hypothesis/constants/5afb1609b2c49025 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/result_cache.py -# hypothesis_version: 6.151.9 - -[0.0, 100, 'Cache cleared', 'ResultCache stopped', 'average_access_count', 'average_age', 'enabled', 'evictions', 'expirations', 'expired_entries', 'hit_rate', 'hits', 'max_size', 'misses', 'size', 'uptime'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/6111555b036e253a b/sdk/python/.hypothesis/constants/6111555b036e253a deleted file mode 100644 index 031a81bd9..000000000 --- a/sdk/python/.hypothesis/constants/6111555b036e253a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/__init__.py -# hypothesis_version: 6.151.9 - -['0.1.64-rc.1', 'AIConfig', 'Agent', 'AgentFieldError', 'AgentRouter', 'ApprovalResult', 'Audio', 'AudioOutput', 'DIDAuthenticator', 'DiscoveryResponse', 'DiscoveryResult', 'FalProvider', 'File', 'FileOutput', 'HEADER_CALLER_DID', 'HEADER_DID_SIGNATURE', 'HEADER_DID_TIMESTAMP', 'HarnessConfig', 'HarnessResult', 'Image', 'ImageOutput', 'LiteLLMProvider', 'MediaProvider', 'MemoryAccessError', 'MemoryConfig', 'MultimodalContent', 'MultimodalResponse', 'OpenRouterProvider', 'ReasonerDefinition', 'RegistrationError', 'SkillDefinition', 'Text', 'ToolCallConfig', 'ToolCallRecord', 'ToolCallResponse', 'ToolCallTrace', 'ValidationError', 'audio_from_file', 'audio_from_url', 'file_from_path', 'file_from_url', 'get_provider', 'image_from_file', 'image_from_url', 'register_provider', 'sign_request', 'text'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/62e92b12e175cdfc b/sdk/python/.hypothesis/constants/62e92b12e175cdfc deleted file mode 100644 index 40fc2e9eb..000000000 --- a/sdk/python/.hypothesis/constants/62e92b12e175cdfc +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/mcp_stdio_bridge.py -# hypothesis_version: 6.151.9 - -[0.1, 1.0, 5.0, 30.0, -32603, 400, 500, 503, 1024, '.', '/health', '/mcp/tools/call', '/mcp/tools/list', '/mcp/v1', '1.0.0', '2.0', '2024-11-05', 'Bridge shutting down', 'MCP Stdio Bridge', 'Request expired', 'Stdio reader stopped', 'arguments', 'bridge', 'capabilities', 'clientInfo', 'code', 'environment', 'error', 'healthy', 'id', 'ignore', 'info', 'initialize', 'jsonrpc', 'listChanged', 'localhost', 'message', 'method', 'name', 'params', 'process', 'protocolVersion', 'result', 'roots', 'run', 'running', 'status', 'tools', 'tools/call', 'tools/list', 'utf-8', 'version', 'working_dir'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/6825d229482d5a6b b/sdk/python/.hypothesis/constants/6825d229482d5a6b deleted file mode 100644 index f828706d5..000000000 --- a/sdk/python/.hypothesis/constants/6825d229482d5a6b +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_utils.py -# hypothesis_version: 6.151.9 - -[b'%PDF', b'GIF8', b'ID3', b'RIFF', b'WAVE', b'ftyp', b'\x89PNG', b'\xff\xd8\xff', b'\xff\xfb', '.aac', '.avi', '.bmp', '.doc', '.docx', '.flac', '.flv', '.gif', '.jpeg', '.jpg', '.m4a', '.md', '.mov', '.mp3', '.mp4', '.ogg', '.pdf', '.png', '.rtf', '.svg', '.tiff', '.txt', '.wav', '.webm', '.webp', '.wmv', '[^a-zA-Z0-9_]', '_', '_+', '__dict__', 'application/msword', 'application/pdf', 'application/rtf', 'array', 'assistant', 'audio', 'audio/aac', 'audio/flac', 'audio/mp4', 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio_base64', 'audio_bytes', 'audio_file', 'binary_data', 'boolean', 'conversation_list', 'data', 'data:audio', 'data:image', 'default', 'dict', 'document_bytes', 'document_file', 'file', 'http://', 'https://', 'image', 'image/bmp', 'image/gif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/tiff', 'image/webp', 'image_base64', 'image_bytes', 'image_file', 'image_url', 'input_schema', 'integer', 'list', 'localhost', 'message_dict', 'model_dump', 'multimodal_list', 'null', 'number', 'object', 'properties', 'required', 'role', 'string', 'structured_input', 'system', 'text', 'text/markdown', 'text/plain', 'type', 'unknown', 'url', 'user', 'video_file'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/6c625f6ab1fdaf30 b/sdk/python/.hypothesis/constants/6c625f6ab1fdaf30 deleted file mode 100644 index 1b5ec1ef5..000000000 --- a/sdk/python/.hypothesis/constants/6c625f6ab1fdaf30 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/__init__.py -# hypothesis_version: 6.151.9 - -['HarnessProvider', 'HarnessResult', 'HarnessRunner', 'Metrics', 'RawResult', 'build_provider'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/74b782fcb5db48be b/sdk/python/.hypothesis/constants/74b782fcb5db48be deleted file mode 100644 index 07b2bb96e..000000000 --- a/sdk/python/.hypothesis/constants/74b782fcb5db48be +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/utils.py -# hypothesis_version: 6.151.9 - -[8001, 8999, 'localhost'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/78d4f0b0a8c6816a b/sdk/python/.hypothesis/constants/78d4f0b0a8c6816a deleted file mode 100644 index a84d5af0b..000000000 --- a/sdk/python/.hypothesis/constants/78d4f0b0a8c6816a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/tool_calling.py -# hypothesis_version: 6.151.9 - -[0.0, 1000, ':', ':skill:', 'Agent', '__', 'auto', 'content', 'description', 'discover', 'eager', 'error', 'function', 'lazy', 'messages', 'name', 'object', 'parameters', 'properties', 'role', 'tool', 'tool_call_id', 'tool_calls', 'tool_choice', 'tools', 'type'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/7a5daa86b9318387 b/sdk/python/.hypothesis/constants/7a5daa86b9318387 deleted file mode 100644 index d77522573..000000000 --- a/sdk/python/.hypothesis/constants/7a5daa86b9318387 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/mcp_manager.py -# hypothesis_version: 6.151.9 - -[1000, 8100, 'alias', 'config', 'config.json', 'environment', 'failed', 'health_check', 'http', 'initialized', 'localhost', 'mcp', 'packages', 'port', 'r', 'run', 'running', 'starting', 'status', 'stdio', 'stopped', 'transport', 'working_dir', '{{port}}'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/7bde5d55a3ec0926 b/sdk/python/.hypothesis/constants/7bde5d55a3ec0926 deleted file mode 100644 index 415f7ed7c..000000000 --- a/sdk/python/.hypothesis/constants/7bde5d55a3ec0926 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/media_providers.py -# hypothesis_version: 6.151.9 - -[1.0, '1024x1024', 'FAL_KEY', 'alloy', 'audio', 'audio_url', 'content', 'dall-e-3', 'duration', 'fal', 'fal-ai/flux/dev', 'fal-ai/whisper', 'gen_text', 'generated_video.mp4', 'guidance_scale', 'height', 'http', 'image', 'image_size', 'image_url', 'images', 'landscape_16_9', 'landscape_4_3', 'language', 'litellm', 'num_images', 'num_inference_steps', 'openrouter', 'openrouter/', 'portrait_16_9', 'portrait_4_3', 'prompt', 'ref_audio_url', 'seed', 'square', 'square_hd', 'standard', 'text', 'transcription', 'tts-1', 'url', 'utf-8', 'video', 'video/mp4', 'video_url', 'wav', 'width', 'x'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/7f1039e8b6741e99 b/sdk/python/.hypothesis/constants/7f1039e8b6741e99 deleted file mode 100644 index c7abb8373..000000000 --- a/sdk/python/.hypothesis/constants/7f1039e8b6741e99 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/async_execution_manager.py -# hypothesis_version: 6.151.9 - -[0.0, 0.1, 0.5, 1.0, 1.5, 60.0, 100, 200, 400, 1024, '+00:00', ',', '/', ':', 'Accept', 'Cleanup loop stopped', 'Content-Type', 'Execution failed', 'GET', 'Manager shutdown', 'Metrics loop stopped', 'Polling loop stopped', 'Z', '_capacity_released', 'active_executions', 'application/json', 'average_queue_time', 'batch_polls', 'cancelled', 'cancelled_executions', 'circuit_breaker', 'cleanup_operations', 'completed_executions', 'connection_manager', 'created_at', 'data:', 'error', 'error_details', 'executionId', 'execution_completed', 'execution_failed', 'execution_id', 'execution_started', 'failed', 'failed_executions', 'failed_polls', 'failures', 'headers', 'ignore', 'input', 'is_open', 'last_failure', 'memory_usage_mb', 'message', 'method', 'pending', 'polling_metrics', 'queued', 'result', 'result_cache', 'run_id', 'running', 'status', 'succeeded', 'success_rate', 'successful_polls', 'text/event-stream', 'timeout', 'timeout_executions', 'total_executions', 'total_polls', 'type', 'uptime', 'url', 'utf-8', 'value', 'webhook', 'webhook_error', 'webhook_registered', 'workflow_id'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/8275e033874bebf7 b/sdk/python/.hypothesis/constants/8275e033874bebf7 deleted file mode 100644 index 7332629f7..000000000 --- a/sdk/python/.hypothesis/constants/8275e033874bebf7 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/types.py -# hypothesis_version: 6.151.9 - -[0.0, 0.2, 0.25, 0.5, 1.0, 2.0, 30.0, 1000, 2048, 4096, 8192, 16385, 32768, 128000, 131072, 200000, 1048576, 2097152, '\n…TRIMMED…\n', '/', 'AIConfig', 'AgentCapability', 'Bash', 'CompactCapability', 'Cost cap in USD.', 'Custom API base URL', 'DiscoveryPagination', 'DiscoveryResponse', 'Edit', 'Glob', 'Grep', 'MemoryChangeEvent', 'MemoryValue', 'Read', 'ReasonerCapability', 'SkillCapability', 'Write', 'X-Actor-ID', 'X-Run-ID', 'X-Session-ID', 'action', 'agent_id', 'api_base', 'api_key', 'api_version', 'auto', 'base_url', 'capabilities', 'claude-3-opus', 'claude-3.5-sonnet', 'codex', 'context_length', 'dall-e-3', 'data', 'degraded', 'deployment_type', 'description', 'discovered_at', 'examples', 'gemini', 'gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.5-flash', 'gemini-2.5-pro', 'gpt-3.5-turbo', 'gpt-4', 'gpt-4o', 'gpt-4o-mini', 'has_more', 'headers', 'health_status', 'high', 'id', 'input_schema', 'invocation_target', 'json', 'key', 'last_heartbeat', 'limit', 'low', 'max_output_tokens', 'max_tokens', 'mcp_servers', 'memory_config', 'metadata', 'model', 'num_retries', 'offline', 'offset', 'openai', 'opencode', 'organization', 'output_schema', 'pagination', 'previous_data', 'protected_namespaces', 'ready', 'reasoners', 'response_format', 'scope', 'scope_id', 'secret', 'skills', 'sonnet', 'starting', 'status', 'stream', 'tags', 'target', 'temperature', 'text', 'timeout', 'timestamp', 'top_p', 'total_agents', 'total_reasoners', 'total_skills', 'tts-1', 'type', 'url', 'version', 'wav'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/861812dce0a6ca59 b/sdk/python/.hypothesis/constants/861812dce0a6ca59 deleted file mode 100644 index 69ca4db6b..000000000 --- a/sdk/python/.hypothesis/constants/861812dce0a6ca59 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_server.py -# hypothesis_version: 6.151.9 - -[0.0, 0.2, 1.0, 10.0, 30.0, 200, 400, 401, 404, 413, 1000, 1024, 2048, 3600, 8001, 100000, '/agentfield/v1/logs', '/health', '/health/mcp', '/info', '/mcp/status', '/mcp/{alias}/restart', '/mcp/{alias}/start', '/mcp/{alias}/stop', '/reasoners', '/shutdown', '/skills', '/status', '/webhooks/approval', '0', '0.0.0.0', '1', '50000', 'AGENTFIELD_AUTO_PORT', 'AGENT_CALLBACK_URL', 'Authorization', 'Available endpoints:', 'Cache-Control', 'HEAD', 'Heartbeat stopped', 'MCP servers stopped', 'PORT', 'SIGINT', 'SIGTERM', 'Single-process mode', 'UVICORN_LOG_LEVEL', 'UVICORN_WORKERS', '_heartbeat_task', '_shutdown_requested', '_start_time', 'access_log', 'agentfield_handler', 'alias', 'application/json', 'application/x-ndjson', 'approval_request_id', 'authorization', 'backlog', 'base_url', 'client', 'content-type', 'cpu_percent', 'critical', 'debug', 'decision', 'degraded', 'description', 'error', 'execution_id', 'failed', 'feedback', 'follow', 'graceful', 'healthy', 'host', 'info', 'inputSchema', 'input_schema', 'invalid JSON', 'last_activity', 'last_health_check', 'limit_concurrency', 'limit_max_requests', 'log_level', 'logs_disabled', 'loop', 'mcp_handler', 'mcp_servers', 'memory_mb', 'message', 'name', 'no-store', 'node_id', 'nosniff', 'orjson', 'overall_health', 'pid', 'port', 'process', 'process_id', 'psutil', 'raw', 'reasoners', 'received', 'registered_at', 'reload', 'resolved', 'resources', 'response', 'running', 'running_servers', 'sent', 'servers', 'shutdown', 'shutting_down', 'since_seq', 'skills', 'ssl_certfile', 'ssl_keyfile', 'started_at', 'startup', 'status', 'stopping', 'success', 'summary', 'tail_lines', 'tail_too_large', 'threads', 'timeout_seconds', 'timestamp', 'tool_count', 'tools', 'total', 'total_servers', 'total_tools', 'trace', 'true', 'unauthorized', 'unknown', 'uptime', 'uptime_seconds', 'uvloop', 'version', 'warning', 'workers', 'yes'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/8e8075ca34a388e6 b/sdk/python/.hypothesis/constants/8e8075ca34a388e6 deleted file mode 100644 index c7bd92637..000000000 --- a/sdk/python/.hypothesis/constants/8e8075ca34a388e6 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/execution_context.py -# hypothesis_version: 6.151.9 - -[0.0, 1000, 'ExecutionContext', 'X-Actor-ID', 'X-Agent-Node-DID', 'X-Agent-Node-ID', 'X-Caller-DID', 'X-Execution-ID', 'X-Parent-Workflow-ID', 'X-Root-Workflow-ID', 'X-Run-ID', 'X-Session-ID', 'X-Target-DID', 'X-Workflow-ID', 'X-Workflow-Run-ID', 'agent_instance', 'execution_context', 'node_id', 'root', 'unknown'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/8f73488e8835b6b6 b/sdk/python/.hypothesis/constants/8f73488e8835b6b6 deleted file mode 100644 index d0362b0f9..000000000 --- a/sdk/python/.hypothesis/constants/8f73488e8835b6b6 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/memory.py -# hypothesis_version: 6.151.9 - -[10.0, 15.0, 404, 'GET', 'POST', 'X-Actor-ID', 'X-Agent-Node-ID', 'X-Session-ID', 'X-Workflow-ID', '_async_request', '_memory_event_scope', 'actor', 'data', 'embedding', 'filters', 'global', 'key', 'metadata', 'query_embedding', 'scope', 'session', 'tolist', 'top_k', 'workflow'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/9974afd178e4f472 b/sdk/python/.hypothesis/constants/9974afd178e4f472 deleted file mode 100644 index b0fa8babe..000000000 --- a/sdk/python/.hypothesis/constants/9974afd178e4f472 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/vc_generator.py -# hypothesis_version: 6.151.9 - -[200, 1000, '+00:00', '/', 'Content-Type', 'Unknown error', 'X-API-Key', 'Z', 'agent_node_did', 'application/json', 'ascii', 'caller_did', 'completed_steps', 'component_vcs', 'created_at', 'duration_ms', 'end_time', 'error', 'error_message', 'execution_context', 'execution_id', 'execution_vc_ids', 'input_data', 'input_hash', 'issuer_did', 'output_data', 'output_hash', 'replace', 'session_id', 'signature', 'start_time', 'status', 'success', 'target_did', 'timestamp', 'total_steps', 'utf-8', 'vc_document', 'vc_id', 'workflow_id', 'workflow_vc_id'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/9aedfa0b4e4d0d3c b/sdk/python/.hypothesis/constants/9aedfa0b4e4d0d3c deleted file mode 100644 index 8960685c0..000000000 --- a/sdk/python/.hypothesis/constants/9aedfa0b4e4d0d3c +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/multimodal.py -# hypothesis_version: 6.151.9 - -['.', '.bmp', '.gif', '.jpeg', '.jpg', '.png', '.webp', 'Audio', 'File', 'Image', 'The text content.', 'data', 'detail', 'file', 'flac', 'format', 'high', 'image/bmp', 'image/gif', 'image/jpeg', 'image/png', 'image/webp', 'image_url', 'input_audio', 'mime_type', 'mp3', 'ogg', 'rb', 'text', 'url', 'wav'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/9b6c5677b208e3f0 b/sdk/python/.hypothesis/constants/9b6c5677b208e3f0 deleted file mode 100644 index 0daad91a3..000000000 --- a/sdk/python/.hypothesis/constants/9b6c5677b208e3f0 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/logger.py -# hypothesis_version: 6.151.9 - -['%(message)s', '...', '200', 'AGENTFIELD_LOG_FIRE', 'AGENTFIELD_LOG_LEVEL', 'DEBUG', 'ERROR', 'INFO', 'SILENT', 'WARN', 'WARNING', 'agentfield', 'false', 'true'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/9d80d15cf78ae30f b/sdk/python/.hypothesis/constants/9d80d15cf78ae30f deleted file mode 100644 index 05467ac0a..000000000 --- a/sdk/python/.hypothesis/constants/9d80d15cf78ae30f +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_ai.py -# hypothesis_version: 6.151.9 - -[b'GIF8', b'ID3', b'RIFF', b'WAVE', b'\x89PNG', b'\xff\xd8\xff', b'\xff\xfb', 1.0, 100, 1000, 7096, 10192, ',', '.', '/', '1024x1024', ';', 'MultimodalResponse', '\\{.*\\}', '__iter__', 'acompletion', 'alloy', 'api_key', 'aspeech', 'audio', 'audio_base64', 'audio_bytes', 'audio_file', 'auto', 'content', 'conversation_list', 'dall-e-3', 'data', 'data:audio/', 'detail', 'dict', 'discover', 'document_file', 'fal-ai/', 'fal-ai/whisper', 'fal/', 'fallback_models', 'final_fallback_model', 'flac', 'format', 'gpt-4o-audio-preview', 'gpt-4o-mini-tts', 'high', 'image', 'image/gif', 'image/jpeg', 'image/png', 'image_base64', 'image_bytes', 'image_file', 'image_url', 'input', 'input_audio', 'json', 'json_schema', 'max_input_tokens', 'message_dict', 'messages', 'modalities', 'model', 'mp3', 'multimodal_list', 'name', 'ogg', 'openai_direct', 'openrouter/', 'rb', 'read', 'response_format', 'role', 'schema', 'standard', 'strict', 'structured_input', 'system', 'text', 'token_counter', 'trim_messages', 'tts-1', 'tts-1-hd', 'type', 'url', 'user', 'utf-8', 'utils', 'voice', 'wav'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/aaed4a84fa6ed33c b/sdk/python/.hypothesis/constants/aaed4a84fa6ed33c deleted file mode 100644 index 51a0f1dc5..000000000 --- a/sdk/python/.hypothesis/constants/aaed4a84fa6ed33c +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_mcp.py -# hypothesis_version: 6.151.9 - -['N/A', '_cleanup_tasks', 'allow', 'args', 'description', 'error', 'id', 'input_schema', 'mcp', 'name', 'packages', 'pid', 'port', 'process', 'result', 'running', 'server', 'status', 'success', 'tags', 'tool', 'unknown'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/ad7a75cba2b20b1d b/sdk/python/.hypothesis/constants/ad7a75cba2b20b1d deleted file mode 100644 index 0d0d98388..000000000 --- a/sdk/python/.hypothesis/constants/ad7a75cba2b20b1d +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_workflow.py -# hypothesis_version: 6.151.9 - -[1000, '/', 'AgentWorkflow', 'POST', '__name__', '_async_request', 'agent', 'agent_node_did', 'agent_node_id', 'agentfield_server', 'caller_did', 'client', 'dev_mode', 'duration_ms', 'error', 'execution_context', 'execution_id', 'failed', 'input_data', 'json', 'node_id', 'parent_execution_id', 'parent_workflow_id', 'reasoner', 'reasoner_id', 'reasoner_name', 'result', 'run_id', 'running', 'self', 'session_id', 'status', 'succeeded', 'target_did', 'type', 'workflow_id'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/afd94d9a732c6528 b/sdk/python/.hypothesis/constants/afd94d9a732c6528 deleted file mode 100644 index 66d5e0060..000000000 --- a/sdk/python/.hypothesis/constants/afd94d9a732c6528 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/rate_limiter.py -# hypothesis_version: 6.151.9 - -[0.1, 0.25, 0.5, 30.0, 429, 503, 'RateLimitError', 'Retry-After', '__class__', 'headers', 'quota exceeded', 'rate limit', 'rate limited', 'rate-limit', 'rate_limit', 'requests per', 'response', 'retry_after', 'rpm exceeded', 'status_code', 'throttled', 'throttling', 'too many requests', 'tpm exceeded', 'usage limit'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/b2bb28f5ebb70fa5 b/sdk/python/.hypothesis/constants/b2bb28f5ebb70fa5 deleted file mode 100644 index b74da62cd..000000000 --- a/sdk/python/.hypothesis/constants/b2bb28f5ebb70fa5 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/router.py -# hypothesis_version: 6.151.9 - -['/', 'Agent', '_agent', 'func', 'kwargs', 'path', 'reasoners/', 'registered', 'skills/', 'tags', 'wrapper'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/b99e179b386676e4 b/sdk/python/.hypothesis/constants/b99e179b386676e4 deleted file mode 100644 index e74fa29cc..000000000 --- a/sdk/python/.hypothesis/constants/b99e179b386676e4 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent.py -# hypothesis_version: 6.151.9 - -[1.0, 5.0, 30.0, 600.0, 3600.0, 200, 202, 256, 300, 400, 401, 403, 404, 422, 500, 1000, 8000, '.', '/', '/.dockerenv', '/api/ui/v1', '/api/v1', '/discover', '/proc/1/cgroup', '/reasoners/', '/skills/', '0.0.0.0', '1', '1.0.0', '1024x1024', '127.0.0.1', '8.8.8.8', ':', '://', '?', 'AGENTFIELD_SERVER', 'AGENT_CALLBACK_URL', 'Agent', 'ApprovalResult', 'CONTAINER', 'Content-Type', 'DEBUG', 'DOCKER_CONTAINER', 'DiscoveryResult', 'Google', 'HarnessConfig', 'HarnessResult', 'HarnessRunner', 'INFO', 'Invalid JSON body', 'KUBERNETES_', 'Metadata', 'Metadata-Flavor', 'MultimodalResponse', 'POST', 'RAILWAY_ENVIRONMENT', 'RAILWAY_SERVICE_NAME', 'X-API-Key', 'X-Caller-DID', 'X-DID-Nonce', 'X-DID-Signature', 'X-DID-Timestamp', 'X-Execution-ID', 'Z', '[', '[]', '[^0-9a-zA-Z]+', ']', '_', '_+', '__args__', '__dict__', '__module__', '__name__', '__origin__', '_call_semaphore', '_current_agent', '_original_func', '_reasoner_tags', 'action', 'actor_id', 'additionalProperties', 'agent', 'agent_default', 'agent_did', 'agent_node_did', 'agent_node_id', 'agent_tags', 'alloy', 'anyOf', 'application/json', 'approval', 'array', 'author', 'auto', 'binary', 'body', 'boolean', 'call', 'callback_candidates', 'callback_discovery', 'caller_did', 'candidates', 'client', 'completed_at', 'container', 'containerd', 'deployment_type', 'description', 'detail', 'did_auth_required', 'did_not_registered', 'did_revoked', 'discover', 'docker', 'duration_ms', 'effective_reasoners', 'effective_skills', 'error', 'error_details', 'execute', 'executionContext', 'execution_context', 'execution_id', 'expired', 'failed', 'format', 'func', 'help', 'http', 'id', 'input', 'input_data', 'input_schema', 'integer', 'items', 'json', 'kubepods', 'kwargs', 'list', 'mcp_handler', 'memory_config', 'message', 'mode', 'model', 'model_json_schema', 'model_validate', 'name', 'node_id', 'null', 'number', 'object', 'output_schema', 'parent_execution_id', 'parent_workflow_id', 'path', 'pending', 'policy_denied', 'preferred', 'prefix', 'processing', 'properties', 'proposed_tags', 'python-sdk:auto', 'quality', 'r', 'rawPath', 'reasoner', 'reasoner_id', 'reasoner_overrides', 'reasoners', 'registered', 'required', 'resolved', 'resolved_base_url', 'result', 'return', 'return_type', 'return_type_hint', 'root_workflow_id', 'run_id', 'running', 'selected', 'self', 'serverless', 'session', 'session_id', 'shell', 'signature_invalid', 'signature_required', 'size', 'skill', 'skill_overrides', 'skills', 'slots', 'standard', 'status', 'statusCode', 'string', 'style', 'submitted_at', 'succeeded', 'success', 'tags', 'target', 'target_did', 'text', 'timestamp', 'true', 'type', 'url', 'value', 'vc_enabled', 'version', 'voice', 'waiting', 'wav', 'workflow_handler', 'workflow_id', 'yes'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/c3e3a9e49ca47260 b/sdk/python/.hypothesis/constants/c3e3a9e49ca47260 deleted file mode 100644 index 297dcd711..000000000 --- a/sdk/python/.hypothesis/constants/c3e3a9e49ca47260 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/async_config.py -# hypothesis_version: 6.151.9 - -[0.03, 0.08, 0.1, 0.4, 1.0, 1.5, 2.0, 3.0, 4.0, 10.0, 20.0, 30.0, 60.0, 120.0, 7200.0, 21600.0, 100, 512, 1000, 4096, 5000, 'AsyncConfig', 'batch_size', 'connection_pool_size', 'enable_batch_polling', 'enable_event_stream', 'event_stream_path', 'fallback_to_sync', 'fast_poll_interval', 'max_active_polls', 'max_poll_interval', 'medium_poll_interval', 'polling_timeout', 'slow_poll_interval', 'true'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/c56ffaf697b00ec3 b/sdk/python/.hypothesis/constants/c56ffaf697b00ec3 deleted file mode 100644 index 408b74051..000000000 --- a/sdk/python/.hypothesis/constants/c56ffaf697b00ec3 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/did_auth.py -# hypothesis_version: 6.151.9 - -['=', 'Ed25519', 'OKP', 'X-Caller-DID', 'X-DID-Nonce', 'X-DID-Signature', 'X-DID-Timestamp', 'ascii', 'configured', 'crv', 'd', 'did', 'kty', 'utf-8'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/c6bfde7b19369faf b/sdk/python/.hypothesis/constants/c6bfde7b19369faf deleted file mode 100644 index e632606c1..000000000 --- a/sdk/python/.hypothesis/constants/c6bfde7b19369faf +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/client.py -# hypothesis_version: 6.151.9 - -[0.05, 0.25, 0.3, 0.8, 1.2, 2.0, 5.0, 10.0, 30.0, 60.0, 200, 201, 400, 401, 403, 4096, '%Y%m%d_%H%M%S', ',', '.', '1.0.0', '2s', '5s', ':', 'Accept', 'AgentFieldSDK/1.0', 'Content-Length', 'Content-Type', 'GET', 'Limits', 'POST', 'Timeout', 'User-Agent', 'X-API-Key', 'X-Actor-ID', 'X-Caller-Agent-ID', 'X-Run-ID', 'X-Session-ID', 'Z', 'ab_testing', 'advanced_metrics', 'agent', 'agent_ids', 'agentfield', 'application/json', 'application/xml', 'approval_request_id', 'approval_request_url', 'approved', 'audit_logging', 'authorization', 'base_url', 'callback_discovery', 'callback_url', 'communication_config', 'compact', 'completed_at', 'compliance', 'content', 'cookie', 'cost', 'custom', 'default', 'deployment', 'development', 'duration', 'duration_ms', 'environment', 'error', 'error_details', 'error_message', 'execution_id', 'experimental', 'expires_in_hours', 'features', 'format', 'headers', 'health_status', 'healthy', 'heartbeat_interval', 'http', 'http://', 'https://', 'httpx', 'httpx.AsyncClient', 'id', 'include_descriptions', 'include_examples', 'include_input_schema', 'input', 'is_closed', 'json', 'language', 'last_heartbeat', 'latency_ms', 'lifecycle_status', 'limit', 'limits', 'local', 'manager_started', 'message', 'metadata', 'node_id', 'offset', 'pending', 'performance', 'platform', 'proposed_tags', 'protocols', 'python', 'reasoner', 'reasoners', 'region', 'registered_at', 'replace', 'request_changes', 'request_url', 'requested_at', 'responded_at', 'response', 'result', 'role_based_access', 'run_id', 'sdk_version', 'skill', 'skills', 'started_at', 'status', 'stream', 'succeeded', 'sync', 'tags', 'target', 'target_type', 'team_id', 'throughput_ps', 'timeout', 'timestamp', 'to_headers', 'type', 'unknown', 'utf-8', 'vc_generation', 'version', 'websocket_endpoint', 'x-', 'x-actor-id', 'x-run-id', 'x-session-id', 'xml'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/c99ccc0d6df7640f b/sdk/python/.hypothesis/constants/c99ccc0d6df7640f deleted file mode 100644 index 8a1906c16..000000000 --- a/sdk/python/.hypothesis/constants/c99ccc0d6df7640f +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/memory_events.py -# hypothesis_version: 6.151.9 - -[1.0, 5.0, 10.0, 100, '&', '*', ',', '.', '.*', '0', '?', 'X-API-Key', '__version__', '_memory_event_scope', 'additional_headers', 'closed', 'extra_headers', 'http', 'limit', 'open', 'patterns', 'scope', 'scope_id', 'since', 'ws'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/cd912208d927be9f b/sdk/python/.hypothesis/constants/cd912208d927be9f deleted file mode 100644 index b7799b081..000000000 --- a/sdk/python/.hypothesis/constants/cd912208d927be9f +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/mcp_client.py -# hypothesis_version: 6.151.9 - -[200, '/mcp/tools/list', '2.0', '_is_stdio_bridge', 'arguments', 'direct HTTP', 'error', 'id', 'jsonrpc', 'method', 'name', 'params', 'result', 'stdio bridge', 'tool_name', 'tools', 'tools/call', 'tools/list'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/de9ef6fd42a2f97a b/sdk/python/.hypothesis/constants/de9ef6fd42a2f97a deleted file mode 100644 index 49b4058fe..000000000 --- a/sdk/python/.hypothesis/constants/de9ef6fd42a2f97a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/execution_state.py -# hypothesis_version: 6.151.9 - -[0.0, 0.05, 'actor_id', 'age', 'cancelled', 'created_at', 'error_details', 'error_message', 'execution_duration', 'execution_id', 'failed', 'high', 'is_active', 'is_cancelled', 'is_successful', 'is_terminal', 'low', 'metrics', 'network_errors', 'network_requests', 'normal', 'parent_execution_id', 'pending', 'poll_count', 'priority', 'queue_duration', 'queued', 'result', 'result_size_bytes', 'retry_count', 'running', 'session_id', 'status', 'succeeded', 'target', 'timeout', 'total_duration', 'unknown', 'updated_at', 'urgent', 'waiting', 'workflow_id'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/e86f774664d2e09c b/sdk/python/.hypothesis/constants/e86f774664d2e09c deleted file mode 100644 index c5fc6f584..000000000 --- a/sdk/python/.hypothesis/constants/e86f774664d2e09c +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/harness/_schema.py -# hypothesis_version: 6.151.9 - -[384, 500, 4000, ',\\s*([}\\]])', '[', '\\1', ']', 'model_json_schema', 'model_validate', 'parse_obj', 'r', 'schema', 'utf-8', 'w', '{', '{[', '}'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/ef86eaa3026c6e45 b/sdk/python/.hypothesis/constants/ef86eaa3026c6e45 deleted file mode 100644 index 571d56729..000000000 --- a/sdk/python/.hypothesis/constants/ef86eaa3026c6e45 +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/exceptions.py -# hypothesis_version: 6.151.9 - -['AgentFieldError', 'MemoryAccessError', 'RegistrationError', 'ValidationError'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/f0067a360e8ed05b b/sdk/python/.hypothesis/constants/f0067a360e8ed05b deleted file mode 100644 index fc2f7b5b6..000000000 --- a/sdk/python/.hypothesis/constants/f0067a360e8ed05b +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/agent_registry.py -# hypothesis_version: 6.151.9 - -['Agent', 'current_agent'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/constants/fdc4cfc1634c776a b/sdk/python/.hypothesis/constants/fdc4cfc1634c776a deleted file mode 100644 index 795f75bd0..000000000 --- a/sdk/python/.hypothesis/constants/fdc4cfc1634c776a +++ /dev/null @@ -1,4 +0,0 @@ -# file: /Users/santoshkumarradha/Documents/agentfield/code/platform/agentfield/sdk/python/agentfield/connection_manager.py -# hypothesis_version: 6.151.9 - -[10.0, 30.0, 'connected', 'connecting', 'degraded', 'disconnected', 'httpcore', 'httpx', 'pending_approval', 'pending_tags', 'reconnecting', 'status'] \ No newline at end of file diff --git a/sdk/python/.hypothesis/unicode_data/16.0.0/charmap.json.gz b/sdk/python/.hypothesis/unicode_data/16.0.0/charmap.json.gz deleted file mode 100644 index 354832906..000000000 Binary files a/sdk/python/.hypothesis/unicode_data/16.0.0/charmap.json.gz and /dev/null differ diff --git a/sdk/python/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz b/sdk/python/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz deleted file mode 100644 index 08f2fb78b..000000000 Binary files a/sdk/python/.hypothesis/unicode_data/16.0.0/codec-utf-8.json.gz and /dev/null differ diff --git a/sdk/python/agentfield/README_stdio_bridge.md b/sdk/python/agentfield/README_stdio_bridge.md deleted file mode 100644 index d72e13d2b..000000000 --- a/sdk/python/agentfield/README_stdio_bridge.md +++ /dev/null @@ -1,233 +0,0 @@ -# MCP Stdio-to-HTTP Bridge - -The `mcp_stdio_bridge.py` module provides a bridge that converts stdio-based MCP (Model Context Protocol) servers to HTTP endpoints. This allows the existing HTTP-based MCP client infrastructure to work with stdio-based MCP servers. - -## Overview - -Some MCP servers (like `@modelcontextprotocol/server-sequential-thinking`) use stdio transport instead of HTTP. The current AgentField SDK implementation assumes all servers are HTTP-based, causing failures when trying to communicate with stdio servers. This bridge solves that problem. - -## How It Works - -1. **Process Management**: Starts the stdio MCP server as a subprocess with stdin/stdout pipes -2. **HTTP Server**: Creates FastAPI endpoints that accept HTTP requests -3. **Protocol Translation**: Converts HTTP requests to JSON-RPC 2.0 format for stdio communication -4. **Request Correlation**: Uses unique IDs to match requests with responses -5. **Concurrent Handling**: Queues multiple HTTP requests for the single stdio process - -## Key Features - -- **HTTP Endpoints**: Provides `/health`, `/mcp/tools/list`, `/mcp/tools/call`, and `/mcp/v1` endpoints -- **JSON-RPC 2.0 Protocol**: Proper MCP protocol implementation with handshake -- **Request Correlation**: Handles multiple concurrent requests reliably -- **Error Handling**: Timeout handling, process crash recovery, proper cleanup -- **Development Mode**: Verbose logging for debugging - -## Usage - -### Basic Usage - -```python -import asyncio -from agentfield.mcp_stdio_bridge import StdioMCPBridge - -async def main(): - # Configure your stdio MCP server - server_config = { - "alias": "sequential-thinking", - "run": "npx -y @modelcontextprotocol/server-sequential-thinking", - "working_dir": ".", - "environment": {}, - "description": "Sequential thinking MCP server" - } - - # Create and start the bridge - bridge = StdioMCPBridge( - server_config=server_config, - port=8200, - dev_mode=True - ) - - try: - success = await bridge.start() - if success: - print("Bridge started successfully!") - # Bridge is now running and accepting HTTP requests - await asyncio.sleep(10) # Keep running for 10 seconds - else: - print("Failed to start bridge") - finally: - await bridge.stop() - -asyncio.run(main()) -``` - -### Making HTTP Requests - -Once the bridge is running, you can make HTTP requests: - -```python -import aiohttp - -async def test_bridge(): - async with aiohttp.ClientSession() as session: - # Health check - async with session.get("http://localhost:8200/health") as response: - health = await response.json() - print(f"Health: {health}") - - # List tools - async with session.post("http://localhost:8200/mcp/tools/list") as response: - tools = await response.json() - print(f"Tools: {tools}") - - # Call a tool - tool_request = { - "name": "example_tool", - "arguments": {"param": "value"} - } - async with session.post("http://localhost:8200/mcp/tools/call", json=tool_request) as response: - result = await response.json() - print(f"Result: {result}") -``` - -### Using with Existing MCP Client - -The bridge is designed to work seamlessly with the existing `MCPClient`: - -```python -from agentfield.mcp_client import MCPClient -from agentfield.mcp_stdio_bridge import StdioMCPBridge - -# Start the bridge -bridge = StdioMCPBridge(server_config, port=8200) -await bridge.start() - -# Use existing MCP client -client = MCPClient("sequential-thinking", port=8200, dev_mode=True) -tools = await client.list_tools() -result = await client.call_tool("tool_name", {"arg": "value"}) -``` - -## Configuration - -The `server_config` dictionary should contain: - -- `alias`: Human-readable name for the server -- `run`: Command to start the stdio MCP server -- `working_dir`: Working directory for the process (optional) -- `environment`: Environment variables (optional) -- `description`: Description of the server (optional) - -## HTTP Endpoints - -### GET /health -Returns the health status of the bridge and stdio process. - -**Response:** -```json -{ - "status": "healthy", - "bridge": "running", - "process": "running" -} -``` - -### POST /mcp/tools/list -Lists available tools from the stdio MCP server. - -**Response:** -```json -{ - "tools": [ - { - "name": "tool_name", - "description": "Tool description", - "inputSchema": {...} - } - ] -} -``` - -### POST /mcp/tools/call -Calls a specific tool on the stdio MCP server. - -**Request:** -```json -{ - "name": "tool_name", - "arguments": { - "param1": "value1", - "param2": "value2" - } -} -``` - -**Response:** -```json -{ - "content": [...], - "isError": false -} -``` - -### POST /mcp/v1 -Standard MCP JSON-RPC 2.0 endpoint. - -**Request:** -```json -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {} -} -``` - -**Response:** -```json -{ - "jsonrpc": "2.0", - "id": 1, - "result": {...} -} -``` - -## Error Handling - -The bridge handles various error conditions: - -- **Process startup failures**: Returns startup errors with stderr output -- **Request timeouts**: 30-second timeout for stdio requests -- **Process crashes**: Automatic cleanup and error reporting -- **Invalid JSON**: Proper error responses for malformed requests -- **MCP protocol errors**: Forwards MCP server errors to HTTP clients - -## Development Mode - -Enable development mode for verbose logging: - -```python -bridge = StdioMCPBridge(server_config, port=8200, dev_mode=True) -``` - -This will log: -- Process startup details -- Request/response correlation -- MCP protocol messages -- Error details - -## Dependencies - -The bridge requires: -- `fastapi`: HTTP server framework -- `uvicorn`: ASGI server -- `asyncio`: Async process management -- Standard library modules: `json`, `subprocess`, `uuid`, `logging` - -## Thread Safety - -The bridge is designed for async/await usage and handles concurrent requests safely through: -- Request correlation with unique IDs -- Async queuing of stdio requests -- Proper cleanup of resources -- Thread-safe request/response matching diff --git a/sdk/python/agentfield/agent.py b/sdk/python/agentfield/agent.py index 74babf4ca..b0c151166 100644 --- a/sdk/python/agentfield/agent.py +++ b/sdk/python/agentfield/agent.py @@ -27,12 +27,10 @@ from agentfield.agent_ai import AgentAI from agentfield.agent_cli import AgentCLI from agentfield.agent_field_handler import AgentFieldHandler -from agentfield.agent_mcp import AgentMCP from agentfield.agent_registry import clear_current_agent, set_current_agent from agentfield.agent_server import AgentServer from agentfield.agent_workflow import AgentWorkflow from agentfield.client import AgentFieldClient, ApprovalResult -from agentfield.dynamic_skills import DynamicMCPSkillManager from agentfield.execution_context import ( ExecutionContext, get_current_context, @@ -42,8 +40,6 @@ from agentfield.execution_state import ExecuteError from agentfield.did_manager import DIDManager from agentfield.vc_generator import VCGenerator -from agentfield.mcp_client import MCPClientRegistry -from agentfield.mcp_manager import MCPManager from agentfield.memory import MemoryClient, MemoryInterface from agentfield.memory_events import MemoryEventClient from agentfield.logger import log_debug, log_error, log_info, log_warn, set_cp_client @@ -432,7 +428,6 @@ class Agent(FastAPI): - Decorator-based reasoner and skill registration - Cross-agent communication via the AgentField execution gateway - Memory interface for persistent and session-based storage - - MCP (Model Context Protocol) server integration - Automatic workflow tracking and DAG building - FastAPI-based HTTP API with automatic schema generation @@ -483,7 +478,6 @@ def __init__( auto_register: bool = True, vc_enabled: Optional[bool] = True, api_key: Optional[str] = None, - enable_mcp: bool = False, enable_did: bool = True, local_verification: bool = False, verification_refresh_interval: int = 300, @@ -549,9 +543,8 @@ def __init__( ``` Note: - The agent automatically initializes all necessary handlers for MCP integration, - memory management, workflow tracking, and server functionality. MCP servers - are discovered and started automatically if present in the agent directory. + The agent automatically initializes all necessary handlers for + memory management, workflow tracking, and server functionality. """ # Set logging level based on dev_mode from agentfield.logger import set_log_level @@ -629,7 +622,6 @@ def __init__( # Fast lifecycle management self._current_status: AgentStatus = AgentStatus.STARTING self._shutdown_requested = False - self._mcp_initialization_complete = False self._start_time = time.time() # Track start time for uptime calculation # Initialize AI and Memory configurations @@ -644,10 +636,6 @@ def __init__( ) ) - # Add MCP management - self.mcp_manager: Optional[MCPManager] = None - self.mcp_client_registry: Optional[MCPClientRegistry] = None - self.dynamic_skill_manager: Optional[DynamicMCPSkillManager] = None self.memory_event_client: Optional[MemoryEventClient] = None # Add DID management @@ -655,8 +643,7 @@ def __init__( self.vc_generator: Optional[VCGenerator] = None self.did_enabled = False - # Store MCP/DID feature flags for conditional initialization - self._enable_mcp = enable_mcp + # Store DID feature flags for conditional initialization self._enable_did = enable_did # Add connection management for resilient AgentField server connectivity @@ -668,7 +655,6 @@ def __init__( self._harness_runner: Optional["HarnessRunner"] = None self._cli_handler: Optional[AgentCLI] = None # Eager handlers - required for core agent functionality - self.mcp_handler = AgentMCP(self) self.agentfield_handler = AgentFieldHandler(self) self.workflow_handler = AgentWorkflow(self) self.server_handler = AgentServer(self) @@ -676,31 +662,6 @@ def __init__( # Register this agent instance for enhanced decorator system set_current_agent(self) - # Initialize MCP components through the handler (if enabled) - if self._enable_mcp: - try: - agent_dir = self.mcp_handler._detect_agent_directory() - self.mcp_manager = MCPManager(agent_dir, self.dev_mode) - self.mcp_client_registry = MCPClientRegistry(self.dev_mode) - - if self.dev_mode: - log_debug(f"Initialized MCP Manager in {agent_dir}") - - # Initialize Dynamic Skill Manager when both MCP components are available - if self.mcp_manager and self.mcp_client_registry: - self.dynamic_skill_manager = DynamicMCPSkillManager( - self, self.dev_mode - ) - if self.dev_mode: - log_debug("Dynamic MCP skill manager initialized") - - except Exception as e: - if self.dev_mode: - log_error(f"Failed to initialize MCP Manager: {e}") - self.mcp_manager = None - self.mcp_client_registry = None - self.dynamic_skill_manager = None - # Initialize DID components (if enabled) if self._enable_did: self._initialize_did_system() @@ -1644,13 +1605,6 @@ def _register_agent_with_did(self) -> bool: log_error(f"Error registering agent with DID system: {e}") return False - def _register_mcp_servers_with_registry(self) -> None: - """ - Placeholder for MCP server registration - functionality removed. - """ - if self.dev_mode: - log_debug("MCP server registration disabled - old modules removed") - def _setup_agentfield_routes(self): """Delegate to server handler for route setup""" return self.server_handler.setup_agentfield_routes() @@ -4072,9 +4026,8 @@ def get_current(cls) -> Optional["Agent"]: """ Get the current agent instance. - This method is used by auto-generated MCP skills to access the current - agent's execution context. It uses a thread-local storage pattern to - track the current agent instance. + This method is used to access the current agent's execution context. + It uses a thread-local storage pattern to track the current agent instance. Returns: Current Agent instance or None if no agent is active @@ -4189,9 +4142,6 @@ def __del__(self) -> None: # pragma: no cover - destructor best effort # Ignore async cleanup errors in destructor pass - # Only attempt cleanup if we have an MCP handler - if hasattr(self, "mcp_handler") and self.mcp_handler: - self.mcp_handler._cleanup_mcp_servers() # Clear agent from thread-local storage as final cleanup clear_current_agent() except Exception: @@ -4448,7 +4398,6 @@ def serve( # pragma: no cover - requires full server runtime integration The server provides: - RESTful endpoints for all registered reasoners and skills - Health check endpoints for monitoring - - MCP server status and management endpoints - Automatic AgentField server registration and heartbeat - Graceful shutdown with proper cleanup @@ -4462,7 +4411,7 @@ def serve( # pragma: no cover - requires full server runtime integration - Enhanced logging and debug output - Auto-reload on code changes (if supported) - Detailed error messages - - MCP server debugging information + - Additional debugging information heartbeat_interval (int): The interval in seconds for sending heartbeats to the AgentField server. Defaults to 2 seconds. Lower values provide faster failure detection but increase network overhead. @@ -4520,7 +4469,6 @@ def get_status() -> dict: - `POST /reasoners/{reasoner_name}`: Execute reasoner functions - `POST /skills/{skill_name}`: Execute skill functions - `GET /health`: Health check endpoint - - `GET /mcp/status`: MCP server status and management - `GET /docs`: Interactive API documentation (Swagger UI) - `GET /redoc`: Alternative API documentation @@ -4533,19 +4481,16 @@ def get_status() -> dict: Lifecycle: 1. Server initialization and route setup - 2. MCP server startup (if configured) - 3. AgentField server registration + 2. AgentField server registration 4. Heartbeat loop starts 5. Ready to receive requests 6. Graceful shutdown on SIGINT/SIGTERM - 7. MCP server cleanup - 8. AgentField server deregistration + 7. AgentField server deregistration Note: - The server runs indefinitely until interrupted (Ctrl+C) - All registered reasoners and skills become available as REST endpoints - Memory and execution context are automatically managed - - MCP servers are started and managed automatically - Use `dev=True` for development, `dev=False` for production """ return self.server_handler.serve( diff --git a/sdk/python/agentfield/agent_field_handler.py b/sdk/python/agentfield/agent_field_handler.py index b7c2acd20..51254ee76 100644 --- a/sdk/python/agentfield/agent_field_handler.py +++ b/sdk/python/agentfield/agent_field_handler.py @@ -285,7 +285,7 @@ def stop_heartbeat(self): async def send_enhanced_heartbeat(self) -> bool: """ - Send enhanced heartbeat with current status and MCP information. + Send enhanced heartbeat with current status information. Returns: True if heartbeat was successful, False otherwise @@ -294,13 +294,9 @@ async def send_enhanced_heartbeat(self) -> bool: return False try: - # Get MCP server health information - mcp_servers = self.agent.mcp_handler._get_mcp_server_health() - # Create heartbeat data heartbeat_data = HeartbeatData( status=self.agent._current_status, - mcp_servers=mcp_servers, timestamp=datetime.now().isoformat(), version=getattr(self.agent, 'version', '') or '', ) @@ -521,7 +517,7 @@ async def register_with_fast_lifecycle( async def enhanced_heartbeat_loop(self, interval: int) -> None: """ - Background loop for sending enhanced heartbeats with status and MCP information. + Background loop for sending enhanced heartbeats with status information. Args: interval: Heartbeat interval in seconds diff --git a/sdk/python/agentfield/agent_mcp.py b/sdk/python/agentfield/agent_mcp.py deleted file mode 100644 index 1dc446217..000000000 --- a/sdk/python/agentfield/agent_mcp.py +++ /dev/null @@ -1,534 +0,0 @@ -import asyncio -from datetime import datetime -from typing import Any, Dict, List, Optional - -from agentfield.agent_utils import AgentUtils -from agentfield.dynamic_skills import DynamicMCPSkillManager -from agentfield.execution_context import ExecutionContext -from agentfield.logger import log_debug, log_error, log_info, log_warn -from agentfield.mcp_client import MCPClientRegistry -from agentfield.mcp_manager import MCPManager -from agentfield.types import AgentStatus, MCPServerHealth -from fastapi import Request - - -class AgentMCP: - """ - MCP Management handler for Agent class. - - This class encapsulates all MCP-related functionality including: - - Agent directory detection - - MCP server lifecycle management - - MCP skill registration - - Health monitoring - """ - - def __init__(self, agent_instance): - """ - Initialize the MCP handler with a reference to the agent instance. - - Args: - agent_instance: The Agent instance this handler belongs to - """ - self.agent = agent_instance - - def _detect_agent_directory(self) -> str: - """Detect the correct agent directory for MCP config discovery""" - import os - from pathlib import Path - - current_dir = Path(os.getcwd()) - - # Check if packages/mcp exists in current directory - if (current_dir / "packages" / "mcp").exists(): - return str(current_dir) - - # Look for agent directories in current directory - for item in current_dir.iterdir(): - if item.is_dir() and (item / "packages" / "mcp").exists(): - if self.agent.dev_mode: - log_debug(f"Found agent directory: {item}") - return str(item) - - # Look in parent directories (up to 3 levels) - for i in range(3): - parent = current_dir.parents[i] if i < len(current_dir.parents) else None - if parent and (parent / "packages" / "mcp").exists(): - if self.agent.dev_mode: - log_debug(f"Found agent directory in parent: {parent}") - return str(parent) - - # Fallback to current directory - if self.agent.dev_mode: - log_warn( - f"No packages/mcp directory found, using current directory: {current_dir}" - ) - return str(current_dir) - - async def initialize_mcp(self): - """ - Initialize MCP management components. - - This method combines the MCP initialization logic that was previously - scattered in the Agent.__init__ method. - """ - try: - agent_dir = self._detect_agent_directory() - self.agent.mcp_manager = MCPManager(agent_dir, self.agent.dev_mode) - self.agent.mcp_client_registry = MCPClientRegistry(self.agent.dev_mode) - - if self.agent.dev_mode: - log_info(f"Initialized MCP Manager in {agent_dir}") - - # Initialize Dynamic Skill Manager when both MCP components are available - if self.agent.mcp_manager and self.agent.mcp_client_registry: - self.agent.dynamic_skill_manager = DynamicMCPSkillManager( - self.agent, self.agent.dev_mode - ) - if self.agent.dev_mode: - log_info("Dynamic MCP skill manager initialized") - - except Exception as e: - if self.agent.dev_mode: - log_error(f"Failed to initialize MCP Manager: {e}") - self.agent.mcp_manager = None - self.agent.mcp_client_registry = None - self.agent.dynamic_skill_manager = None - - async def _start_mcp_servers(self) -> None: - """Start all configured MCP servers using SimpleMCPManager.""" - if not self.agent.mcp_manager: - if self.agent.dev_mode: - log_info("No MCP Manager available - skipping server startup") - return - - try: - if self.agent.dev_mode: - log_info("Starting MCP servers...") - - # Start all servers - started_servers = await self.agent.mcp_manager.start_all_servers() - - if started_servers: - successful = sum(1 for success in started_servers.values() if success) - if self.agent.dev_mode: - log_info(f"Started {successful}/{len(started_servers)} MCP servers") - elif self.agent.dev_mode: - log_info("No MCP servers configured to start") - - except Exception as e: - if self.agent.dev_mode: - log_error(f"MCP server startup error: {e}") - - def _cleanup_mcp_servers(self) -> None: - """ - Stop all MCP servers during agent shutdown. - - This method is called during graceful shutdown to ensure all - MCP server processes are properly terminated. - """ - if not self.agent.mcp_manager: - if self.agent.dev_mode: - log_info("No MCP Manager available - skipping cleanup") - return - - async def async_cleanup(): - try: - if self.agent.dev_mode: - log_info("Stopping MCP servers...") - - # Check if mcp_manager is still available - if not self.agent.mcp_manager: - if self.agent.dev_mode: - log_info("MCP Manager not available during cleanup") - return - - # Get current server status before stopping - all_status = self.agent.mcp_manager.get_all_status() - - if all_status: - running_servers = [ - alias - for alias, health in all_status.items() - if health.get("status") == "running" - ] - - if running_servers: - # Stop all running servers - for alias in running_servers: - try: - if ( - self.agent.mcp_manager - ): # Double-check before each call - await self.agent.mcp_manager.stop_server(alias) - if self.agent.dev_mode: - health = all_status.get(alias, {}) - pid = health.get("pid") or "N/A" - log_info( - f"Stopped MCP server: {alias} (PID: {pid})" - ) - except Exception as e: - if self.agent.dev_mode: - log_error(f"Failed to stop MCP server {alias}: {e}") - - if self.agent.dev_mode: - log_info(f"Stopped {len(running_servers)} MCP servers") - elif self.agent.dev_mode: - log_info("No running MCP servers to stop") - except Exception as e: - if self.agent.dev_mode: - log_error(f"Error during MCP server cleanup: {e}") - # Continue with shutdown even if cleanup fails - - # Run the async cleanup properly - try: - # Check if we're already in an event loop - try: - loop = asyncio.get_running_loop() - # If we're in a loop, create a task and store reference to prevent warning - task = loop.create_task(async_cleanup()) - - # Add a done callback to handle any exceptions and suppress warnings - def handle_task_completion(t): - try: - if t.exception() is not None and self.agent.dev_mode: - log_error(f"MCP cleanup task failed: {t.exception()}") - except Exception: - # Suppress any callback exceptions to prevent warnings - pass - - task.add_done_callback(handle_task_completion) - # Store task reference to prevent garbage collection warning - if not hasattr(self, "_cleanup_tasks"): - self._cleanup_tasks = [] - self._cleanup_tasks.append(task) - except RuntimeError: - # No event loop running, we can use asyncio.run() - try: - asyncio.run(async_cleanup()) - except Exception as cleanup_error: - if self.agent.dev_mode: - log_error(f"MCP cleanup failed: {cleanup_error}") - except Exception as e: - if self.agent.dev_mode: - log_error(f"Failed to run MCP cleanup: {e}") - - def _register_mcp_server_skills(self) -> None: - """ - DEPRECATED: This method is replaced by DynamicMCPSkillManager. - The static file-based approach is broken after SimpleMCPManager refactor. - """ - if self.agent.dev_mode: - log_warn("DEPRECATED: _register_mcp_server_skills() is no longer used") - return - - def _register_mcp_tool_as_skill( - self, server_alias: str, tool: Dict[str, Any] - ) -> None: - """ - Register an MCP tool as a proper FastAPI skill endpoint. - - Args: - server_alias: The alias of the MCP server - tool: Tool definition from mcp.json - """ - tool_name = tool.get("name", "") - if not tool_name: - if self.agent.dev_mode: - log_warn(f"Skipping tool with missing name: {tool}") - return - - skill_name = f"{server_alias}_{tool_name}" - endpoint_path = f"/skills/{skill_name}" - - # Create a simple input schema - use dict for flexibility - from pydantic import BaseModel - - class InputSchema(BaseModel): - """Dynamic input schema for MCP tool""" - - args: dict = {} - - class Config: - extra = "allow" # Allow additional fields - - # Create the MCP skill function - async def mcp_skill_function(**kwargs): - """Dynamically created MCP skill function""" - if self.agent.dev_mode: - log_debug( - f"MCP skill called: {server_alias}.{tool_name} with args: {kwargs}" - ) - - try: - # Get process-aware MCP client (reuses existing running processes) - if not self.agent.mcp_client_registry: - raise Exception("MCPClientRegistry not initialized") - mcp_client = self.agent.mcp_client_registry.get_client(server_alias) - if not mcp_client: - raise Exception(f"MCP client for {server_alias} not found") - - # Call the MCP tool using existing process - result = await mcp_client.call_tool(tool_name, kwargs) - - return { - "status": "success", - "result": result, - "server": server_alias, - "tool": tool_name, - } - - except Exception as e: - if self.agent.dev_mode: - log_error(f"MCP skill error: {e}") - return { - "status": "error", - "error": str(e), - "server": server_alias, - "tool": tool_name, - "args": kwargs, - } - - # Create FastAPI endpoint - @self.agent.post(endpoint_path, response_model=dict) - async def mcp_skill_endpoint(input_data: InputSchema, request: Request): - from agentfield.execution_context import ExecutionContext - - # Extract execution context from request headers - execution_context = ExecutionContext.from_request( - request, self.agent.node_id - ) - - # Store current context for use in app.call() - self.agent._current_execution_context = execution_context - - # Convert input to function arguments - kwargs = input_data.args - - # Call the MCP skill function - result = await mcp_skill_function(**kwargs) - - return result - - # Register skill metadata - self.agent.skills.append( - { - "id": skill_name, - "input_schema": InputSchema.model_json_schema(), - "tags": ["mcp", server_alias], - "description": tool.get("description", f"MCP tool: {tool_name}"), - } - ) - - def _create_and_register_mcp_skill( - self, server_alias: str, tool: Dict[str, Any] - ) -> None: - """ - Create and register a single MCP tool as a AgentField skill. - - Args: - server_alias: The alias of the MCP server - tool: Tool definition from mcp.json - """ - tool_name = tool.get("name", "") - if not tool_name: - raise ValueError("Tool missing 'name' field") - - # Generate skill function name: server_alias + tool_name - skill_name = AgentUtils.generate_skill_name(server_alias, tool_name) - - # Create the skill function dynamically - async def mcp_skill_function( - execution_context: Optional[ExecutionContext] = None, **kwargs - ) -> Any: - """ - Auto-generated MCP skill function. - - This function calls the corresponding MCP tool and returns the result. - """ - try: - # Get MCP client - if not self.agent.mcp_client_registry: - raise Exception("MCPClientRegistry not initialized") - client = self.agent.mcp_client_registry.get_client(server_alias) - if not client: - raise Exception(f"MCP client for {server_alias} not found") - - # Call the MCP tool - result = await client.call_tool(tool_name, kwargs) - return result - - except Exception as e: - # Re-raise with helpful context - raise Exception( - f"MCP tool '{server_alias}.{tool_name}' failed: {str(e)}" - ) from e - - # Set function metadata - mcp_skill_function.__name__ = skill_name - mcp_skill_function.__doc__ = f""" - {tool.get("description", f"MCP tool: {tool_name}")} - - This is an auto-generated skill that wraps the '{tool_name}' tool from the '{server_alias}' MCP server. - - Args: - execution_context (ExecutionContext, optional): AgentField execution context for workflow tracking - **kwargs: Arguments to pass to the MCP tool - - Returns: - Any: The result from the MCP tool execution - - Raises: - Exception: If the MCP server is unavailable or the tool execution fails - """ - - # Create input schema from tool's input schema - input_schema = AgentUtils.create_input_schema_from_mcp_tool(skill_name, tool) - - # Create FastAPI endpoint - endpoint_path = f"/skills/{skill_name}" - - @self.agent.post(endpoint_path, response_model=dict) - async def mcp_skill_endpoint(input_data: Dict[str, Any], request: Request): - # Extract execution context from request headers - execution_context = ExecutionContext.from_request( - request, self.agent.node_id - ) - - # Store current context for use in app.call() - self.agent._current_execution_context = execution_context - - # Convert input to function arguments - kwargs = input_data - - # Call the MCP skill function - result = await mcp_skill_function( - execution_context=execution_context, **kwargs - ) - return result - - # Register skill metadata - self.agent.skills.append( - { - "id": skill_name, - "input_schema": input_schema.model_json_schema(), - "tags": ["mcp", server_alias], - } - ) - - def _get_mcp_server_health(self) -> List[MCPServerHealth]: - """ - Get health information for all MCP servers. - - Returns: - List of MCPServerHealth objects - """ - mcp_servers = [] - - if self.agent.mcp_manager: - try: - all_status = self.agent.mcp_manager.get_all_status() - - for alias, server_info in all_status.items(): - server_health = MCPServerHealth( - alias=alias, - status=server_info.get("status", "unknown"), - tool_count=0, - port=server_info.get("port"), - process_id=( - server_info.get("process", {}).get("pid") - if server_info.get("process") - else None - ), - started_at=datetime.now().isoformat(), - last_health_check=datetime.now().isoformat(), - ) - - # Try to get tool count if server is running - if ( - server_health.status == "running" - and self.agent.mcp_client_registry - ): - try: - client = self.agent.mcp_client_registry.get_client(alias) - if client: - # This would need to be implemented properly - server_health.tool_count = 0 # Placeholder - except Exception: - pass - - mcp_servers.append(server_health) - - except Exception as e: - if self.agent.dev_mode: - log_error(f"Error getting MCP server health: {e}") - - return mcp_servers - - async def _background_mcp_initialization(self) -> None: - """ - Initialize MCP servers in the background after registration. - """ - try: - if self.agent.dev_mode: - log_info("Background MCP initialization started") - - # Start MCP servers - if self.agent.mcp_manager: - results = await self.agent.mcp_manager.start_all_servers() - - # Register clients for successfully started servers - for alias, success in results.items(): - if success and self.agent.mcp_client_registry: - server_status = self.agent.mcp_manager.get_server_status(alias) - if server_status and server_status.get("port"): - self.agent.mcp_client_registry.register_client( - alias, server_status["port"] - ) - - successful = sum(1 for success in results.values() if success) - total = len(results) - - if self.agent.dev_mode: - log_info( - f"MCP initialization: {successful}/{total} servers started" - ) - - # Update status based on MCP results - if successful == total and total > 0: - self.agent._current_status = AgentStatus.READY - elif successful > 0: - self.agent._current_status = AgentStatus.DEGRADED - else: - self.agent._current_status = ( - AgentStatus.READY - ) # Still ready even without MCP - else: - # No MCP manager, agent is ready - self.agent._current_status = AgentStatus.READY - if self.agent.dev_mode: - log_info("No MCP servers to initialize - agent ready") - - # Register dynamic skills if available - if self.agent.dynamic_skill_manager: - if self.agent.dev_mode: - log_info("Registering MCP tools as skills...") - await ( - self.agent.dynamic_skill_manager.discover_and_register_all_skills() - ) - - self.agent._mcp_initialization_complete = True - - # Send status update heartbeat - await self.agent.agentfield_handler.send_enhanced_heartbeat() - - if self.agent.dev_mode: - log_info( - f"Background initialization complete - Status: {self.agent._current_status.value}" - ) - - except Exception as e: - if self.agent.dev_mode: - log_error(f"Background MCP initialization error: {e}") - self.agent._current_status = AgentStatus.DEGRADED - await self.agent.agentfield_handler.send_enhanced_heartbeat() diff --git a/sdk/python/agentfield/agent_server.py b/sdk/python/agentfield/agent_server.py index 3cb8ec032..3ed1fa82c 100644 --- a/sdk/python/agentfield/agent_server.py +++ b/sdk/python/agentfield/agent_server.py @@ -91,58 +91,6 @@ async def health(): "timestamp": datetime.now().isoformat(), } - # Add MCP server status if manager is available - if self.agent.mcp_manager: - try: - all_status = self.agent.mcp_manager.get_all_status() - - # Calculate summary statistics - total_servers = len(all_status) - running_servers = sum( - 1 - for server in all_status.values() - if server.get("status") == "running" - ) - failed_servers = sum( - 1 - for server in all_status.values() - if server.get("status") == "failed" - ) - - # Determine overall health status - if failed_servers > 0: - health_response["status"] = "degraded" - - # Add MCP information to health response - mcp_server_info = { - "total": total_servers, - "running": running_servers, - "failed": failed_servers, - "servers": {}, - } - - # Add individual server details - for alias, server_process in all_status.items(): - process = server_process.get("process") - server_info = { - "status": server_process.get("status"), - "port": server_process.get("port"), - "pid": process.pid if process else None, - } - mcp_server_info["servers"][alias] = server_info - - health_response["mcp_servers"] = mcp_server_info - - except Exception as e: - if self.agent.dev_mode: - log_warn(f"Error getting MCP status for health check: {e}") - health_response["mcp_servers"] = { - "error": "Failed to get MCP status", - "total": 0, - "running": 0, - "failed": 0, - } - return health_response @self.agent.get("/reasoners") @@ -263,24 +211,6 @@ async def get_agent_status(): }, } - # Add MCP server information if available - if self.agent.mcp_manager: - try: - all_status = self.agent.mcp_manager.get_all_status() - status_response["mcp_servers"] = { - "total": len(all_status), - "running": sum( - 1 - for s in all_status.values() - if s.get("status") == "running" - ), - "servers": all_status, - } - except Exception as e: - if self.agent.dev_mode: - log_warn(f"Error getting MCP status: {e}") - status_response["mcp_servers"] = {"error": str(e)} - return status_response except ImportError: @@ -313,286 +243,6 @@ async def node_info(): "registered_at": datetime.now().isoformat(), } - @self.agent.get("/mcp/status") - async def mcp_status(): - """Get status of all MCP servers""" - if not self.agent.mcp_manager: - return { - "error": "MCP Manager not available", - "servers": {}, - "total": 0, - "running": 0, - "failed": 0, - } - - # MCP functionality disabled - return { - "error": "MCP functionality disabled - old modules removed", - "servers": {}, - "total": 0, - "running": 0, - "failed": 0, - } - - @self.agent.post("/mcp/{alias}/start") - async def start_mcp_server(alias: str): - """Start a specific MCP server""" - if not self.agent.mcp_manager: - return { - "success": False, - "error": "MCP Process Manager not available", - "alias": alias, - } - - try: - success = await self.agent.mcp_manager.start_server_by_alias(alias) - if success: - # Get updated status - status = self.agent.mcp_manager.get_server_status(alias) - return { - "success": True, - "message": f"MCP server '{alias}' started successfully", - "alias": alias, - "status": status, - "timestamp": datetime.now().isoformat(), - } - else: - return { - "success": False, - "error": f"Failed to start MCP server '{alias}'", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - - except Exception as e: - return { - "success": False, - "error": f"Error starting MCP server '{alias}': {str(e)}", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - - @self.agent.post("/mcp/{alias}/stop") - async def stop_mcp_server(alias: str): - """Stop a specific MCP server""" - if not self.agent.mcp_manager: - return { - "success": False, - "error": "MCP Process Manager not available", - "alias": alias, - } - - try: - success = self.agent.mcp_manager.stop_server(alias) - if success: - return { - "success": True, - "message": f"MCP server '{alias}' stopped successfully", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - else: - return { - "success": False, - "error": f"Failed to stop MCP server '{alias}' (may not be running)", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - - except Exception as e: - return { - "success": False, - "error": f"Error stopping MCP server '{alias}': {str(e)}", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - - @self.agent.post("/mcp/{alias}/restart") - async def restart_mcp_server(alias: str): - """Restart a specific MCP server""" - if not self.agent.mcp_manager: - return { - "success": False, - "error": "MCP Process Manager not available", - "alias": alias, - } - - try: - success = await self.agent.mcp_manager.restart_server(alias) - if success: - # Get updated status - status = self.agent.mcp_manager.get_server_status(alias) - return { - "success": True, - "message": f"MCP server '{alias}' restarted successfully", - "alias": alias, - "status": status, - "timestamp": datetime.now().isoformat(), - } - else: - return { - "success": False, - "error": f"Failed to restart MCP server '{alias}'", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - - except Exception as e: - return { - "success": False, - "error": f"Error restarting MCP server '{alias}': {str(e)}", - "alias": alias, - "timestamp": datetime.now().isoformat(), - } - - @self.agent.get("/health/mcp") - async def mcp_health(): - """Get MCP health information in the format expected by AgentField server""" - if not self.agent.mcp_manager: - # Return empty response when MCP manager is not available - return { - "servers": [], - "summary": { - "total_servers": 0, - "running_servers": 0, - "total_tools": 0, - "overall_health": 0.0, - }, - } - - try: - # Get all server status from MCP manager - all_status = self.agent.mcp_manager.get_all_status() - servers = [] - total_tools = 0 - running_servers = 0 - - # Process each server to get detailed health information - for alias, server_info in all_status.items(): - server_health = { - "alias": alias, - "status": server_info.get("status", "unknown"), - "tool_count": 0, - "started_at": None, - "last_health_check": datetime.now().isoformat(), - "port": server_info.get("port"), - "process_id": None, - } - - # Get process ID if available - if alias in self.agent.mcp_manager.servers: - server_process = self.agent.mcp_manager.servers[alias] - if server_process.process: - server_health["process_id"] = server_process.process.pid - - # Count running servers - if server_health["status"] == "running": - running_servers += 1 - - # Try to get tool count from MCP client - try: - if self.agent.mcp_client_registry: - client = self.agent.mcp_client_registry.get_client( - alias - ) - if client: - tools = await client.list_tools() - server_health["tool_count"] = len(tools) - total_tools += len(tools) - - # Set started_at time (approximate) - server_health["started_at"] = ( - datetime.now().isoformat() - ) - - except Exception as e: - if self.agent.dev_mode: - log_warn(f"Failed to get tools for {alias}: {e}") - - servers.append(server_health) - - # Calculate overall health score - total_servers = len(servers) - if total_servers == 0: - overall_health = 0.0 - else: - # Health score based on running servers ratio - health_ratio = running_servers / total_servers - # Adjust for any servers with errors - error_servers = sum(1 for s in servers if s["status"] == "error") - if error_servers > 0: - health_ratio *= 1 - ( - error_servers * 0.2 - ) # Reduce health for errors - overall_health = max(0.0, min(1.0, health_ratio)) - - # Build summary - summary = { - "total_servers": total_servers, - "running_servers": running_servers, - "total_tools": total_tools, - "overall_health": overall_health, - } - - return {"servers": servers, "summary": summary} - - except Exception as e: - if self.agent.dev_mode: - log_error(f"Error getting MCP health: {e}") - - # Return error response in expected format - return { - "servers": [], - "summary": { - "total_servers": 0, - "running_servers": 0, - "total_tools": 0, - "overall_health": 0.0, - }, - } - - @self.agent.post("/mcp/servers/{alias}/restart") - async def restart_mcp_server_alt(alias: str): - """Alternative restart endpoint for AgentField server compatibility""" - return await restart_mcp_server(alias) - - @self.agent.get("/mcp/servers/{alias}/tools") - async def get_mcp_server_tools(alias: str): - """Get tools from a specific MCP server""" - if not self.agent.mcp_client_registry: - return {"error": "MCP Client Registry not available", "tools": []} - - try: - client = self.agent.mcp_client_registry.get_client(alias) - if not client: - return { - "error": f"MCP server '{alias}' not found or not running", - "tools": [], - } - - tools = await client.list_tools() - - # Transform tools to match expected format - formatted_tools = [] - for tool in tools: - formatted_tool = { - "name": tool.get("name", ""), - "description": tool.get("description", ""), - "input_schema": tool.get("inputSchema", {}), - } - formatted_tools.append(formatted_tool) - - return {"tools": formatted_tools} - - except Exception as e: - if self.agent.dev_mode: - log_error(f"Error getting tools for {alias}: {e}") - - return { - "error": f"Failed to get tools from MCP server '{alias}': {str(e)}", - "tools": [], - } - # ----------------------------------------------------------------- # Approval webhook — receives callbacks from the control plane when # an execution's approval state resolves. Auto-registered so every @@ -663,16 +313,6 @@ async def _graceful_shutdown(self, timeout_seconds: int = 30): if self.agent.dev_mode: log_info(f"Starting graceful shutdown (timeout: {timeout_seconds}s)") - # Stop MCP servers first - try: - if hasattr(self.agent, "mcp_handler") and self.agent.mcp_handler: - self.agent.mcp_handler._cleanup_mcp_servers() - if self.agent.dev_mode: - log_info("MCP servers stopped") - except Exception as e: - if self.agent.dev_mode: - log_error(f"MCP shutdown error: {e}") - # Stop heartbeat try: if ( @@ -718,13 +358,6 @@ async def _immediate_shutdown(self): if self.agent.dev_mode: log_warn("Immediate shutdown initiated") - # Quick cleanup attempt - try: - if hasattr(self.agent, "mcp_handler") and self.agent.mcp_handler: - self.agent.mcp_handler._cleanup_mcp_servers() - except Exception: - pass # Ignore errors in immediate shutdown - # Exit immediately os._exit(0) @@ -872,7 +505,7 @@ def setup_signal_handlers(self) -> None: Setup signal handlers for graceful shutdown. This method registers signal handlers for SIGTERM and SIGINT - to ensure MCP servers are properly stopped when the agent shuts down. + to ensure proper cleanup when the agent shuts down. """ try: # Register signal handlers for graceful shutdown @@ -900,9 +533,6 @@ def signal_handler(self, signum: int, frame) -> None: if self.agent.dev_mode: log_warn(f"{signal_name} received, shutting down gracefully...") - # Perform cleanup - self.agent.mcp_handler._cleanup_mcp_servers() - # Exit gracefully os._exit(0) @@ -1119,8 +749,7 @@ def on_disconnected(): # Start connection manager (non-blocking) connected = await self.agent.connection_manager.start() - # Always connect memory event client and start MCP initialization - # These work independently of AgentField server connection + # Connect memory event client - works independently of AgentField server connection if self.agent.memory_event_client: try: await self.agent.memory_event_client.connect() @@ -1128,9 +757,6 @@ def on_disconnected(): if self.agent.dev_mode: log_error(f"Memory event client connection failed: {e}") - # Start background MCP initialization (non-blocking) - asyncio.create_task(self.agent.mcp_handler._background_mcp_initialization()) - if connected: if self.agent.dev_mode: log_info("Agent started with AgentField server connection") @@ -1153,23 +779,6 @@ async def shutdown_cleanup(): if self.agent.memory_event_client: await self.agent.memory_event_client.close() - # Stop MCP servers - if self.agent.mcp_manager: - try: - await self.agent.mcp_manager.shutdown_all() - if self.agent.dev_mode: - log_info("MCP servers stopped") - except Exception as e: - if self.agent.dev_mode: - log_error(f"MCP shutdown error: {e}") - - if self.agent.mcp_client_registry: - try: - await self.agent.mcp_client_registry.close_all() - except Exception as e: - if self.agent.dev_mode: - log_error(f"MCP client shutdown error: {e}") - if getattr(self.agent, "client", None): try: await self.agent.client.aclose() @@ -1284,15 +893,12 @@ async def shutdown_cleanup(): log_error(f"Unexpected server error: {e}") raise finally: - # Phase 5: Graceful shutdown - stop heartbeat and MCP servers + # Phase 5: Graceful shutdown - stop heartbeat if self.agent.dev_mode: log_info("Agent shutdown initiated...") # Stop heartbeat worker self.agent.agentfield_handler.stop_heartbeat() - # Stop all MCP servers - self.agent.mcp_handler._cleanup_mcp_servers() - if self.agent.dev_mode: log_success("Agent shutdown complete") diff --git a/sdk/python/agentfield/agent_utils.py b/sdk/python/agentfield/agent_utils.py index f53020efb..12e037dac 100644 --- a/sdk/python/agentfield/agent_utils.py +++ b/sdk/python/agentfield/agent_utils.py @@ -155,11 +155,11 @@ def map_json_type_to_python(json_type: str) -> Type: @staticmethod def generate_skill_name(server_alias: str, tool_name: str) -> str: """ - Generate a valid Python function name for the MCP skill. + Generate a valid Python function name for a skill. Args: - server_alias: MCP server alias - tool_name: MCP tool name + server_alias: Server alias + tool_name: Tool name Returns: Valid Python function name @@ -178,20 +178,20 @@ def generate_skill_name(server_alias: str, tool_name: str) -> str: # Ensure it's not empty if not name: - name = f"mcp_tool_{int(time.time())}" + name = f"tool_{int(time.time())}" return name @staticmethod - def create_input_schema_from_mcp_tool( + def create_input_schema_from_tool( skill_name: str, tool: Dict[str, Any] ) -> Type[BaseModel]: """ - Create a Pydantic input schema from MCP tool definition. + Create a Pydantic input schema from a tool definition. Args: skill_name: Name of the skill function - tool: MCP tool definition + tool: Tool definition Returns: Pydantic model class for input validation diff --git a/sdk/python/agentfield/client.py b/sdk/python/agentfield/client.py index 117b162ee..891a7d3f3 100644 --- a/sdk/python/agentfield/client.py +++ b/sdk/python/agentfield/client.py @@ -1084,11 +1084,11 @@ async def send_enhanced_heartbeat( self, node_id: str, heartbeat_data: HeartbeatData ) -> bool: """ - Send enhanced heartbeat with status and MCP information to AgentField server. + Send enhanced heartbeat with status information to AgentField server. Args: node_id: The agent node ID - heartbeat_data: Enhanced heartbeat data with status and MCP info + heartbeat_data: Enhanced heartbeat data with status info Returns: True if heartbeat was successful, False otherwise @@ -1116,7 +1116,7 @@ def send_enhanced_heartbeat_sync( Args: node_id: The agent node ID - heartbeat_data: Enhanced heartbeat data with status and MCP info + heartbeat_data: Enhanced heartbeat data with status info Returns: True if heartbeat was successful, False otherwise diff --git a/sdk/python/agentfield/dynamic_skills.py b/sdk/python/agentfield/dynamic_skills.py deleted file mode 100644 index 12f18a0f0..000000000 --- a/sdk/python/agentfield/dynamic_skills.py +++ /dev/null @@ -1,304 +0,0 @@ -import asyncio -from typing import Any, Dict, Optional, Type - -from pydantic import BaseModel, create_model -from fastapi import Request - -from agentfield.agent_utils import AgentUtils -from agentfield.execution_context import ExecutionContext -from agentfield.logger import log_debug, log_error, log_info, log_warn - - -class DynamicMCPSkillManager: - """ - Dynamic MCP Skill Generator that converts MCP tools into AgentField skills. - - This class discovers MCP servers, lists their tools, and dynamically - registers each tool as a AgentField skill with proper schema generation - and execution context handling. - """ - - def __init__(self, agent, dev_mode: bool = False): - """ - Initialize the Dynamic MCP Skill Manager. - - Args: - agent: The AgentField agent instance - dev_mode: Enable development mode logging - """ - self.agent = agent - self.dev_mode = dev_mode - self.registered_skills: Dict[str, Dict] = {} - - async def discover_and_register_all_skills(self) -> None: - """ - Discover and register all MCP tools as AgentField skills. - - This method: - 1. Checks for MCP client registry availability - 2. Iterates through all connected MCP servers - 3. Waits for server readiness - 4. Performs health checks on each server - 5. Lists tools from healthy servers - 6. Registers each tool as a AgentField skill - """ - if not self.agent.mcp_client_registry: - if self.dev_mode: - log_warn("MCP client registry not available") - return - - if self.dev_mode: - log_info("Starting MCP skill discovery...") - - # Get all registered MCP clients - clients = self.agent.mcp_client_registry.clients - - if not clients: - if self.dev_mode: - log_info("No MCP servers found in registry") - return - - # Wait for server readiness - await asyncio.sleep(1) - - for server_alias, client in clients.items(): - try: - if self.dev_mode: - log_debug(f"Processing MCP server: {server_alias}") - - # Perform health check - is_healthy = await client.health_check() - if not is_healthy: - if self.dev_mode: - log_warn( - f"MCP server {server_alias} failed health check, skipping" - ) - continue - - # List tools from the server - tools = await client.list_tools() - if not tools: - if self.dev_mode: - log_info(f"No tools found in MCP server {server_alias}") - continue - - if self.dev_mode: - log_debug(f"Found {len(tools)} tools in {server_alias}") - - # Register each tool as a skill - for tool in tools: - try: - skill_name = AgentUtils.generate_skill_name( - server_alias, tool.get("name", "") - ) - await self._register_mcp_tool_as_skill( - server_alias, tool, skill_name - ) - - if self.dev_mode: - log_info(f"Registered skill: {skill_name}") - - except Exception as e: - if self.dev_mode: - log_error( - f"Failed to register tool {tool.get('name', 'unknown')} from {server_alias}: {e}" - ) - continue - - except Exception as e: - if self.dev_mode: - log_error(f"Error processing MCP server {server_alias}: {e}") - continue - - if self.dev_mode: - log_info( - f"MCP skill discovery complete. Registered {len(self.registered_skills)} skills" - ) - - async def _register_mcp_tool_as_skill( - self, server_alias: str, tool: Dict[str, Any], skill_name: str - ) -> None: - """ - Register an MCP tool as a AgentField skill. - - This method: - 1. Extracts tool metadata (name, description) - 2. Generates Pydantic input schema from tool definition - 3. Creates async wrapper function for MCP tool calls - 4. Sets function metadata - 5. Creates FastAPI endpoint - 6. Handles execution context from request headers - 7. Stores and clears execution context appropriately - 8. Registers skill metadata with agent - 9. Adds to internal skill registry - - Args: - server_alias: MCP server alias - tool: Tool definition from MCP server - skill_name: Generated skill name - """ - tool_name = tool.get("name", "") - description = tool.get( - "description", f"MCP tool {tool_name} from {server_alias}" - ) - - # Generate Pydantic input schema - input_schema = self._create_input_schema_from_tool(skill_name, tool) - - # Create async wrapper function for MCP tool calls - async def mcp_skill_wrapper(**kwargs): - """Dynamically created MCP skill function""" - try: - # Get MCP client for this server - client = self.agent.mcp_client_registry.get_client(server_alias) - if not client: - return { - "status": "error", - "error": f"MCP client for server '{server_alias}' not available", - "server": server_alias, - "tool": tool_name, - "args": kwargs, - } - - # Call the MCP tool - result = await client.call_tool(tool_name, kwargs) - - return { - "status": "success", - "result": result, - "server": server_alias, - "tool": tool_name, - } - - except Exception as e: - return { - "status": "error", - "error": str(e), - "server": server_alias, - "tool": tool_name, - "args": kwargs, - } - - # Set function metadata - mcp_skill_wrapper.__name__ = skill_name - mcp_skill_wrapper.__doc__ = description - - # Create FastAPI endpoint - endpoint_path = f"/skills/{skill_name}" - - # Create the endpoint function dynamically - async def mcp_skill_endpoint(input_data: Any, request: Request): - """Dynamically created MCP skill endpoint""" - # Validate input data against the schema - validated_data = ( - input_schema(**input_data) - if isinstance(input_data, dict) - else input_data - ) - - # Handle execution context from request headers - execution_context = ExecutionContext.from_request( - request, self.agent.node_id - ) - - # Store execution context in agent - self.agent._current_execution_context = execution_context - - try: - # Convert input to function arguments - if hasattr(validated_data, "dict"): - kwargs = validated_data.model_dump() - elif isinstance(validated_data, dict): - kwargs = validated_data - else: - kwargs = {} - - # Call the MCP skill wrapper - result = await mcp_skill_wrapper(**kwargs) - - return result - - finally: - # Clear execution context after completion - self.agent._current_execution_context = None - - # Set the correct parameter annotation for FastAPI - mcp_skill_endpoint.__annotations__ = { - "input_data": input_schema, - "request": Request, - "return": dict, - } - - # Register the endpoint - self.agent.post(endpoint_path, response_model=dict)(mcp_skill_endpoint) - - # Register skill metadata with agent - skill_metadata = { - "id": skill_name, - "input_schema": input_schema.model_json_schema(), - "tags": ["mcp", server_alias], - "description": description, - "server_alias": server_alias, - "tool_name": tool_name, - } - - self.agent.skills.append(skill_metadata) - - # Add to internal skill registry - self.registered_skills[skill_name] = skill_metadata - - def _create_input_schema_from_tool( - self, skill_name: str, tool: Dict[str, Any] - ) -> Type[BaseModel]: - """ - Create Pydantic input schema from MCP tool definition. - - Schema Generation Rules: - - Extract inputSchema.properties and required fields - - Map JSON Schema types to Python types - - Handle required vs optional fields appropriately - - Set default values when specified - - Use Optional[Type] for non-required fields without defaults - - Fallback to generic {"data": Optional[Dict[str, Any]]} if no properties - - Create model with name pattern: {skill_name}Input - - Args: - skill_name: Name of the skill - tool: Tool definition from MCP server - - Returns: - Pydantic BaseModel class for input validation - """ - input_schema = tool.get("inputSchema", {}) - properties = input_schema.get("properties", {}) - required_fields = set(input_schema.get("required", [])) - - # If no properties defined, use generic schema - if not properties: - return create_model( - f"{skill_name}Input", data=(Optional[Dict[str, Any]], None) - ) - - # Build field definitions for Pydantic model - field_definitions = {} - - for field_name, field_def in properties.items(): - field_type = AgentUtils.map_json_type_to_python( - field_def.get("type", "string") - ) - default_value = field_def.get("default") - is_required = field_name in required_fields - - if is_required and default_value is None: - # Required field without default - field_definitions[field_name] = (field_type, ...) - elif default_value is not None: - # Field with default value - field_definitions[field_name] = (field_type, default_value) - else: - # Optional field without default - field_definitions[field_name] = (Optional[field_type], None) - - # Create and return the Pydantic model - model_name = f"{skill_name}Input" - return create_model(model_name, **field_definitions) diff --git a/sdk/python/agentfield/logger.py b/sdk/python/agentfield/logger.py index 565eccc75..4aeee8db3 100644 --- a/sdk/python/agentfield/logger.py +++ b/sdk/python/agentfield/logger.py @@ -414,19 +414,6 @@ def network(self, message: str, **kwargs): extra=kwargs or None, ) - def mcp(self, message: str, **kwargs): - """Log MCP-related messages""" - - self._emit_optional_structured( - "INFO", - message, - prefix="šŸ”Œ", - event_type="runtime.mcp", - source="sdk.python.runtime", - force_structured=False, - extra=kwargs or None, - ) - def security(self, message: str, **kwargs): """Log security/DID-related messages""" @@ -552,12 +539,6 @@ def log_network(message: str, **kwargs): get_logger().network(message, **kwargs) -def log_mcp(message: str, **kwargs): - """Log MCP message""" - - get_logger().mcp(message, **kwargs) - - def log_security(message: str, **kwargs): """Log security message""" diff --git a/sdk/python/agentfield/mcp_client.py b/sdk/python/agentfield/mcp_client.py deleted file mode 100644 index 9c3a41d52..000000000 --- a/sdk/python/agentfield/mcp_client.py +++ /dev/null @@ -1,204 +0,0 @@ -from typing import Any, Dict, List, Optional - -import aiohttp -from aiohttp import ClientTimeout - -from agentfield.logger import log_debug, log_error, log_info, log_warn - - -class MCPClient: - def __init__(self, base_url: str, alias: str, dev_mode: bool = False): - self.server_alias = alias - self.base_url = base_url - self.dev_mode = dev_mode - self.session: Optional[aiohttp.ClientSession] = None - self._is_stdio_bridge = False # Default to direct HTTP - - # Legacy constructor support for backward compatibility - @classmethod - def from_port(cls, server_alias: str, port: int, dev_mode: bool = False): - """Create MCPClient from port (legacy method for backward compatibility)""" - base_url = f"http://localhost:{port}" - return cls(base_url, server_alias, dev_mode) - - async def _ensure_session(self) -> None: - """Ensure aiohttp session exists""" - if self.session is None or self.session.closed: - self.session = aiohttp.ClientSession() - - async def close(self): - """Close the client session""" - if self.session and not self.session.closed: - await self.session.close() - - async def health_check(self) -> bool: - """Check if MCP server is healthy""" - try: - await self._ensure_session() - if self.session is None: - return False - timeout = ClientTimeout(total=5) - - # Use /health endpoint for both bridge and direct HTTP - async with self.session.get( - f"{self.base_url}/health", timeout=timeout - ) as response: - return response.status == 200 - except Exception as e: - if self.dev_mode: - log_warn(f"Health check failed for {self.server_alias}: {e}") - return False - - async def list_tools(self) -> List[Dict[str, Any]]: - """Get available tools from MCP server""" - try: - await self._ensure_session() - if self.session is None: - return [] - - timeout = ClientTimeout(total=10) - - if getattr(self, "_is_stdio_bridge", False): - # Use bridge endpoint - endpoint = "/mcp/tools/list" - async with self.session.post( - f"{self.base_url}{endpoint}", timeout=timeout - ) as response: - if response.status == 200: - data = await response.json() - tools = data.get("tools", []) - if self.dev_mode: - log_debug( - f"Found {len(tools)} tools in {self.server_alias} (stdio bridge)" - ) - return tools - else: - # Use direct HTTP endpoint - request_data = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/list", - "params": {}, - } - - async with self.session.post( - f"{self.base_url}/mcp/v1", json=request_data, timeout=timeout - ) as response: - if response.status == 200: - data = await response.json() - if "result" in data and "tools" in data["result"]: - tools = data["result"]["tools"] - if self.dev_mode: - log_debug( - f"Found {len(tools)} tools in {self.server_alias} (direct HTTP)" - ) - return tools - - except Exception as e: - if self.dev_mode: - log_error(f"Failed to list tools for {self.server_alias}: {e}") - - return [] - - async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Any: - """Call specific tool on MCP server""" - try: - await self._ensure_session() - if self.session is None: - raise Exception("Session not available") - - if self.dev_mode: - transport_type = ( - "stdio bridge" - if getattr(self, "_is_stdio_bridge", False) - else "direct HTTP" - ) - log_debug( - f"Calling {self.server_alias}.{tool_name} with args: {arguments} ({transport_type})" - ) - - timeout = ClientTimeout(total=30) - - if getattr(self, "_is_stdio_bridge", False): - # Use bridge endpoint - request_data = {"tool_name": tool_name, "arguments": arguments} - - async with self.session.post( - f"{self.base_url}/mcp/tools/call", - json=request_data, - timeout=timeout, - ) as response: - if response.status == 200: - data = await response.json() - return data - else: - raise Exception( - f"HTTP {response.status}: {await response.text()}" - ) - else: - # Use direct HTTP endpoint - request_data = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": {"name": tool_name, "arguments": arguments}, - } - - async with self.session.post( - f"{self.base_url}/mcp/v1", json=request_data, timeout=timeout - ) as response: - if response.status == 200: - data = await response.json() - if "result" in data: - return data["result"] - elif "error" in data: - raise Exception(f"MCP tool error: {data['error']}") - else: - raise Exception( - f"HTTP {response.status}: {await response.text()}" - ) - - except Exception as e: - if self.dev_mode: - log_error(f"Tool call failed {self.server_alias}.{tool_name}: {e}") - raise Exception( - f"MCP tool '{self.server_alias}.{tool_name}' failed: {str(e)}" - ) - - -class MCPClientRegistry: - """Registry to manage MCP clients for all servers""" - - def __init__(self, dev_mode: bool = False): - self.clients: Dict[str, MCPClient] = {} - self.dev_mode = dev_mode - - def register_client(self, alias: str, port: int): - """Register MCP client for server""" - base_url = f"http://localhost:{port}" - client = MCPClient(base_url, alias, self.dev_mode) - self.clients[alias] = client - - if self.dev_mode: - log_info(f"Registered MCP client for {alias} on port {port}") - - def register_stdio_bridge_client(self, alias: str, bridge_port: int) -> None: - """Register a client for a stdio bridge server""" - base_url = f"http://localhost:{bridge_port}" - client = MCPClient(base_url, alias, self.dev_mode) - client._is_stdio_bridge = True # Mark as bridge client - self.clients[alias] = client - if self.dev_mode: - log_info( - f"Registered stdio bridge client for {alias} on port {bridge_port}" - ) - - def get_client(self, alias: str) -> Optional[MCPClient]: - """Get MCP client by server alias""" - return self.clients.get(alias) - - async def close_all(self): - """Close all MCP clients""" - for client in self.clients.values(): - await client.close() - self.clients.clear() diff --git a/sdk/python/agentfield/mcp_manager.py b/sdk/python/agentfield/mcp_manager.py deleted file mode 100644 index 4b0292535..000000000 --- a/sdk/python/agentfield/mcp_manager.py +++ /dev/null @@ -1,340 +0,0 @@ -import asyncio -import json -import os -import subprocess -from typing import Any, Dict, List, Optional -from dataclasses import dataclass - -from .logger import get_logger -from .mcp_stdio_bridge import StdioMCPBridge - -logger = get_logger(__name__) - - -@dataclass -class MCPServerConfig: - alias: str - run_command: str - working_dir: str - environment: Dict[str, str] - health_check: Optional[str] = None - port: Optional[int] = None - transport: str = "http" - - -@dataclass -class MCPServerProcess: - config: MCPServerConfig - process: Optional[subprocess.Popen] = None - port: Optional[int] = None - status: str = "stopped" # stopped, starting, running, failed - - -class MCPManager: - def __init__(self, agent_directory: str, dev_mode: bool = False): - self.agent_directory = agent_directory - self.dev_mode = dev_mode - self.servers: Dict[str, MCPServerProcess] = {} - self.stdio_bridges: Dict[str, StdioMCPBridge] = {} - self.port_range_start = 8100 # Start assigning ports from 8100 - self.used_ports = set() - - def discover_mcp_servers(self) -> List[MCPServerConfig]: - """Discover MCP servers from packages/mcp/ directory""" - mcp_dir = os.path.join(self.agent_directory, "packages", "mcp") - servers = [] - - if not os.path.exists(mcp_dir): - if self.dev_mode: - logger.debug(f"No MCP directory found at {mcp_dir}") - return servers - - for item in os.listdir(mcp_dir): - server_dir = os.path.join(mcp_dir, item) - config_file = os.path.join(server_dir, "config.json") - - if os.path.isdir(server_dir) and os.path.exists(config_file): - try: - with open(config_file, "r") as f: - config_data = json.load(f) - - config = MCPServerConfig( - alias=config_data.get("alias", item), - run_command=config_data.get("run", ""), - working_dir=server_dir, - environment=config_data.get("environment", {}), - health_check=config_data.get("health_check"), - transport=config_data.get("transport", "http"), - ) - servers.append(config) - - if self.dev_mode: - logger.debug(f"Discovered MCP server: {config.alias}") - - except Exception as e: - if self.dev_mode: - logger.warning(f"Failed to load config for {item}: {e}") - - return servers - - def _get_next_available_port(self) -> int: - """Get next available port for MCP server""" - import socket - - for port in range(self.port_range_start, self.port_range_start + 1000): - if port not in self.used_ports: - # Test if port is actually available - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("localhost", port)) - self.used_ports.add(port) - return port - except OSError: - continue - - raise RuntimeError("No available ports for MCP servers") - - def _detect_transport(self, config: MCPServerConfig) -> str: - """Detect transport type from config""" - return config.transport - - async def _start_stdio_server(self, config: MCPServerConfig) -> bool: - """Start stdio MCP server using bridge""" - try: - # Assign port for the bridge - port = self._get_next_available_port() - config.port = port - - if self.dev_mode: - logger.info(f"Starting stdio MCP server: {config.alias} on port {port}") - logger.debug(f"Command: {config.run_command}") - - # Prepare server config for bridge - server_config = { - "run": config.run_command, - "working_dir": config.working_dir, - "environment": config.environment, - } - - # Create and start stdio bridge - bridge = StdioMCPBridge( - server_config=server_config, port=port, dev_mode=self.dev_mode - ) - - success = await bridge.start() - if success: - self.stdio_bridges[config.alias] = bridge - if self.dev_mode: - logger.info(f"Stdio MCP server {config.alias} started successfully") - return True - else: - if self.dev_mode: - logger.error(f"Stdio MCP server {config.alias} failed to start") - return False - - except Exception as e: - if self.dev_mode: - logger.error(f"Error starting stdio MCP server {config.alias}: {e}") - return False - - async def _start_http_server(self, config: MCPServerConfig) -> bool: - """Start HTTP MCP server (original implementation)""" - try: - # Assign port - port = self._get_next_available_port() - config.port = port - - # Prepare command with port substitution - run_command = config.run_command.replace("{{port}}", str(port)) - - # Prepare environment - env = os.environ.copy() - env.update(config.environment) - - if self.dev_mode: - logger.info(f"Starting HTTP MCP server: {config.alias} on port {port}") - logger.debug(f"Command: {run_command}") - - # Start process - process = subprocess.Popen( - run_command.split(), - cwd=config.working_dir, - env=env, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - # Create server process object - server_process = MCPServerProcess( - config=config, process=process, port=port, status="starting" - ) - - self.servers[config.alias] = server_process - - # Wait a moment for startup - await asyncio.sleep(2) - - # Check if process is still running - if process.poll() is None: - server_process.status = "running" - if self.dev_mode: - logger.info(f"HTTP MCP server {config.alias} started successfully") - return True - else: - server_process.status = "failed" - if self.dev_mode: - logger.error(f"HTTP MCP server {config.alias} failed to start") - return False - - except Exception as e: - if self.dev_mode: - logger.error(f"Error starting HTTP MCP server {config.alias}: {e}") - if config.alias in self.servers: - self.servers[config.alias].status = "failed" - return False - - async def start_server(self, config: MCPServerConfig) -> bool: - """Start individual MCP server""" - transport = self._detect_transport(config) - if transport == "stdio": - return await self._start_stdio_server(config) - else: - return await self._start_http_server(config) - - async def start_all_servers(self) -> Dict[str, bool]: - """Start all discovered MCP servers""" - configs = self.discover_mcp_servers() - results = {} - - if self.dev_mode: - logger.info(f"Starting {len(configs)} MCP servers...") - - for config in configs: - success = await self.start_server(config) - results[config.alias] = success - - return results - - def get_server_status(self, alias: str) -> Optional[Dict[str, Any]]: - """Get status of specific MCP server""" - # Check stdio bridges first - if alias in self.stdio_bridges: - bridge = self.stdio_bridges[alias] - return { - "alias": alias, - "transport": "stdio", - "port": bridge.port, - "status": "running" if bridge.running else "stopped", - "initialized": bridge.initialized, - } - - # Check HTTP servers - if alias in self.servers: - server_process = self.servers[alias] - return { - "alias": alias, - "transport": "http", - "port": server_process.port, - "status": server_process.status, - "config": server_process.config, - } - - return None - - def get_all_status(self) -> Dict[str, Dict[str, Any]]: - """Get status of all MCP servers""" - all_status = {} - - # Add stdio bridges - for alias, bridge in self.stdio_bridges.items(): - all_status[alias] = { - "alias": alias, - "transport": "stdio", - "port": bridge.port, - "status": "running" if bridge.running else "stopped", - "initialized": bridge.initialized, - } - - # Add HTTP servers - for alias, server_process in self.servers.items(): - all_status[alias] = { - "alias": alias, - "transport": "http", - "port": server_process.port, - "status": server_process.status, - "config": server_process.config, - } - - return all_status - - async def stop_server(self, alias: str) -> bool: - """Stop specific MCP server""" - # Check if it's a stdio bridge - if alias in self.stdio_bridges: - bridge = self.stdio_bridges[alias] - await bridge.stop() - if bridge.port: - self.used_ports.discard(bridge.port) - del self.stdio_bridges[alias] - if self.dev_mode: - logger.info(f"Stopped stdio MCP server: {alias}") - return True - - # Check if it's an HTTP server - if alias in self.servers: - server_process = self.servers[alias] - if server_process.process and server_process.process.poll() is None: - server_process.process.terminate() - try: - server_process.process.wait(timeout=5) - except subprocess.TimeoutExpired: - server_process.process.kill() - - server_process.status = "stopped" - if server_process.port: - self.used_ports.discard(server_process.port) - - if self.dev_mode: - logger.info(f"Stopped HTTP MCP server: {alias}") - return True - - return False - - async def start_server_by_alias(self, alias: str) -> bool: - """Start MCP server by alias""" - # Find the config for this alias - configs = self.discover_mcp_servers() - for config in configs: - if config.alias == alias: - return await self.start_server(config) - - if self.dev_mode: - logger.warning(f"No configuration found for MCP server: {alias}") - return False - - async def restart_server(self, alias: str) -> bool: - """Restart MCP server by alias""" - # Stop first - stop_success = await self.stop_server(alias) - if self.dev_mode: - logger.info(f"Stopped '{alias}' for restart: {stop_success}") - - # Wait a moment for cleanup - await asyncio.sleep(1) - - # Start again - return await self.start_server_by_alias(alias) - - async def shutdown_all(self) -> None: - """Stop all MCP servers""" - if self.dev_mode: - logger.info("Shutting down all MCP servers...") - - # Stop all stdio bridges - for alias in list(self.stdio_bridges.keys()): - await self.stop_server(alias) - - # Stop all HTTP servers - for alias in list(self.servers.keys()): - await self.stop_server(alias) diff --git a/sdk/python/agentfield/mcp_stdio_bridge.py b/sdk/python/agentfield/mcp_stdio_bridge.py deleted file mode 100644 index 9905fe942..000000000 --- a/sdk/python/agentfield/mcp_stdio_bridge.py +++ /dev/null @@ -1,551 +0,0 @@ -import asyncio -import json -import os -from contextlib import asynccontextmanager -from dataclasses import dataclass -from typing import Dict, Optional - -import uvicorn -from fastapi import FastAPI, HTTPException - -from .logger import get_logger - -logger = get_logger(__name__) - - -@dataclass -class PendingRequest: - """Represents a pending request waiting for response""" - - future: asyncio.Future - timestamp: float - - -class StdioMCPBridge: - """ - Bridge that converts stdio-based MCP servers to HTTP endpoints. - - This bridge starts a stdio MCP server process and provides HTTP endpoints - that translate HTTP requests to JSON-RPC over stdio and back. - """ - - def __init__(self, server_config: dict, port: int, dev_mode: bool = False): - self.server_config = server_config - self.port = port - self.dev_mode = dev_mode - - # Process management - self.process: Optional[asyncio.subprocess.Process] = None - self.stdin_writer: Optional[asyncio.StreamWriter] = None - self.stdout_reader: Optional[asyncio.StreamReader] = None - self.stderr_reader: Optional[asyncio.StreamReader] = None - - # Request correlation - self.pending_requests: Dict[str, PendingRequest] = {} - self.request_timeout = 30.0 # seconds - - # Server state - self.initialized = False - self.running = False - self.app: Optional[FastAPI] = None - self.server_task: Optional[asyncio.Task] = None - self.stdio_reader_task: Optional[asyncio.Task] = None - - # Request ID counter for JSON-RPC - self._request_id_counter = 0 - - def _get_next_request_id(self) -> int: - """Get next request ID for JSON-RPC""" - self._request_id_counter += 1 - return self._request_id_counter - - async def start(self) -> bool: - """Start the stdio MCP server and HTTP bridge""" - try: - if self.dev_mode: - logger.debug( - f"Starting stdio MCP bridge for {self.server_config.get('alias', 'unknown')} " - f"on port {self.port}" - ) - - # Start the stdio MCP server process - if not await self._start_stdio_process(): - return False - - # Start stdio response reader BEFORE initializing MCP session - self.running = True - self.stdio_reader_task = asyncio.create_task(self._read_stdio_responses()) - - # Give the reader task a moment to start - await asyncio.sleep(0.1) - - # Initialize MCP session - if not await self._initialize_mcp_session(): - await self.stop() - return False - - # Setup HTTP server - self._setup_http_server() - - # Start HTTP server - if self.app is None: - raise RuntimeError("HTTP server not properly initialized") - - config = uvicorn.Config( - app=self.app, - host="localhost", - port=self.port, - log_level="error" if not self.dev_mode else "info", - access_log=self.dev_mode, - ws="websockets-sansio", - ) - - server = uvicorn.Server(config) - self.server_task = asyncio.create_task(server.serve()) - - if self.dev_mode: - logger.debug( - f"Stdio MCP bridge started successfully on port {self.port}" - ) - - return True - - except Exception as e: - logger.error(f"Failed to start stdio MCP bridge: {e}") - await self.stop() - return False - - async def stop(self) -> None: - """Stop the bridge and cleanup resources""" - if self.dev_mode: - logger.debug("Stopping stdio MCP bridge...") - - self.running = False - - # Cancel pending requests - for request_id, pending in self.pending_requests.items(): - if not pending.future.done(): - pending.future.set_exception(Exception("Bridge shutting down")) - self.pending_requests.clear() - - # Stop HTTP server - if self.server_task and not self.server_task.done(): - self.server_task.cancel() - try: - await self.server_task - except asyncio.CancelledError: - pass - - # Stop stdio reader - if self.stdio_reader_task and not self.stdio_reader_task.done(): - self.stdio_reader_task.cancel() - try: - await self.stdio_reader_task - except asyncio.CancelledError: - pass - - # Close stdio streams - if self.stdin_writer: - self.stdin_writer.close() - await self.stdin_writer.wait_closed() - - # Terminate process - if self.process: - try: - self.process.terminate() - try: - await asyncio.wait_for( - asyncio.create_task(self._wait_for_process()), timeout=5.0 - ) - except asyncio.TimeoutError: - self.process.kill() - await asyncio.create_task(self._wait_for_process()) - except Exception as e: - logger.error(f"Error stopping process: {e}") - - if self.dev_mode: - logger.debug("Stdio MCP bridge stopped") - - async def _wait_for_process(self): - """Wait for process to terminate""" - if self.process: - await self.process.wait() - - async def health_check(self) -> bool: - """Check if bridge and stdio process are healthy""" - if not self.running or not self.process: - return False - - # Check if process is still running - if self.process.returncode is not None: - return False - - # Try a simple tools/list request to verify communication - try: - await asyncio.wait_for( - self._send_stdio_request("tools/list", {}), timeout=5.0 - ) - return True - except Exception: - return False - - async def _start_stdio_process(self) -> bool: - """Start the stdio MCP server process""" - try: - run_command = self.server_config.get("run", "") - if not run_command: - raise ValueError("No run command specified in server config") - - working_dir = self.server_config.get("working_dir", ".") - env = os.environ.copy() - env.update(self.server_config.get("environment", {})) - - if self.dev_mode: - logger.debug(f"Starting process: {run_command}") - logger.debug(f"Working directory: {working_dir}") - - # Start process - self.process = await asyncio.create_subprocess_shell( - run_command, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=working_dir, - env=env, - ) - - if ( - self.process.stdin is None - or self.process.stdout is None - or self.process.stderr is None - ): - raise RuntimeError("Failed to create stdio pipes for process") - - self.stdin_writer = self.process.stdin - self.stdout_reader = self.process.stdout - self.stderr_reader = self.process.stderr - - # Give process time to start - await asyncio.sleep(1.0) - - # Check if process started successfully - if self.process.returncode is not None: - stderr_output = "" - if self.stderr_reader: - try: - stderr_data = await asyncio.wait_for( - self.stderr_reader.read(1024), timeout=1.0 - ) - stderr_output = stderr_data.decode("utf-8", errors="ignore") - except asyncio.TimeoutError: - pass - - raise RuntimeError( - f"Process failed to start. Exit code: {self.process.returncode}. Stderr: {stderr_output}" - ) - - return True - - except Exception as e: - logger.error(f"Failed to start stdio process: {e}") - return False - - async def _initialize_mcp_session(self) -> bool: - """Initialize MCP session with handshake""" - try: - if self.dev_mode: - logger.debug("Initializing MCP session...") - - # Send initialize request - init_params = { - "protocolVersion": "2024-11-05", - "capabilities": {"roots": {"listChanged": True}}, - "clientInfo": {"name": "agentfield-stdio-bridge", "version": "1.0.0"}, - } - - response = await self._send_stdio_request("initialize", init_params) - - if "error" in response: - raise RuntimeError(f"Initialize failed: {response['error']}") - - # Send initialized notification (no response expected) - await self._send_stdio_notification("notifications/initialized", {}) - - self.initialized = True - - if self.dev_mode: - logger.debug("MCP session initialized successfully") - - return True - - except Exception as e: - logger.error(f"Failed to initialize MCP session: {e}") - return False - - def _setup_http_server(self) -> None: - """Setup FastAPI HTTP server with MCP endpoints""" - - @asynccontextmanager - async def lifespan(app: FastAPI): - # Startup - yield - # Shutdown - await self.stop() - - self.app = FastAPI( - title="MCP Stdio Bridge", - description="HTTP bridge for stdio-based MCP servers", - lifespan=lifespan, - ) - - @self.app.get("/health") - async def health_endpoint(): - """Health check endpoint""" - is_healthy = await self.health_check() - if is_healthy: - return {"status": "healthy", "bridge": "running", "process": "running"} - else: - raise HTTPException( - status_code=503, detail="Bridge or process not healthy" - ) - - @self.app.post("/mcp/tools/list") - async def list_tools_endpoint(): - """List available tools from stdio MCP server""" - try: - response = await self._handle_list_tools({}) - return response - except Exception as e: - logger.error(f"Error listing tools: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - @self.app.post("/mcp/tools/call") - async def call_tool_endpoint(request: dict): - """Call a specific tool on stdio MCP server""" - try: - response = await self._handle_call_tool(request) - return response - except Exception as e: - logger.error(f"Error calling tool: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - # Also support the standard MCP v1 endpoint format - @self.app.post("/mcp/v1") - async def mcp_v1_endpoint(request: dict): - """Standard MCP v1 JSON-RPC endpoint""" - try: - method = request.get("method", "") - params = request.get("params", {}) - - if method == "tools/list": - result = await self._handle_list_tools(params) - return { - "jsonrpc": "2.0", - "id": request.get("id", 1), - "result": result, - } - elif method == "tools/call": - result = await self._handle_call_tool(params) - return { - "jsonrpc": "2.0", - "id": request.get("id", 1), - "result": result, - } - else: - raise HTTPException( - status_code=400, detail=f"Unsupported method: {method}" - ) - - except Exception as e: - logger.error(f"Error in MCP v1 endpoint: {e}") - return { - "jsonrpc": "2.0", - "id": request.get("id", 1), - "error": {"code": -32603, "message": str(e)}, - } - - async def _handle_list_tools(self, request: dict) -> dict: - """Handle tools/list request""" - try: - response = await self._send_stdio_request("tools/list", {}) - - if "error" in response: - raise RuntimeError(f"Tools list failed: {response['error']}") - - result = response.get("result", {}) - tools = result.get("tools", []) - - return {"tools": tools} - - except Exception as e: - logger.error(f"Failed to list tools: {e}") - raise - - async def _handle_call_tool(self, request: dict) -> dict: - """Handle tools/call request""" - try: - tool_name = request.get("name") - arguments = request.get("arguments", {}) - - if not tool_name: - raise ValueError("Tool name is required") - - params = {"name": tool_name, "arguments": arguments} - - response = await self._send_stdio_request("tools/call", params) - - if "error" in response: - raise RuntimeError(f"Tool call failed: {response['error']}") - - return response.get("result", {}) - - except Exception as e: - logger.error(f"Failed to call tool: {e}") - raise - - async def _send_stdio_request(self, method: str, params: dict) -> dict: - """Send JSON-RPC request to stdio process and wait for response""" - if not self.stdin_writer: - raise RuntimeError("Stdio process not initialized") - - request_id = self._get_next_request_id() - - request = { - "jsonrpc": "2.0", - "id": request_id, - "method": method, - "params": params, - } - - # Create future for response - future = asyncio.Future() - self.pending_requests[str(request_id)] = PendingRequest( - future=future, timestamp=asyncio.get_event_loop().time() - ) - - try: - # Send request - request_json = json.dumps(request) + "\n" - self.stdin_writer.write(request_json.encode("utf-8")) - await self.stdin_writer.drain() - - if self.dev_mode: - logger.debug(f"Sent request: {method} (id: {request_id})") - - # Wait for response with timeout - response = await asyncio.wait_for(future, timeout=self.request_timeout) - return response - - except asyncio.TimeoutError: - # Clean up pending request - self.pending_requests.pop(str(request_id), None) - raise RuntimeError(f"Request timeout for {method}") - except Exception as e: - # Clean up pending request - self.pending_requests.pop(str(request_id), None) - raise RuntimeError(f"Request failed for {method}: {e}") - - async def _send_stdio_notification(self, method: str, params: dict) -> None: - """Send JSON-RPC notification to stdio process (no response expected)""" - if not self.stdin_writer: - raise RuntimeError("Stdio process not initialized") - - notification = {"jsonrpc": "2.0", "method": method, "params": params} - - notification_json = json.dumps(notification) + "\n" - self.stdin_writer.write(notification_json.encode("utf-8")) - await self.stdin_writer.drain() - - if self.dev_mode: - logger.debug(f"Sent notification: {method}") - - async def _read_stdio_responses(self) -> None: - """Read responses from stdio process and correlate with pending requests""" - if not self.stdout_reader: - return - - try: - while self.running: - try: - # Read line from stdout - line = await asyncio.wait_for( - self.stdout_reader.readline(), timeout=1.0 - ) - - if not line: - # EOF reached - break - - line_str = line.decode("utf-8").strip() - if not line_str: - continue - - # Parse JSON response - try: - response = json.loads(line_str) - except json.JSONDecodeError: - if self.dev_mode: - logger.warning( - f"Failed to parse JSON response: {line_str[:100]}..." - ) - continue - - # Handle response - await self._handle_stdio_response(response) - - except asyncio.TimeoutError: - # Check for expired requests - await self._cleanup_expired_requests() - continue - except Exception as e: - if self.running: - logger.error(f"Error reading stdio response: {e}") - break - - except Exception as e: - if self.running: - logger.error(f"Stdio reader task failed: {e}") - finally: - # Cancel all pending requests - for pending in self.pending_requests.values(): - if not pending.future.done(): - pending.future.set_exception(Exception("Stdio reader stopped")) - self.pending_requests.clear() - - async def _handle_stdio_response(self, response: dict) -> None: - """Handle a response from stdio process""" - response_id = response.get("id") - - if response_id is None: - # This might be a notification, ignore - return - - request_id = str(response_id) - pending = self.pending_requests.pop(request_id, None) - - if pending and not pending.future.done(): - pending.future.set_result(response) - - if self.dev_mode: - logger.debug(f"Received response for request {request_id}") - elif self.dev_mode: - logger.warning(f"Received response for unknown request {request_id}") - - async def _cleanup_expired_requests(self) -> None: - """Clean up expired pending requests""" - current_time = asyncio.get_event_loop().time() - expired_ids = [] - - for request_id, pending in self.pending_requests.items(): - if current_time - pending.timestamp > self.request_timeout: - expired_ids.append(request_id) - if not pending.future.done(): - pending.future.set_exception( - asyncio.TimeoutError("Request expired") - ) - - for request_id in expired_ids: - self.pending_requests.pop(request_id, None) - - if expired_ids and self.dev_mode: - logger.warning(f"Cleaned up {len(expired_ids)} expired requests") diff --git a/sdk/python/agentfield/types.py b/sdk/python/agentfield/types.py index 23c00c367..48d6f8640 100644 --- a/sdk/python/agentfield/types.py +++ b/sdk/python/agentfield/types.py @@ -13,35 +13,17 @@ class AgentStatus(str, Enum): OFFLINE = "offline" -@dataclass -class MCPServerHealth: - """MCP server health information for heartbeat reporting""" - - alias: str - status: str - tool_count: int = 0 - port: Optional[int] = None - process_id: Optional[int] = None - started_at: Optional[str] = None - last_health_check: Optional[str] = None - - def to_dict(self) -> Dict[str, Any]: - return asdict(self) - - @dataclass class HeartbeatData: - """Enhanced heartbeat data with status and MCP information""" + """Enhanced heartbeat data with status information""" status: AgentStatus - mcp_servers: List[MCPServerHealth] timestamp: str version: str = "" def to_dict(self) -> Dict[str, Any]: return { "status": self.status.value, - "mcp_servers": [server.to_dict() for server in self.mcp_servers], "timestamp": self.timestamp, "version": self.version, } diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 0185c2488..4a2d6a909 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -82,21 +82,15 @@ markers = [ "contract: API/interface stability tests", "unit: isolated unit tests", "integration: tests that can touch network/services", - "mcp: tests that exercise MCP/network interactions", "harness_live: live tests that invoke real coding agents (claude, codex, opencode) — costs real money", "httpx_mock: pytest-httpx marker for configuring mock behavior" ] -addopts = "-ra -q -m \"not mcp and not harness_live\" --strict-markers --strict-config --cov=agentfield.client --cov=agentfield.agent_field_handler --cov=agentfield.execution_context --cov=agentfield.execution_state --cov=agentfield.memory --cov=agentfield.rate_limiter --cov=agentfield.result_cache --cov-report=term-missing:skip-covered" +addopts = "-ra -q -m \"not harness_live\" --strict-markers --strict-config --cov=agentfield.client --cov=agentfield.agent_field_handler --cov=agentfield.execution_context --cov=agentfield.execution_state --cov=agentfield.memory --cov=agentfield.rate_limiter --cov=agentfield.result_cache --cov-report=term-missing:skip-covered" asyncio_mode = "auto" [tool.coverage.run] source = ["agentfield"] omit = [ - "agentfield/agent_mcp.py", - "agentfield/dynamic_skills.py", - "agentfield/mcp_client.py", - "agentfield/mcp_manager.py", - "agentfield/mcp_stdio_bridge.py", "agentfield/logger.py", "agentfield/types.py", ] diff --git a/sdk/python/tests/README.md b/sdk/python/tests/README.md index 60c31c3f3..ede4e62ef 100644 --- a/sdk/python/tests/README.md +++ b/sdk/python/tests/README.md @@ -3,7 +3,7 @@ - `tests/` contains the supported open-source regression suite. These tests run in CI and cover the SDK surface that ships publicly. - `legacy_tests/` preserves the original internal suite. Those tests exercise - tightly coupled infrastructure (MCP, remote services, etc.) and are excluded + tightly coupled infrastructure (remote services, etc.) and are excluded from the default run. Execute them manually via `pytest legacy_tests` when the required services are available. diff --git a/sdk/python/tests/conftest.py b/sdk/python/tests/conftest.py index 942c69d9a..dcfcb9dba 100644 --- a/sdk/python/tests/conftest.py +++ b/sdk/python/tests/conftest.py @@ -51,7 +51,7 @@ def _network_allowed(node: "pytest.Node") -> bool: return bool( - node.get_closest_marker("integration") or node.get_closest_marker("mcp") + node.get_closest_marker("integration") ) diff --git a/sdk/python/tests/helpers.py b/sdk/python/tests/helpers.py index 5a257a4fb..74d0db7ae 100644 --- a/sdk/python/tests/helpers.py +++ b/sdk/python/tests/helpers.py @@ -101,11 +101,6 @@ class StubAgent: async_config: Any = None client: DummyAgentFieldClient = field(default_factory=DummyAgentFieldClient) did_manager: Any = None - mcp_handler: Any = field( - default_factory=lambda: type( - "MCP", (), {"_get_mcp_server_health": lambda self: []} - )() - ) reasoners: List[Dict[str, Any]] = field(default_factory=list) skills: List[Dict[str, Any]] = field(default_factory=list) agent_tags: List[str] = field(default_factory=list) @@ -380,31 +375,6 @@ def decorator(func): return decorator - class _FakeAgentMCP: - def __init__(self, agent_instance: Any): - self.agent = agent_instance - - def _detect_agent_directory(self) -> str: - return "." - - def _get_mcp_server_health(self) -> Dict[str, Any]: - return {} - - class _FakeMCPManager: - def __init__(self, *args, **kwargs): - self._status: Dict[str, Any] = {} - - def get_all_status(self) -> Dict[str, Any]: - return self._status - - class _FakeMCPClientRegistry: - def __init__(self, *args, **kwargs): - pass - - class _FakeDynamicSkillManager: - def __init__(self, *args, **kwargs): - pass - class _FakeDIDManager: def __init__(self, agentfield_server: str, node: str, api_key: Optional[str] = None): self.agentfield_server = agentfield_server @@ -524,12 +494,6 @@ async def _noop_fire_and_forget_update(self, payload: Dict[str, Any]) -> None: monkeypatch.setattr("agentfield.agent.AgentFieldClient", _agentfield_client_factory) monkeypatch.setattr("agentfield.agent.MemoryClient", _FakeMemoryClient) monkeypatch.setattr("agentfield.agent.MemoryEventClient", _FakeMemoryEventClient) - monkeypatch.setattr("agentfield.agent.AgentMCP", _FakeAgentMCP) - monkeypatch.setattr("agentfield.agent.MCPManager", _FakeMCPManager) - monkeypatch.setattr("agentfield.agent.MCPClientRegistry", _FakeMCPClientRegistry) - monkeypatch.setattr( - "agentfield.agent.DynamicMCPSkillManager", _FakeDynamicSkillManager - ) monkeypatch.setattr("agentfield.agent.DIDManager", _FakeDIDManager) monkeypatch.setattr("agentfield.agent.VCGenerator", _FakeVCGenerator) monkeypatch.setattr( diff --git a/sdk/python/tests/test_agent_field_handler.py b/sdk/python/tests/test_agent_field_handler.py index a348f2a9b..d4e5d3006 100644 --- a/sdk/python/tests/test_agent_field_handler.py +++ b/sdk/python/tests/test_agent_field_handler.py @@ -282,9 +282,6 @@ def fake_worker(interval): async def test_enhanced_heartbeat_and_shutdown(monkeypatch): agent = StubAgent() agent.client = DummyAgentFieldClient() - agent.mcp_handler = type( - "MCP", (), {"_get_mcp_server_health": lambda self: ["mcp"]} - )() agent.dev_mode = True agentfield = AgentFieldHandler(agent) diff --git a/sdk/python/tests/test_agent_server.py b/sdk/python/tests/test_agent_server.py index dae901b4b..91ab9ab7e 100644 --- a/sdk/python/tests/test_agent_server.py +++ b/sdk/python/tests/test_agent_server.py @@ -31,23 +31,6 @@ def make_agent_app(**overrides): "client", SimpleNamespace(notify_graceful_shutdown_sync=lambda node_id: True), ) - app.mcp_manager = overrides.get( - "mcp_manager", - type( - "MCPManager", - (), - { - "get_all_status": lambda self: { - "test": { - "status": "running", - "port": 1234, - "process": type("Proc", (), {"pid": 42})(), - } - } - }, - )(), - ) - app.mcp_client_registry = overrides.get("mcp_client_registry", None) app.dev_mode = overrides.get("dev_mode", False) app.agentfield_server = overrides.get("agentfield_server", "http://agentfield") app.base_url = overrides.get("base_url", "http://localhost:8001") @@ -100,62 +83,6 @@ async def test_health_endpoint_basic(): assert "timestamp" in data -@pytest.mark.asyncio -async def test_health_endpoint_with_mcp_servers(): - app = make_agent_app() - _setup_server(app) - resp = await _get(app, "/health") - data = resp.json() - assert data["mcp_servers"]["running"] == 1 - assert data["mcp_servers"]["total"] == 1 - assert data["mcp_servers"]["failed"] == 0 - assert data["mcp_servers"]["servers"]["test"]["pid"] == 42 - - -@pytest.mark.asyncio -async def test_health_endpoint_no_mcp_manager(): - app = make_agent_app(mcp_manager=None) - _setup_server(app) - resp = await _get(app, "/health") - data = resp.json() - assert data["status"] == "healthy" - assert "mcp_servers" not in data - - -@pytest.mark.asyncio -async def test_health_degraded_when_mcp_failed(): - mgr = type( - "M", - (), - { - "get_all_status": lambda self: { - "a": {"status": "running", "port": 1, "process": None}, - "b": {"status": "failed", "port": 2, "process": None}, - } - }, - )() - app = make_agent_app(mcp_manager=mgr) - _setup_server(app) - resp = await _get(app, "/health") - data = resp.json() - assert data["status"] == "degraded" - - -@pytest.mark.asyncio -async def test_health_mcp_error_handling(): - """MCP manager raising an exception should not crash health endpoint.""" - - class BadMgr: - def get_all_status(self): - raise RuntimeError("boom") - - app = make_agent_app(mcp_manager=BadMgr(), dev_mode=True) - _setup_server(app) - resp = await _get(app, "/health") - data = resp.json() - assert data["mcp_servers"]["error"] == "Failed to get MCP status" - - # --------------------------------------------------------------------------- # Reasoners / Skills listing # --------------------------------------------------------------------------- @@ -321,220 +248,6 @@ def num_threads(self): assert resp.json()["status"] == "stopping" -# --------------------------------------------------------------------------- -# MCP status / health endpoints -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mcp_status_no_manager(): - app = make_agent_app(mcp_manager=None) - _setup_server(app) - resp = await _get(app, "/mcp/status") - data = resp.json() - assert data["total"] == 0 - assert "error" in data - - -@pytest.mark.asyncio -async def test_mcp_health_no_manager(): - app = make_agent_app(mcp_manager=None) - _setup_server(app) - resp = await _get(app, "/health/mcp") - data = resp.json() - assert data["summary"]["total_servers"] == 0 - assert data["servers"] == [] - - -# --------------------------------------------------------------------------- -# MCP start / stop / restart -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mcp_start_stop_restart(): - app = make_agent_app() - - class StubMCPManager: - async def start_server_by_alias(self, alias): - self.last_start = alias - return True - - def stop_server(self, alias): - self.last_stop = alias - return True - - async def restart_server(self, alias): - self.last_restart = alias - return True - - def get_server_status(self, alias): - return {"status": "running"} - - def get_all_status(self): - return {} - - manager = StubMCPManager() - app.mcp_manager = manager - _setup_server(app) - - start = await _post(app, "/mcp/foo/start") - stop = await _post(app, "/mcp/foo/stop") - restart = await _post(app, "/mcp/foo/restart") - - assert start.json()["success"] is True - assert stop.json()["success"] is True - assert restart.json()["success"] is True - assert manager.last_start == "foo" - assert manager.last_stop == "foo" - assert manager.last_restart == "foo" - - -@pytest.mark.asyncio -async def test_mcp_start_no_manager(): - app = make_agent_app(mcp_manager=None) - _setup_server(app) - resp = await _post(app, "/mcp/test/start") - assert resp.json()["success"] is False - - -@pytest.mark.asyncio -async def test_mcp_stop_no_manager(): - app = make_agent_app(mcp_manager=None) - _setup_server(app) - resp = await _post(app, "/mcp/test/stop") - assert resp.json()["success"] is False - - -@pytest.mark.asyncio -async def test_mcp_restart_no_manager(): - app = make_agent_app(mcp_manager=None) - _setup_server(app) - resp = await _post(app, "/mcp/test/restart") - assert resp.json()["success"] is False - - -@pytest.mark.asyncio -async def test_mcp_start_failure(): - class FailMgr: - async def start_server_by_alias(self, alias): - return False - - def get_all_status(self): - return {} - - app = make_agent_app(mcp_manager=FailMgr()) - _setup_server(app) - resp = await _post(app, "/mcp/bar/start") - data = resp.json() - assert data["success"] is False - assert "Failed" in data["error"] - - -@pytest.mark.asyncio -async def test_mcp_start_exception(): - class ExcMgr: - async def start_server_by_alias(self, alias): - raise RuntimeError("boom") - - def get_all_status(self): - return {} - - app = make_agent_app(mcp_manager=ExcMgr()) - _setup_server(app) - resp = await _post(app, "/mcp/bar/start") - data = resp.json() - assert data["success"] is False - assert "boom" in data["error"] - - -@pytest.mark.asyncio -async def test_mcp_stop_failure(): - class FailMgr: - def stop_server(self, alias): - return False - - def get_all_status(self): - return {} - - app = make_agent_app(mcp_manager=FailMgr()) - _setup_server(app) - resp = await _post(app, "/mcp/bar/stop") - assert resp.json()["success"] is False - - -@pytest.mark.asyncio -async def test_mcp_stop_exception(): - class ExcMgr: - def stop_server(self, alias): - raise RuntimeError("fail") - - def get_all_status(self): - return {} - - app = make_agent_app(mcp_manager=ExcMgr()) - _setup_server(app) - resp = await _post(app, "/mcp/bar/stop") - assert resp.json()["success"] is False - assert "fail" in resp.json()["error"] - - -@pytest.mark.asyncio -async def test_mcp_restart_failure(): - class FailMgr: - async def restart_server(self, alias): - return False - - def get_all_status(self): - return {} - - app = make_agent_app(mcp_manager=FailMgr()) - _setup_server(app) - resp = await _post(app, "/mcp/bar/restart") - assert resp.json()["success"] is False - - -@pytest.mark.asyncio -async def test_mcp_restart_exception(): - class ExcMgr: - async def restart_server(self, alias): - raise RuntimeError("err") - - def get_all_status(self): - return {} - - app = make_agent_app(mcp_manager=ExcMgr()) - _setup_server(app) - resp = await _post(app, "/mcp/bar/restart") - assert "err" in resp.json()["error"] - - -# --------------------------------------------------------------------------- -# MCP server tools endpoint -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mcp_server_tools_no_registry(): - app = make_agent_app(mcp_client_registry=None) - _setup_server(app) - resp = await _get(app, "/mcp/servers/test/tools") - data = resp.json() - assert data["tools"] == [] - assert "error" in data - - -@pytest.mark.asyncio -async def test_mcp_server_tools_client_not_found(): - registry = SimpleNamespace(get_client=lambda alias: None) - app = make_agent_app(mcp_client_registry=registry) - _setup_server(app) - resp = await _get(app, "/mcp/servers/missing/tools") - data = resp.json() - assert data["tools"] == [] - assert "not found" in data["error"] - - # --------------------------------------------------------------------------- # Approval webhook # --------------------------------------------------------------------------- diff --git a/sdk/python/tests/test_agent_server_extended.py b/sdk/python/tests/test_agent_server_extended.py index 12ca0d5b5..f0400a802 100644 --- a/sdk/python/tests/test_agent_server_extended.py +++ b/sdk/python/tests/test_agent_server_extended.py @@ -1,12 +1,8 @@ """Extended tests for AgentServer routes. Covers paths not exercised by test_agent_server.py: -- Health endpoint without MCP manager (no mcp_manager attribute) -- Health endpoint when MCP manager raises an exception -- Health endpoint shows degraded status when failed servers present +- Health endpoint - /info endpoint returns correct schema -- /mcp/status endpoint with and without mcp_manager -- MCP start/stop/restart when mcp_manager is None (guard paths) - /status endpoint fallback when psutil is unavailable - /reasoners and /skills discovery endpoints - Malformed JSON body to /shutdown falls back gracefully @@ -30,7 +26,7 @@ # --------------------------------------------------------------------------- -def _make_app(*, mcp_manager=None, dev_mode=False, base_url="http://agent.local:8000"): +def _make_app(*, dev_mode=False, base_url="http://agent.local:8000"): """Minimal FastAPI application wired like a real Agent.""" app = FastAPI() app.node_id = "test-node" @@ -38,7 +34,6 @@ def _make_app(*, mcp_manager=None, dev_mode=False, base_url="http://agent.local: app.base_url = base_url app.reasoners = [{"id": "do_something", "description": "Does something"}] app.skills = [{"id": "skill_a"}] - app.mcp_manager = mcp_manager app.dev_mode = dev_mode app.agentfield_server = "http://agentfield" app.client = SimpleNamespace(notify_graceful_shutdown_sync=lambda node_id: True) @@ -46,36 +41,6 @@ def _make_app(*, mcp_manager=None, dev_mode=False, base_url="http://agent.local: return app -def _make_mcp_manager(*, status="running", fail=False): - """Return a stub MCP manager.""" - - class _Manager: - def get_all_status(self): - if fail: - raise RuntimeError("MCP gone wrong") - return { - "server-a": { - "status": status, - "port": 5001, - "process": SimpleNamespace(pid=1234), - } - } - - def get_server_status(self, alias): - return {"status": status} - - async def start_server_by_alias(self, alias): - return True - - def stop_server(self, alias): - return True - - async def restart_server(self, alias): - return True - - return _Manager() - - async def _get(app, path): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://test" @@ -91,13 +56,13 @@ async def _post(app, path, **kwargs): # --------------------------------------------------------------------------- -# /health — no mcp_manager +# /health # --------------------------------------------------------------------------- @pytest.mark.asyncio -async def test_health_without_mcp_manager(): - app = _make_app(mcp_manager=None) +async def test_health_endpoint(): + app = _make_app() AgentServer(app).setup_agentfield_routes() resp = await _get(app, "/health") @@ -106,61 +71,6 @@ async def test_health_without_mcp_manager(): data = resp.json() assert data["status"] == "healthy" assert data["node_id"] == "test-node" - assert "mcp_servers" not in data - - -# --------------------------------------------------------------------------- -# /health — mcp_manager present, healthy -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_health_with_running_mcp_manager(): - app = _make_app(mcp_manager=_make_mcp_manager(status="running")) - AgentServer(app).setup_agentfield_routes() - - resp = await _get(app, "/health") - - data = resp.json() - assert data["status"] == "healthy" - assert data["mcp_servers"]["running"] == 1 - assert data["mcp_servers"]["failed"] == 0 - - -# --------------------------------------------------------------------------- -# /health — mcp_manager reports failed servers → degraded -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_health_degraded_when_mcp_server_failed(): - app = _make_app(mcp_manager=_make_mcp_manager(status="failed")) - AgentServer(app).setup_agentfield_routes() - - resp = await _get(app, "/health") - - data = resp.json() - assert data["status"] == "degraded" - assert data["mcp_servers"]["failed"] == 1 - - -# --------------------------------------------------------------------------- -# /health — mcp_manager raises → error dict in mcp_servers -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_health_mcp_manager_exception_returns_error_dict(): - app = _make_app(mcp_manager=_make_mcp_manager(fail=True), dev_mode=True) - AgentServer(app).setup_agentfield_routes() - - resp = await _get(app, "/health") - - data = resp.json() - # Should still return 200 with partial health info - assert resp.status_code == 200 - assert "mcp_servers" in data - assert "error" in data["mcp_servers"] or data["mcp_servers"]["total"] == 0 # --------------------------------------------------------------------------- @@ -227,76 +137,6 @@ async def test_info_endpoint_returns_node_metadata(): assert "registered_at" in data -# --------------------------------------------------------------------------- -# /mcp/status -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mcp_status_without_manager(): - app = _make_app(mcp_manager=None) - AgentServer(app).setup_agentfield_routes() - - resp = await _get(app, "/mcp/status") - - assert resp.status_code == 200 - data = resp.json() - assert "error" in data - assert data["total"] == 0 - - -@pytest.mark.asyncio -async def test_mcp_status_with_manager_returns_disabled_message(): - """The route returns disabled message even when mcp_manager is present.""" - app = _make_app(mcp_manager=_make_mcp_manager()) - AgentServer(app).setup_agentfield_routes() - - resp = await _get(app, "/mcp/status") - - data = resp.json() - assert resp.status_code == 200 - assert data["total"] == 0 - - -# --------------------------------------------------------------------------- -# /mcp/{alias}/start — no manager guard -# --------------------------------------------------------------------------- - - -@pytest.mark.asyncio -async def test_mcp_start_without_manager_returns_error(): - app = _make_app(mcp_manager=None) - AgentServer(app).setup_agentfield_routes() - - resp = await _post(app, "/mcp/server-x/start") - - data = resp.json() - assert data["success"] is False - assert "not available" in data["error"].lower() or "mcp" in data["error"].lower() - - -@pytest.mark.asyncio -async def test_mcp_stop_without_manager_returns_error(): - app = _make_app(mcp_manager=None) - AgentServer(app).setup_agentfield_routes() - - resp = await _post(app, "/mcp/server-x/stop") - - data = resp.json() - assert data["success"] is False - - -@pytest.mark.asyncio -async def test_mcp_restart_without_manager_returns_error(): - app = _make_app(mcp_manager=None) - AgentServer(app).setup_agentfield_routes() - - resp = await _post(app, "/mcp/server-x/restart") - - data = resp.json() - assert data["success"] is False - - # --------------------------------------------------------------------------- # /shutdown — graceful path sets flag # --------------------------------------------------------------------------- diff --git a/sdk/python/tests/test_agent_utils.py b/sdk/python/tests/test_agent_utils.py index 50b17bd07..d4ae7a190 100644 --- a/sdk/python/tests/test_agent_utils.py +++ b/sdk/python/tests/test_agent_utils.py @@ -43,7 +43,7 @@ def test_generate_skill_name_and_schema(): "required": ["q"], } } - Model = AgentUtils.create_input_schema_from_mcp_tool("skill", tool) + Model = AgentUtils.create_input_schema_from_tool("skill", tool) inst = Model(q="text") assert inst.q == "text" @@ -118,7 +118,7 @@ def test_detect_input_type_additional_branches(tmp_path): def test_create_input_schema_handles_empty_properties(): - Model = AgentUtils.create_input_schema_from_mcp_tool("skill", {"input_schema": {}}) + Model = AgentUtils.create_input_schema_from_tool("skill", {"input_schema": {}}) instance = Model() assert hasattr(instance, "data") assert instance.data is None diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 8f0fad199..a7629123b 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -272,7 +272,7 @@ def on_request(method, url, **kwargs): ) client = AgentFieldClient(base_url="http://example.com") - heartbeat = HeartbeatData(status=AgentStatus.READY, mcp_servers=[], timestamp="now") + heartbeat = HeartbeatData(status=AgentStatus.READY, timestamp="now") assert await client.send_enhanced_heartbeat("node", heartbeat) is True assert calls and calls[0][1].endswith("/nodes/node/heartbeat") @@ -295,7 +295,7 @@ def fake_post(url, json=None, headers=None, timeout=None): monkeypatch.setattr(client_mod.requests, "post", fake_post) client = AgentFieldClient(base_url="http://example.com") - heartbeat = HeartbeatData(status=AgentStatus.READY, mcp_servers=[], timestamp="now") + heartbeat = HeartbeatData(status=AgentStatus.READY, timestamp="now") assert client.send_enhanced_heartbeat_sync("node", heartbeat) is True assert client.notify_graceful_shutdown_sync("node") is True diff --git a/sdk/python/tests/test_client_auth.py b/sdk/python/tests/test_client_auth.py index deb2423dd..4c5db1e45 100644 --- a/sdk/python/tests/test_client_auth.py +++ b/sdk/python/tests/test_client_auth.py @@ -333,7 +333,7 @@ def on_request(method, url, **kwargs): from agentfield.types import AgentStatus, HeartbeatData client = AgentFieldClient(base_url="http://example.com", api_key="heartbeat-key") - heartbeat = HeartbeatData(status=AgentStatus.READY, mcp_servers=[], timestamp="now") + heartbeat = HeartbeatData(status=AgentStatus.READY, timestamp="now") result = await client.send_enhanced_heartbeat("node-1", heartbeat) @@ -360,7 +360,7 @@ def raise_for_status(self): from agentfield.types import AgentStatus, HeartbeatData client = AgentFieldClient(base_url="http://example.com", api_key="heartbeat-key") - heartbeat = HeartbeatData(status=AgentStatus.READY, mcp_servers=[], timestamp="now") + heartbeat = HeartbeatData(status=AgentStatus.READY, timestamp="now") result = client.send_enhanced_heartbeat_sync("node-1", heartbeat) diff --git a/sdk/python/tests/test_client_lifecycle.py b/sdk/python/tests/test_client_lifecycle.py index 9b4d859bf..501a60247 100644 --- a/sdk/python/tests/test_client_lifecycle.py +++ b/sdk/python/tests/test_client_lifecycle.py @@ -34,7 +34,7 @@ def ok_post(url, json, headers, timeout): monkeypatch.setattr(client_mod.requests, "post", ok_post) bc = AgentFieldClient(base_url="http://example") - hb = HeartbeatData(status=AgentStatus.READY, mcp_servers=[], timestamp="now") + hb = HeartbeatData(status=AgentStatus.READY, timestamp="now") assert bc.send_enhanced_heartbeat_sync("node1", hb) is True def bad_post(url, json, headers, timeout): diff --git a/sdk/python/tests/test_dynamic_skills.py b/sdk/python/tests/test_dynamic_skills.py deleted file mode 100644 index 47d8762ac..000000000 --- a/sdk/python/tests/test_dynamic_skills.py +++ /dev/null @@ -1,90 +0,0 @@ -from types import SimpleNamespace - -import httpx -import pytest -from fastapi import FastAPI - -from agentfield.dynamic_skills import DynamicMCPSkillManager - - -class StubMCPClient: - def __init__(self, tools, *, result=None): - self._tools = tools - self._result = result or {"echo": "ok"} - self.calls = [] - - async def health_check(self): - return True - - async def list_tools(self): - return self._tools - - async def call_tool(self, name, args): - self.calls.append((name, args)) - return self._result - - -@pytest.mark.asyncio -async def test_dynamic_skill_registration(monkeypatch): - app = FastAPI() - app.node_id = "agent" - app.reasoners = [] - app.skills = [] - app.dev_mode = False - app.mcp_client_registry = SimpleNamespace( - clients={ - "server": StubMCPClient( - tools=[ - { - "name": "Echo", - "description": "Echo tool", - "inputSchema": { - "properties": {"text": {"type": "string"}}, - "required": ["text"], - }, - } - ] - ) - }, - get_client=lambda alias: None, - ) - - def get_client(alias): - return app.mcp_client_registry.clients[alias] - - app.mcp_client_registry.get_client = get_client - - manager = DynamicMCPSkillManager(app) - await manager.discover_and_register_all_skills() - - assert "server_Echo" in manager.registered_skills - assert any(skill["id"] == "server_Echo" for skill in app.skills) - - client_stub = app.mcp_client_registry.get_client("server") - - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://test" - ) as client: - resp = await client.post( - "/skills/server_Echo", - json={"text": "hello"}, - headers={"x-workflow-id": "wf", "x-execution-id": "exec"}, - ) - - assert resp.status_code == 200 - assert client_stub.calls[0][0].lower() == "echo" - assert client_stub.calls[0][1] == {"text": "hello"} - - -@pytest.mark.asyncio -async def test_mcp_registry_absent_succeeds_quickly(): - app = FastAPI() - app.node_id = "agent" - app.reasoners = [] - app.skills = [] - app.dev_mode = False - app.mcp_client_registry = None - - manager = DynamicMCPSkillManager(app) - await manager.discover_and_register_all_skills() - assert manager.registered_skills == {} diff --git a/sdk/python/tests/test_mcp_client.py b/sdk/python/tests/test_mcp_client.py deleted file mode 100644 index a7c12a159..000000000 --- a/sdk/python/tests/test_mcp_client.py +++ /dev/null @@ -1,397 +0,0 @@ -""" -Tests for MCP Client -""" - -import pytest -from unittest.mock import AsyncMock, patch -from aiohttp import ClientSession -from agentfield.mcp_client import MCPClient - - -class TestMCPClientInitialization: - """Test MCP Client initialization""" - - def test_init_basic(self): - """Test basic initialization""" - client = MCPClient("http://localhost:8080", "test-server") - - assert client.base_url == "http://localhost:8080" - assert client.server_alias == "test-server" - assert client.dev_mode is False - assert client.session is None - assert client._is_stdio_bridge is False - - def test_init_with_dev_mode(self): - """Test initialization with dev mode""" - client = MCPClient("http://localhost:8080", "test-server", dev_mode=True) - - assert client.dev_mode is True - - def test_from_port_legacy(self): - """Test legacy from_port constructor""" - client = MCPClient.from_port("test-server", 8080, dev_mode=False) - - assert client.base_url == "http://localhost:8080" - assert client.server_alias == "test-server" - assert client.dev_mode is False - - def test_from_port_with_dev_mode(self): - """Test from_port with dev mode""" - client = MCPClient.from_port("test-server", 9000, dev_mode=True) - - assert client.base_url == "http://localhost:9000" - assert client.dev_mode is True - - -class TestMCPClientSession: - """Test session management""" - - @pytest.mark.asyncio - async def test_ensure_session_creates_new(self): - """Test that _ensure_session creates new session""" - client = MCPClient("http://localhost:8080", "test-server") - - assert client.session is None - - await client._ensure_session() - - assert client.session is not None - assert isinstance(client.session, ClientSession) - - # Cleanup - await client.close() - - @pytest.mark.asyncio - async def test_ensure_session_reuses_existing(self): - """Test that _ensure_session reuses existing session""" - client = MCPClient("http://localhost:8080", "test-server") - - await client._ensure_session() - first_session = client.session - - await client._ensure_session() - second_session = client.session - - assert first_session is second_session - - # Cleanup - await client.close() - - @pytest.mark.asyncio - async def test_close_session(self): - """Test closing session""" - client = MCPClient("http://localhost:8080", "test-server") - - await client._ensure_session() - assert client.session is not None - assert not client.session.closed - - await client.close() - - assert client.session.closed - - @pytest.mark.asyncio - async def test_close_without_session(self): - """Test closing when no session exists""" - client = MCPClient("http://localhost:8080", "test-server") - - # Should not raise error - await client.close() - - assert client.session is None - - -class TestMCPClientHealthCheck: - """Test health check functionality""" - - @pytest.mark.asyncio - async def test_health_check_success(self): - """Test successful health check""" - client = MCPClient("http://localhost:8080", "test-server") - - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "get", return_value=mock_response): - result = await client.health_check() - - assert result is True - - await client.close() - - @pytest.mark.asyncio - async def test_health_check_failure_404(self): - """Test health check with 404 status""" - client = MCPClient("http://localhost:8080", "test-server") - - mock_response = AsyncMock() - mock_response.status = 404 - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "get", return_value=mock_response): - result = await client.health_check() - - assert result is False - - await client.close() - - @pytest.mark.asyncio - async def test_health_check_network_error(self): - """Test health check with network error""" - client = MCPClient("http://localhost:8080", "test-server", dev_mode=True) - - with patch.object( - ClientSession, "get", side_effect=ConnectionError("Connection refused") - ): - result = await client.health_check() - - assert result is False - - await client.close() - - @pytest.mark.asyncio - async def test_health_check_timeout(self): - """Test health check with timeout""" - client = MCPClient("http://localhost:8080", "test-server") - - with patch.object(ClientSession, "get", side_effect=TimeoutError): - result = await client.health_check() - - assert result is False - - await client.close() - - -class TestMCPClientListTools: - """Test list_tools functionality""" - - @pytest.mark.asyncio - async def test_list_tools_direct_http_success(self): - """Test listing tools via direct HTTP (non-stdio)""" - client = MCPClient("http://localhost:8080", "test-server") - client._is_stdio_bridge = False - - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock( - return_value={ - "result": { - "tools": [ - {"name": "tool1", "description": "Test tool 1"}, - {"name": "tool2", "description": "Test tool 2"}, - ] - } - } - ) - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "post", return_value=mock_response): - tools = await client.list_tools() - - assert len(tools) == 2 - assert tools[0]["name"] == "tool1" - assert tools[1]["name"] == "tool2" - - await client.close() - - @pytest.mark.asyncio - async def test_list_tools_stdio_bridge_success(self): - """Test listing tools via stdio bridge""" - client = MCPClient("http://localhost:8080", "test-server") - client._is_stdio_bridge = True - - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock( - return_value={ - "tools": [ - {"name": "bridge_tool1", "description": "Bridge tool 1"}, - {"name": "bridge_tool2", "description": "Bridge tool 2"}, - ] - } - ) - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "post", return_value=mock_response): - tools = await client.list_tools() - - assert len(tools) == 2 - assert tools[0]["name"] == "bridge_tool1" - - await client.close() - - @pytest.mark.asyncio - async def test_list_tools_empty_result(self): - """Test listing tools with empty result""" - client = MCPClient("http://localhost:8080", "test-server") - - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock(return_value={"result": {"tools": []}}) - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "post", return_value=mock_response): - tools = await client.list_tools() - - assert len(tools) == 0 - assert tools == [] - - await client.close() - - @pytest.mark.asyncio - async def test_list_tools_network_error(self): - """Test list_tools with network error""" - client = MCPClient("http://localhost:8080", "test-server", dev_mode=True) - - with patch.object( - ClientSession, "post", side_effect=ConnectionError("Connection refused") - ): - tools = await client.list_tools() - - assert tools == [] - - await client.close() - - @pytest.mark.asyncio - async def test_list_tools_malformed_response(self): - """Test list_tools with malformed JSON response""" - client = MCPClient("http://localhost:8080", "test-server") - - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.json = AsyncMock( - return_value={"error": "malformed"} # Missing 'result' key - ) - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "post", return_value=mock_response): - tools = await client.list_tools() - - assert tools == [] - - await client.close() - - @pytest.mark.asyncio - async def test_list_tools_http_500(self): - """Test list_tools with server error""" - client = MCPClient("http://localhost:8080", "test-server") - - mock_response = AsyncMock() - mock_response.status = 500 - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "post", return_value=mock_response): - tools = await client.list_tools() - - assert tools == [] - - await client.close() - - -class TestMCPClientEdgeCases: - """Test edge cases and error handling""" - - @pytest.mark.asyncio - async def test_multiple_operations_on_same_client(self): - """Test performing multiple operations on same client""" - client = MCPClient("http://localhost:8080", "test-server") - - mock_health_response = AsyncMock() - mock_health_response.status = 200 - mock_health_response.__aenter__.return_value = mock_health_response - mock_health_response.__aexit__.return_value = None - - mock_tools_response = AsyncMock() - mock_tools_response.status = 200 - mock_tools_response.json = AsyncMock( - return_value={"result": {"tools": [{"name": "tool1"}]}} - ) - mock_tools_response.__aenter__.return_value = mock_tools_response - mock_tools_response.__aexit__.return_value = None - - with patch.object(ClientSession, "get", return_value=mock_health_response): - health1 = await client.health_check() - - assert health1 is True - - with patch.object(ClientSession, "post", return_value=mock_tools_response): - tools = await client.list_tools() - - assert len(tools) == 1 - - with patch.object(ClientSession, "get", return_value=mock_health_response): - health2 = await client.health_check() - - assert health2 is True - - await client.close() - - @pytest.mark.asyncio - async def test_operations_after_close(self): - """Test that operations work after close (should recreate session)""" - client = MCPClient("http://localhost:8080", "test-server") - - # First operation - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "get", return_value=mock_response): - result1 = await client.health_check() - - assert result1 is True - - # Close session - await client.close() - assert client.session.closed - - # Second operation should work (creates new session) - with patch.object(ClientSession, "get", return_value=mock_response): - _ = await client.health_check() - - # This will fail if _ensure_session doesn't handle closed sessions - # Current implementation may need fixing for this case - # Tests document the expected behavior - - await client.close() - - def test_client_attributes_immutable(self): - """Test that client attributes are properly set""" - client = MCPClient("http://localhost:8080", "test-server", dev_mode=True) - - # Attributes should be accessible - assert client.base_url == "http://localhost:8080" - assert client.server_alias == "test-server" - assert client.dev_mode is True - - @pytest.mark.asyncio - async def test_concurrent_health_checks(self): - """Test multiple concurrent health checks""" - import asyncio - - client = MCPClient("http://localhost:8080", "test-server") - - mock_response = AsyncMock() - mock_response.status = 200 - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - with patch.object(ClientSession, "get", return_value=mock_response): - results = await asyncio.gather( - client.health_check(), - client.health_check(), - client.health_check(), - ) - - assert all(results) - - await client.close() diff --git a/sdk/python/tests/test_types.py b/sdk/python/tests/test_types.py index 8b001198e..199078a17 100644 --- a/sdk/python/tests/test_types.py +++ b/sdk/python/tests/test_types.py @@ -6,8 +6,6 @@ import asyncio import sys import types as stdlib_types -from dataclasses import asdict - import pytest from agentfield.types import ( @@ -22,7 +20,7 @@ ExecutionHeaders, HarnessConfig, HeartbeatData, - MCPServerHealth, + MemoryChangeEvent, MemoryConfig, MemoryValue, @@ -60,48 +58,6 @@ def test_all_members(self): assert len(AgentStatus) == 4 -# --------------------------------------------------------------------------- -# MCPServerHealth -# --------------------------------------------------------------------------- - - -class TestMCPServerHealth: - def test_minimal_construction(self): - h = MCPServerHealth(alias="s1", status="running") - assert h.alias == "s1" - assert h.status == "running" - assert h.tool_count == 0 - assert h.port is None - assert h.process_id is None - assert h.started_at is None - assert h.last_health_check is None - - def test_full_construction(self): - h = MCPServerHealth( - alias="s1", - status="running", - tool_count=5, - port=3000, - process_id=1234, - started_at="2024-01-01T00:00:00Z", - last_health_check="2024-01-01T00:01:00Z", - ) - assert h.tool_count == 5 - assert h.port == 3000 - assert h.process_id == 1234 - - def test_to_dict(self): - h = MCPServerHealth(alias="s1", status="running", tool_count=3) - d = h.to_dict() - assert d["alias"] == "s1" - assert d["tool_count"] == 3 - assert d["port"] is None - - def test_to_dict_matches_asdict(self): - h = MCPServerHealth(alias="s1", status="running") - assert h.to_dict() == asdict(h) - - # --------------------------------------------------------------------------- # HeartbeatData # --------------------------------------------------------------------------- @@ -111,44 +67,27 @@ class TestHeartbeatData: def test_construction_and_defaults(self): hb = HeartbeatData( status=AgentStatus.READY, - mcp_servers=[], timestamp="2024-01-01T00:00:00Z", ) assert hb.version == "" def test_to_dict_serializes_status_value(self): - server = MCPServerHealth(alias="s1", status="running", tool_count=2) hb = HeartbeatData( status=AgentStatus.READY, - mcp_servers=[server], timestamp="2024-01-01T00:00:00Z", version="1.0", ) d = hb.to_dict() assert d["status"] == "ready" - assert len(d["mcp_servers"]) == 1 - assert d["mcp_servers"][0]["alias"] == "s1" assert d["version"] == "1.0" assert d["timestamp"] == "2024-01-01T00:00:00Z" - def test_to_dict_empty_servers(self): + def test_to_dict_offline(self): hb = HeartbeatData( - status=AgentStatus.OFFLINE, mcp_servers=[], timestamp="t" + status=AgentStatus.OFFLINE, timestamp="t" ) d = hb.to_dict() assert d["status"] == "offline" - assert d["mcp_servers"] == [] - - def test_to_dict_multiple_servers(self): - servers = [ - MCPServerHealth(alias="a", status="running"), - MCPServerHealth(alias="b", status="failed"), - ] - hb = HeartbeatData( - status=AgentStatus.DEGRADED, mcp_servers=servers, timestamp="t" - ) - d = hb.to_dict() - assert len(d["mcp_servers"]) == 2 # --------------------------------------------------------------------------- diff --git a/sdk/typescript/src/agent/Agent.ts b/sdk/typescript/src/agent/Agent.ts index 268bff616..99798e304 100644 --- a/sdk/typescript/src/agent/Agent.ts +++ b/sdk/typescript/src/agent/Agent.ts @@ -37,14 +37,11 @@ import { matchesPattern } from '../utils/pattern.js'; import { toJsonSchema } from '../utils/schema.js'; import { WorkflowReporter } from '../workflow/WorkflowReporter.js'; import type { DiscoveryOptions } from '../types/agent.js'; -import type { MCPToolRegistration } from '../types/mcp.js'; import { createExecutionLogger, type ExecutionLogContext, type ExecutionLogger } from '../observability/ExecutionLogger.js'; -import { MCPClientRegistry } from '../mcp/MCPClientRegistry.js'; -import { MCPToolRegistrar } from '../mcp/MCPToolRegistrar.js'; import { LocalVerifier } from '../verification/LocalVerifier.js'; import { installStdioLogCapture, @@ -70,29 +67,19 @@ export class Agent { private readonly didClient: DidClient; private readonly didManager: DidManager; private readonly memoryWatchers: Array<{ pattern: string; handler: MemoryWatchHandler; scope?: string; scopeId?: string }> = []; - private readonly mcpClientRegistry?: MCPClientRegistry; - private readonly mcpToolRegistrar?: MCPToolRegistrar; private readonly localVerifier?: LocalVerifier; private readonly realtimeValidationFunctions = new Set(); private readonly processLogRing = new ProcessLogRing(); private readonly executionLogger: ExecutionLogger; constructor(config: AgentConfig) { - const mcp = config.mcp - ? { - autoRegisterTools: config.mcp.autoRegisterTools ?? true, - ...config.mcp - } - : undefined; - this.config = { port: 8001, agentFieldUrl: 'http://localhost:8080', host: '0.0.0.0', ...config, didEnabled: config.didEnabled ?? true, - deploymentType: config.deploymentType ?? 'long_running', - mcp + deploymentType: config.deploymentType ?? 'long_running' }; this.app = express(); @@ -112,15 +99,6 @@ export class Agent { }); this.memoryEventClient.onEvent((event) => this.dispatchMemoryEvent(event)); - if (this.config.mcp?.servers?.length) { - this.mcpClientRegistry = new MCPClientRegistry(this.config.devMode); - this.mcpToolRegistrar = new MCPToolRegistrar(this, this.mcpClientRegistry, { - namespace: this.config.mcp.namespace, - tags: this.config.mcp.tags, - devMode: this.config.devMode - }); - this.mcpToolRegistrar.registerServers(this.config.mcp.servers); - } // Initialize local verifier for decentralized verification if (this.config.localVerification && this.config.agentFieldUrl) { @@ -191,11 +169,6 @@ export class Agent { return this.agentFieldClient.discoverCapabilities(options); } - async registerMcpTools(): Promise<{ registered: MCPToolRegistration[] }> { - if (!this.mcpToolRegistrar) return { registered: [] }; - return this.mcpToolRegistrar.registerAll(); - } - getAIClient() { return this.aiClient; } @@ -319,16 +292,6 @@ export class Agent { } async serve(): Promise { - if (this.config.mcp?.autoRegisterTools !== false) { - try { - await this.registerMcpTools(); - } catch (err) { - if (this.config.devMode) { - console.warn('MCP tool registration failed', err); - } - } - } - await this.registerWithControlPlane(); // Perform a blocking initial refresh for local verification before accepting requests @@ -617,25 +580,6 @@ export class Agent { res.json(this.discoveryPayload(this.config.deploymentType ?? 'long_running')); }); - // MCP health probe expected by control-plane UI - this.app.get('/health/mcp', async (_req, res) => { - if (!this.mcpClientRegistry) { - res.json({ status: 'disabled', totalServers: 0, healthyServers: 0, servers: [] }); - return; - } - - try { - const summary = await this.mcpClientRegistry.healthSummary(); - res.json(summary); - } catch (err: any) { - res.status(500).json({ status: 'error', error: err?.message ?? 'MCP health check failed' }); - } - }); - - this.app.get('/mcp/status', (_req, res) => { - res.json(this.mcpStatus()); - }); - this.app.get('/status', (_req, res) => { res.json({ ...this.health(), @@ -1582,27 +1526,6 @@ export class Agent { }; } - private mcpStatus() { - const servers = this.mcpClientRegistry - ? this.mcpClientRegistry.list().map((client) => ({ - alias: client.alias, - baseUrl: client.baseUrl, - transport: client.transport - })) - : []; - - const skills = this.skills - .all() - .filter((skill) => skill.options?.tags?.includes('mcp')) - .map((skill) => skill.name); - - return { - status: servers.length ? 'configured' : 'disabled', - servers, - skills - }; - } - private dispatchMemoryEvent(event: MemoryChangeEvent) { this.memoryWatchers.forEach(({ pattern, handler, scope, scopeId }) => { const scopeMatch = (!scope || scope === event.scope) && (!scopeId || scopeId === event.scopeId); diff --git a/sdk/typescript/src/agent/AgentConfig.ts b/sdk/typescript/src/agent/AgentConfig.ts index 621a1d908..058bbd5d2 100644 --- a/sdk/typescript/src/agent/AgentConfig.ts +++ b/sdk/typescript/src/agent/AgentConfig.ts @@ -1 +1 @@ -export type { AgentConfig, AIConfig, MemoryConfig, MCPConfig, MCPServerConfig } from '../types/agent.js'; +export type { AgentConfig, AIConfig, MemoryConfig } from '../types/agent.js'; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 9c7352289..765cd4625 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -15,16 +15,12 @@ export * from './client/DIDAuthenticator.js'; export * from './did/DidClient.js'; export * from './did/DidInterface.js'; export * from './did/DidManager.js'; -export * from './mcp/MCPClient.js'; -export * from './mcp/MCPClientRegistry.js'; -export * from './mcp/MCPToolRegistrar.js'; export * from './ai/RateLimiter.js'; export * from './ai/multimodal.js'; export * from './ai/MultimodalResponse.js'; export * from './types/agent.js'; export * from './types/reasoner.js'; export * from './types/skill.js'; -export * from './types/mcp.js'; export * from './harness/index.js'; export * from './status/ExecutionStatus.js'; export * from './approval/ApprovalClient.js'; diff --git a/sdk/typescript/src/mcp/MCPClient.ts b/sdk/typescript/src/mcp/MCPClient.ts deleted file mode 100644 index 318976726..000000000 --- a/sdk/typescript/src/mcp/MCPClient.ts +++ /dev/null @@ -1,123 +0,0 @@ -import axios, { type AxiosInstance } from 'axios'; -import type { MCPServerConfig } from '../types/agent.js'; -import type { MCPTool } from '../types/mcp.js'; -import { httpAgent, httpsAgent } from '../utils/httpAgents.js'; - -export class MCPClient { - readonly alias: string; - readonly baseUrl: string; - readonly transport: 'http' | 'bridge'; - private readonly http: AxiosInstance; - private readonly devMode: boolean; - private lastHealthy = false; - - constructor(config: MCPServerConfig, devMode?: boolean) { - if (!config.alias) { - throw new Error('MCP server alias is required'); - } - if (!config.url && !config.port) { - throw new Error(`MCP server "${config.alias}" requires a url or port`); - } - - this.alias = config.alias; - this.transport = config.transport ?? 'http'; - this.baseUrl = (config.url ?? `http://localhost:${config.port}`).replace(/\/$/, ''); - this.http = axios.create({ - baseURL: this.baseUrl, - headers: config.headers, - timeout: 30000, - httpAgent, - httpsAgent - }); - this.devMode = Boolean(devMode); - } - - async healthCheck(): Promise { - try { - await this.http.get('/health'); - this.lastHealthy = true; - return true; - } catch (err) { - this.lastHealthy = false; - if (this.devMode) { - console.warn(`MCP health check failed for ${this.alias}:`, err instanceof Error ? err.message : err); - } - return false; - } - } - - async listTools(): Promise { - try { - if (this.transport === 'bridge') { - const res = await this.http.post('/mcp/tools/list'); - const tools = res.data?.tools ?? []; - return this.normalizeTools(tools); - } - - const res = await this.http.post('/mcp/v1', { - jsonrpc: '2.0', - id: Date.now(), - method: 'tools/list', - params: {} - }); - const tools = res.data?.result?.tools ?? []; - return this.normalizeTools(tools); - } catch (err) { - if (this.devMode) { - console.warn(`MCP listTools failed for ${this.alias}:`, err instanceof Error ? err.message : err); - } - return []; - } - } - - async callTool(toolName: string, arguments_: Record = {}): Promise { - if (!toolName) { - throw new Error('toolName is required'); - } - - try { - if (this.transport === 'bridge') { - const res = await this.http.post('/mcp/tools/call', { - tool_name: toolName, - arguments: arguments_ - }); - return res.data?.result ?? res.data; - } - - const res = await this.http.post('/mcp/v1', { - jsonrpc: '2.0', - id: Date.now(), - method: 'tools/call', - params: { name: toolName, arguments: arguments_ } - }); - - if (res.data?.error) { - throw new Error(String(res.data.error?.message ?? res.data.error)); - } - - if (res.data?.result !== undefined) { - return res.data.result; - } - - return res.data; - } catch (err) { - if (this.devMode) { - console.warn(`MCP callTool failed for ${this.alias}.${toolName}:`, err instanceof Error ? err.message : err); - } - throw err; - } - } - - get lastHealthStatus() { - return this.lastHealthy; - } - - private normalizeTools(tools: any[]): MCPTool[] { - return (tools ?? []).map((tool) => ({ - name: tool?.name ?? 'unknown', - description: tool?.description, - inputSchema: tool?.inputSchema ?? tool?.input_schema, - input_schema: tool?.input_schema - })); - } -} diff --git a/sdk/typescript/src/mcp/MCPClientRegistry.ts b/sdk/typescript/src/mcp/MCPClientRegistry.ts deleted file mode 100644 index dd64f29c9..000000000 --- a/sdk/typescript/src/mcp/MCPClientRegistry.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { MCPServerConfig } from '../types/agent.js'; -import type { MCPHealthSummary } from '../types/mcp.js'; -import { MCPClient } from './MCPClient.js'; - -export class MCPClientRegistry { - private readonly clients = new Map(); - private readonly devMode: boolean; - - constructor(devMode?: boolean) { - this.devMode = Boolean(devMode); - } - - register(config: MCPServerConfig): MCPClient { - const client = new MCPClient(config, this.devMode); - this.clients.set(config.alias, client); - return client; - } - - get(alias: string) { - return this.clients.get(alias); - } - - list(): MCPClient[] { - return Array.from(this.clients.values()); - } - - clear(): void { - this.clients.clear(); - } - - async healthSummary(): Promise { - if (!this.clients.size) { - return { - status: 'disabled', - totalServers: 0, - healthyServers: 0, - servers: [] - }; - } - - const results = await Promise.all( - Array.from(this.clients.values()).map(async (client) => { - const healthy = await client.healthCheck(); - return { - alias: client.alias, - baseUrl: client.baseUrl, - transport: client.transport, - healthy - }; - }) - ); - - const healthyCount = results.filter((r) => r.healthy).length; - const status: MCPHealthSummary['status'] = - healthyCount === 0 ? 'degraded' : healthyCount === results.length ? 'ok' : 'degraded'; - - return { - status, - totalServers: results.length, - healthyServers: healthyCount, - servers: results - }; - } -} diff --git a/sdk/typescript/src/mcp/MCPToolRegistrar.ts b/sdk/typescript/src/mcp/MCPToolRegistrar.ts deleted file mode 100644 index 103c703fe..000000000 --- a/sdk/typescript/src/mcp/MCPToolRegistrar.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Agent } from '../agent/Agent.js'; -import type { MCPServerConfig } from '../types/agent.js'; -import type { MCPTool, MCPToolRegistration } from '../types/mcp.js'; -import { MCPClientRegistry } from './MCPClientRegistry.js'; - -export interface MCPToolRegistrarOptions { - namespace?: string; - tags?: string[]; - devMode?: boolean; -} - -export class MCPToolRegistrar { - private readonly registered = new Set(); - private readonly devMode: boolean; - - constructor( - private readonly agent: Agent, - private readonly registry: MCPClientRegistry, - private readonly options: MCPToolRegistrarOptions = {} - ) { - this.devMode = Boolean(options.devMode); - } - - registerServers(servers: MCPServerConfig[]) { - servers.forEach((server) => this.registry.register(server)); - } - - async registerAll(): Promise<{ registered: MCPToolRegistration[] }> { - const registrations: MCPToolRegistration[] = []; - const clients = this.registry.list(); - - for (const client of clients) { - const healthy = await client.healthCheck(); - if (!healthy) { - if (this.devMode) { - console.warn(`Skipping MCP server ${client.alias} (health check failed)`); - } - continue; - } - - const tools = await client.listTools(); - for (const tool of tools) { - if (!tool?.name) continue; - - const skillName = this.buildSkillName(client.alias, tool.name); - if (this.registered.has(skillName) || this.agent.skills.get(skillName)) { - continue; - } - - this.agent.skill( - skillName, - async (ctx) => { - const args = (ctx.input && typeof ctx.input === 'object') ? (ctx.input as Record) : {}; - const result = await client.callTool(tool.name, args); - return { - status: 'success', - result, - server: client.alias, - tool: tool.name - }; - }, - { - description: tool.description ?? `MCP tool ${tool.name} from ${client.alias}`, - inputSchema: tool.inputSchema ?? tool.input_schema ?? {}, - tags: this.buildTags(client.alias) - } - ); - - this.registered.add(skillName); - registrations.push({ server: client.alias, skillName, tool }); - if (this.devMode) { - console.info(`Registered MCP skill ${skillName}`); - } - } - } - - return { registered: registrations }; - } - - private buildTags(alias: string) { - return Array.from(new Set(['mcp', alias, ...(this.options.tags ?? [])])); - } - - private buildSkillName(serverAlias: string, toolName: string) { - const base = [this.options.namespace, serverAlias, toolName].filter(Boolean).join('_'); - return this.sanitize(base); - } - - private sanitize(value: string) { - const collapsed = value.replace(/[^a-zA-Z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, ''); - if (/^[0-9]/.test(collapsed)) { - return `mcp_${collapsed}`; - } - return collapsed || 'mcp_tool'; - } -} diff --git a/sdk/typescript/src/types/agent.ts b/sdk/typescript/src/types/agent.ts index f1b05614a..f6624966b 100644 --- a/sdk/typescript/src/types/agent.ts +++ b/sdk/typescript/src/types/agent.ts @@ -25,7 +25,6 @@ export interface AgentConfig { apiKey?: string; did?: string; privateKeyJwk?: string; - mcp?: MCPConfig; deploymentType?: DeploymentType; /** Enable decentralized local verification of incoming DID signatures. */ localVerification?: boolean; @@ -69,21 +68,6 @@ export interface MemoryConfig { export type MemoryScope = 'workflow' | 'session' | 'actor' | 'global'; -export interface MCPServerConfig { - alias: string; - url?: string; - port?: number; - transport?: 'http' | 'bridge'; - headers?: Record; -} - -export interface MCPConfig { - servers?: MCPServerConfig[]; - autoRegisterTools?: boolean; - namespace?: string; - tags?: string[]; -} - export interface AgentCapability { agentId: string; baseUrl: string; diff --git a/sdk/typescript/src/types/mcp.ts b/sdk/typescript/src/types/mcp.ts deleted file mode 100644 index 59ac9f625..000000000 --- a/sdk/typescript/src/types/mcp.ts +++ /dev/null @@ -1,24 +0,0 @@ -export interface MCPTool { - name: string; - description?: string; - inputSchema?: any; - input_schema?: any; -} - -export interface MCPToolRegistration { - server: string; - skillName: string; - tool: MCPTool; -} - -export interface MCPHealthSummary { - status: 'ok' | 'degraded' | 'disabled'; - totalServers: number; - healthyServers: number; - servers: Array<{ - alias: string; - baseUrl: string; - transport: 'http' | 'bridge'; - healthy: boolean; - }>; -} diff --git a/sdk/typescript/src/utils/httpAgents.ts b/sdk/typescript/src/utils/httpAgents.ts index 166d41d58..6c57d4ce8 100644 --- a/sdk/typescript/src/utils/httpAgents.ts +++ b/sdk/typescript/src/utils/httpAgents.ts @@ -5,7 +5,7 @@ import https from 'node:https'; * Shared HTTP agents with connection pooling to prevent socket exhaustion. * * These agents are shared across all SDK HTTP clients (AgentFieldClient, - * MemoryClient, DidClient, MCPClient) to ensure consistent connection + * MemoryClient, DidClient) to ensure consistent connection * pooling behavior and prevent socket leaks. * * Configuration: diff --git a/sdk/typescript/tests/mcp.test.ts b/sdk/typescript/tests/mcp.test.ts deleted file mode 100644 index 2006b6741..000000000 --- a/sdk/typescript/tests/mcp.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import express from 'express'; -import type http from 'node:http'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import { Agent } from '../src/agent/Agent.js'; -import { SkillContext } from '../src/context/SkillContext.js'; - -describe('MCP integration', () => { - let server: http.Server; - let baseUrl = ''; - - beforeAll(async () => { - const app = express(); - app.use(express.json()); - - app.get('/health', (_req, res) => { - res.json({ status: 'ok' }); - }); - - app.post('/mcp/v1', (req, res) => { - const method = req.body?.method; - if (method === 'tools/list') { - res.json({ - jsonrpc: '2.0', - id: req.body?.id ?? 1, - result: { - tools: [ - { - name: 'echo', - description: 'Echo back the provided message', - inputSchema: { - type: 'object', - properties: { message: { type: 'string' } }, - required: ['message'] - } - } - ] - } - }); - return; - } - - if (method === 'tools/call') { - const message = req.body?.params?.arguments?.message; - res.json({ - jsonrpc: '2.0', - id: req.body?.id ?? 1, - result: { echoed: message ?? null } - }); - return; - } - - res.status(400).json({ error: 'unknown method' }); - }); - - await new Promise((resolve) => { - server = app.listen(0, () => { - const port = (server.address() as any).port; - baseUrl = `http://127.0.0.1:${port}`; - resolve(); - }); - }); - }); - - afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); - }); - - it('registers MCP tools as skills and executes them', async () => { - const agent = new Agent({ - nodeId: 'mcp-agent', - devMode: true, - mcp: { - servers: [{ alias: 'demo', url: baseUrl }], - autoRegisterTools: true - } - }); - - const { registered } = await agent.registerMcpTools(); - expect(registered.length).toBe(1); - expect(registered[0]?.skillName).toBe('demo_echo'); - - const skill = agent.skills.get('demo_echo'); - expect(skill).toBeDefined(); - - const ctx = new SkillContext({ - input: { message: 'hello' }, - executionId: 'exec-1', - sessionId: 'session-1', - workflowId: 'wf-1', - callerDid: undefined, - agentNodeDid: undefined, - req: {} as any, - res: {} as any, - agent, - logger: agent.getExecutionLogger(), - memory: agent.getMemoryInterface({ executionId: 'exec-1', runId: 'run-1', workflowId: 'wf-1' }), - workflow: agent.getWorkflowReporter({ executionId: 'exec-1', runId: 'run-1', workflowId: 'wf-1' } as any), - did: agent.getDidInterface({ executionId: 'exec-1', runId: 'run-1', workflowId: 'wf-1' } as any, { message: 'hello' }) - }); - - const result = await skill!.handler(ctx as any); - expect(result).toEqual({ - status: 'success', - result: { echoed: 'hello' }, - server: 'demo', - tool: 'echo' - }); - }); -}); diff --git a/sdk/typescript/tests/mcp_client.test.ts b/sdk/typescript/tests/mcp_client.test.ts deleted file mode 100644 index b37062f7f..000000000 --- a/sdk/typescript/tests/mcp_client.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import axios from 'axios'; -import { MCPClient } from '../src/mcp/MCPClient.js'; - -// --------------------------------------------------------------------------- -// Module-level axios mock -// --------------------------------------------------------------------------- - -vi.mock('axios', () => { - const create = vi.fn(() => ({ - post: vi.fn(), - get: vi.fn() - })); - - return { - default: { create }, - create - }; -}); - -function getHttpMock() { - const mockCreate = (axios as any).create as ReturnType; - const last = mockCreate.mock.results.at(-1); - return last?.value as { post: ReturnType; get: ReturnType }; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('MCPClient', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - // ------------------------------------------------------------------------- - // Constructor - // ------------------------------------------------------------------------- - describe('constructor', () => { - it('creates a client with url config', () => { - const client = new MCPClient({ alias: 'test', url: 'http://mcp:9000' }); - expect(client.alias).toBe('test'); - expect(client.baseUrl).toBe('http://mcp:9000'); - expect(client.transport).toBe('http'); - }); - - it('creates a client with port config', () => { - const client = new MCPClient({ alias: 'test', port: 3001 }); - expect(client.baseUrl).toBe('http://localhost:3001'); - }); - - it('strips trailing slash from url', () => { - const client = new MCPClient({ alias: 'test', url: 'http://mcp:9000/' }); - expect(client.baseUrl).toBe('http://mcp:9000'); - }); - - it('uses specified transport', () => { - const client = new MCPClient({ alias: 'test', url: 'http://mcp:9000', transport: 'bridge' }); - expect(client.transport).toBe('bridge'); - }); - - it('throws when alias is missing', () => { - expect(() => new MCPClient({ alias: '', url: 'http://mcp:9000' })).toThrow('alias is required'); - }); - - it('throws when both url and port are missing', () => { - expect(() => new MCPClient({ alias: 'test' })).toThrow('requires a url or port'); - }); - }); - - // ------------------------------------------------------------------------- - // healthCheck - // ------------------------------------------------------------------------- - describe('healthCheck()', () => { - it('returns true on successful /health GET', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.get.mockResolvedValue({ data: {} }); - - const result = await client.healthCheck(); - expect(result).toBe(true); - expect(http.get).toHaveBeenCalledWith('/health'); - expect(client.lastHealthStatus).toBe(true); - }); - - it('returns false when /health fails', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.get.mockRejectedValue(new Error('ECONNREFUSED')); - - const result = await client.healthCheck(); - expect(result).toBe(false); - expect(client.lastHealthStatus).toBe(false); - }); - - it('logs warning in devMode when health check fails', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }, true); - const http = getHttpMock(); - http.get.mockRejectedValue(new Error('down')); - - await client.healthCheck(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('MCP health check failed'), 'down'); - warnSpy.mockRestore(); - }); - - it('does not log in non-devMode when health check fails', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }, false); - const http = getHttpMock(); - http.get.mockRejectedValue(new Error('down')); - - await client.healthCheck(); - expect(warnSpy).not.toHaveBeenCalled(); - warnSpy.mockRestore(); - }); - }); - - // ------------------------------------------------------------------------- - // listTools - // ------------------------------------------------------------------------- - describe('listTools()', () => { - it('uses JSON-RPC for http transport', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ - data: { - result: { - tools: [ - { name: 'search', description: 'Search tool', inputSchema: { type: 'object' } } - ] - } - } - }); - - const tools = await client.listTools(); - expect(tools).toHaveLength(1); - expect(tools[0].name).toBe('search'); - expect(tools[0].description).toBe('Search tool'); - expect(http.post).toHaveBeenCalledWith('/mcp/v1', expect.objectContaining({ - jsonrpc: '2.0', - method: 'tools/list' - })); - }); - - it('uses bridge endpoint for bridge transport', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000', transport: 'bridge' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ - data: { tools: [{ name: 'read', description: 'Read file' }] } - }); - - const tools = await client.listTools(); - expect(tools).toHaveLength(1); - expect(tools[0].name).toBe('read'); - expect(http.post).toHaveBeenCalledWith('/mcp/tools/list'); - }); - - it('returns empty array on error', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockRejectedValue(new Error('timeout')); - - const tools = await client.listTools(); - expect(tools).toEqual([]); - }); - - it('normalizes tools with missing fields', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ - data: { result: { tools: [{ name: null }, {}] } } - }); - - const tools = await client.listTools(); - expect(tools[0].name).toBe('unknown'); - expect(tools[1].name).toBe('unknown'); - }); - - it('handles missing tools array in response', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ data: { result: {} } }); - - const tools = await client.listTools(); - expect(tools).toEqual([]); - }); - - it('normalizes input_schema to inputSchema', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - const schema = { type: 'object', properties: {} }; - http.post.mockResolvedValue({ - data: { result: { tools: [{ name: 'tool', input_schema: schema }] } } - }); - - const tools = await client.listTools(); - expect(tools[0].inputSchema).toEqual(schema); - expect(tools[0].input_schema).toEqual(schema); - }); - }); - - // ------------------------------------------------------------------------- - // callTool - // ------------------------------------------------------------------------- - describe('callTool()', () => { - it('throws when toolName is empty', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - await expect(client.callTool('')).rejects.toThrow('toolName is required'); - }); - - it('calls via JSON-RPC for http transport', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ data: { result: { output: 'hello' } } }); - - const result = await client.callTool('greet', { name: 'Alice' }); - expect(result).toEqual({ output: 'hello' }); - expect(http.post).toHaveBeenCalledWith('/mcp/v1', expect.objectContaining({ - jsonrpc: '2.0', - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'Alice' } } - })); - }); - - it('calls via bridge endpoint for bridge transport', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000', transport: 'bridge' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ data: { result: 'bridge-result' } }); - - const result = await client.callTool('read', { path: '/tmp' }); - expect(result).toBe('bridge-result'); - expect(http.post).toHaveBeenCalledWith('/mcp/tools/call', { - tool_name: 'read', - arguments: { path: '/tmp' } - }); - }); - - it('returns res.data when bridge result is undefined', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000', transport: 'bridge' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ data: { raw: 'value' } }); - - const result = await client.callTool('tool', {}); - expect(result).toEqual({ raw: 'value' }); - }); - - it('throws on JSON-RPC error response', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ - data: { error: { message: 'tool not found' } } - }); - - await expect(client.callTool('missing')).rejects.toThrow('tool not found'); - }); - - it('throws on JSON-RPC error without message', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ - data: { error: 'some string error' } - }); - - await expect(client.callTool('broken')).rejects.toThrow('some string error'); - }); - - it('returns res.data when result is undefined in JSON-RPC', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ data: { other: 'field' } }); - - const result = await client.callTool('tool'); - expect(result).toEqual({ other: 'field' }); - }); - - it('defaults arguments to empty object', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockResolvedValue({ data: { result: 'ok' } }); - - await client.callTool('tool'); - const [, body] = http.post.mock.calls[0]; - expect(body.params.arguments).toEqual({}); - }); - - it('re-throws HTTP errors', async () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - const http = getHttpMock(); - http.post.mockRejectedValue(new Error('network down')); - - await expect(client.callTool('tool')).rejects.toThrow('network down'); - }); - - it('logs warning in devMode on error', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }, true); - const http = getHttpMock(); - http.post.mockRejectedValue(new Error('fail')); - - await expect(client.callTool('tool')).rejects.toThrow('fail'); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('MCP callTool failed'), 'fail'); - warnSpy.mockRestore(); - }); - }); - - // ------------------------------------------------------------------------- - // lastHealthStatus - // ------------------------------------------------------------------------- - describe('lastHealthStatus', () => { - it('defaults to false', () => { - const client = new MCPClient({ alias: 'srv', url: 'http://mcp:9000' }); - expect(client.lastHealthStatus).toBe(false); - }); - }); -}); diff --git a/sdk/typescript/tests/mcp_registry.test.ts b/sdk/typescript/tests/mcp_registry.test.ts deleted file mode 100644 index 840645263..000000000 --- a/sdk/typescript/tests/mcp_registry.test.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { MCPClientRegistry } from '../src/mcp/MCPClientRegistry.js'; -import { MCPToolRegistrar } from '../src/mcp/MCPToolRegistrar.js'; -import { MCPClient } from '../src/mcp/MCPClient.js'; -import type { Agent } from '../src/agent/Agent.js'; - -// --------------------------------------------------------------------------- -// Mock MCPClient so no real axios instances are created -// --------------------------------------------------------------------------- - -vi.mock('../src/mcp/MCPClient.js', () => { - return { - MCPClient: vi.fn().mockImplementation(function MockMCPClient(config: any, devMode?: boolean) { - void devMode; - return { - alias: config.alias, - baseUrl: config.url ?? `http://localhost:${config.port}`, - transport: config.transport ?? 'http', - healthCheck: vi.fn().mockResolvedValue(true), - listTools: vi.fn().mockResolvedValue([]), - callTool: vi.fn().mockResolvedValue({}), - lastHealthStatus: false - }; - }) - }; -}); - -// --------------------------------------------------------------------------- -// MCPClientRegistry -// --------------------------------------------------------------------------- - -describe('MCPClientRegistry', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('registers and retrieves clients by alias', () => { - const registry = new MCPClientRegistry(); - const client = registry.register({ alias: 'search', url: 'http://mcp:9000' }); - - expect(client.alias).toBe('search'); - expect(registry.get('search')).toBe(client); - }); - - it('returns undefined for unregistered alias', () => { - const registry = new MCPClientRegistry(); - expect(registry.get('nonexistent')).toBeUndefined(); - }); - - it('lists all registered clients', () => { - const registry = new MCPClientRegistry(); - registry.register({ alias: 'a', url: 'http://a:9000' }); - registry.register({ alias: 'b', url: 'http://b:9000' }); - - const clients = registry.list(); - expect(clients).toHaveLength(2); - expect(clients.map((c) => c.alias)).toEqual(['a', 'b']); - }); - - it('clears all clients', () => { - const registry = new MCPClientRegistry(); - registry.register({ alias: 'x', url: 'http://x:9000' }); - registry.clear(); - expect(registry.list()).toHaveLength(0); - expect(registry.get('x')).toBeUndefined(); - }); - - // ----------------------------------------------------------------------- - // healthSummary - // ----------------------------------------------------------------------- - describe('healthSummary()', () => { - it('returns disabled status when no servers are registered', async () => { - const registry = new MCPClientRegistry(); - const summary = await registry.healthSummary(); - - expect(summary.status).toBe('disabled'); - expect(summary.totalServers).toBe(0); - expect(summary.healthyServers).toBe(0); - expect(summary.servers).toEqual([]); - }); - - it('returns ok when all servers are healthy', async () => { - const registry = new MCPClientRegistry(); - registry.register({ alias: 'a', url: 'http://a:9000' }); - registry.register({ alias: 'b', url: 'http://b:9000' }); - - const summary = await registry.healthSummary(); - expect(summary.status).toBe('ok'); - expect(summary.totalServers).toBe(2); - expect(summary.healthyServers).toBe(2); - expect(summary.servers).toHaveLength(2); - }); - - it('returns degraded when some servers are unhealthy', async () => { - const registry = new MCPClientRegistry(); - const healthy = registry.register({ alias: 'a', url: 'http://a:9000' }); - const unhealthy = registry.register({ alias: 'b', url: 'http://b:9000' }); - (unhealthy.healthCheck as ReturnType).mockResolvedValue(false); - - const summary = await registry.healthSummary(); - expect(summary.status).toBe('degraded'); - expect(summary.healthyServers).toBe(1); - }); - - it('returns degraded when all servers are unhealthy', async () => { - const registry = new MCPClientRegistry(); - const c = registry.register({ alias: 'a', url: 'http://a:9000' }); - (c.healthCheck as ReturnType).mockResolvedValue(false); - - const summary = await registry.healthSummary(); - expect(summary.status).toBe('degraded'); - expect(summary.healthyServers).toBe(0); - }); - }); -}); - -// --------------------------------------------------------------------------- -// MCPToolRegistrar -// --------------------------------------------------------------------------- - -describe('MCPToolRegistrar', () => { - /** Create a minimal mock Agent with a skill() method and skills map */ - function createMockAgent(): Agent { - const skillsMap = new Map(); - return { - skill: vi.fn((name: string, handler: any, opts: any) => { - skillsMap.set(name, { name, handler, ...opts }); - }), - skills: { - get: (name: string) => skillsMap.get(name), - all: () => Array.from(skillsMap.values()) - } - } as unknown as Agent; - } - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('registers MCP servers into the registry', () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - registrar.registerServers([ - { alias: 'a', url: 'http://a:9000' }, - { alias: 'b', url: 'http://b:9000' } - ]); - - expect(registry.list()).toHaveLength(2); - }); - - describe('registerAll()', () => { - it('registers tools from healthy servers as agent skills', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'search', description: 'Search things', inputSchema: { type: 'object' } }, - { name: 'read', description: 'Read file' } - ]); - - const result = await registrar.registerAll(); - expect(result.registered).toHaveLength(2); - expect(result.registered[0].skillName).toBe('srv_search'); - expect(result.registered[1].skillName).toBe('srv_read'); - expect(agent.skill).toHaveBeenCalledTimes(2); - }); - - it('skips unhealthy servers', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'down', url: 'http://down:9000' }); - (client.healthCheck as ReturnType).mockResolvedValue(false); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'tool', description: 'A tool' } - ]); - - const result = await registrar.registerAll(); - expect(result.registered).toHaveLength(0); - expect(agent.skill).not.toHaveBeenCalled(); - }); - - it('skips tools with no name', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: '', description: 'empty name' }, - { name: null, description: 'null name' }, - { description: 'missing name' } - ]); - - const result = await registrar.registerAll(); - expect(result.registered).toHaveLength(0); - }); - - it('does not register duplicate skills', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'tool', description: 'A tool' } - ]); - - await registrar.registerAll(); - await registrar.registerAll(); - - expect(agent.skill).toHaveBeenCalledTimes(1); - }); - - it('applies namespace to skill names', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry, { namespace: 'mcp' }); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'search', description: 'Search' } - ]); - - const result = await registrar.registerAll(); - expect(result.registered[0].skillName).toBe('mcp_srv_search'); - }); - - it('sanitizes special characters in skill names', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'my-srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'read-file!', description: 'Read' } - ]); - - const result = await registrar.registerAll(); - expect(result.registered[0].skillName).toBe('my_srv_read_file'); - }); - - it('prefixes with mcp_ when name starts with digit', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: '123srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'tool', description: 'Tool' } - ]); - - const result = await registrar.registerAll(); - expect(result.registered[0].skillName).toBe('mcp_123srv_tool'); - }); - - it('includes custom tags and mcp/alias tags', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry, { tags: ['custom'] }); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'tool', description: 'Tool' } - ]); - - await registrar.registerAll(); - const [, , opts] = (agent.skill as ReturnType).mock.calls[0]; - expect(opts.tags).toContain('mcp'); - expect(opts.tags).toContain('srv'); - expect(opts.tags).toContain('custom'); - }); - - it('logs in devMode when skipping and registering', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}); - - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry, { devMode: true }); - - const unhealthy = registry.register({ alias: 'down', url: 'http://down:9000' }); - (unhealthy.healthCheck as ReturnType).mockResolvedValue(false); - - const healthy = registry.register({ alias: 'up', url: 'http://up:9000' }); - (healthy.listTools as ReturnType).mockResolvedValue([ - { name: 'tool', description: 'Tool' } - ]); - - await registrar.registerAll(); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Skipping MCP server down')); - expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('Registered MCP skill')); - - warnSpy.mockRestore(); - infoSpy.mockRestore(); - }); - - it('registered skill handler calls client.callTool', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'greet', description: 'Greet' } - ]); - (client.callTool as ReturnType).mockResolvedValue({ message: 'hi' }); - - await registrar.registerAll(); - - // Get the handler that was passed to agent.skill - const [, handler] = (agent.skill as ReturnType).mock.calls[0]; - const result = await handler({ input: { name: 'Alice' } }); - - expect(client.callTool).toHaveBeenCalledWith('greet', { name: 'Alice' }); - expect(result).toEqual({ - status: 'success', - result: { message: 'hi' }, - server: 'srv', - tool: 'greet' - }); - }); - - it('registered skill handler handles non-object input', async () => { - const agent = createMockAgent(); - const registry = new MCPClientRegistry(); - const registrar = new MCPToolRegistrar(agent, registry); - - const client = registry.register({ alias: 'srv', url: 'http://srv:9000' }); - (client.listTools as ReturnType).mockResolvedValue([ - { name: 'tool', description: 'Tool' } - ]); - (client.callTool as ReturnType).mockResolvedValue('ok'); - - await registrar.registerAll(); - - const [, handler] = (agent.skill as ReturnType).mock.calls[0]; - // Pass non-object input - const result = await handler({ input: null }); - - expect(client.callTool).toHaveBeenCalledWith('tool', {}); - expect(result.status).toBe('success'); - }); - }); -});