diff --git a/cmd/gh-aw/main.go b/cmd/gh-aw/main.go index 9f9f6e5710..f46a5323d6 100644 --- a/cmd/gh-aw/main.go +++ b/cmd/gh-aw/main.go @@ -373,7 +373,7 @@ func init() { rootCmd.AddCommand(enableCmd) rootCmd.AddCommand(disableCmd) rootCmd.AddCommand(cli.NewLogsCommand()) - rootCmd.AddCommand(cli.NewMCPInspectCommand()) + rootCmd.AddCommand(cli.NewMCPCommand()) rootCmd.AddCommand(versionCmd) } diff --git a/pkg/cli/mcp.go b/pkg/cli/mcp.go new file mode 100644 index 0000000000..261ab3b623 --- /dev/null +++ b/pkg/cli/mcp.go @@ -0,0 +1,515 @@ +package cli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/githubnext/gh-aw/pkg/console" + "github.com/githubnext/gh-aw/pkg/parser" + "github.com/goccy/go-yaml" + "github.com/spf13/cobra" +) + +// NewMCPCommand creates the mcp command with subcommands +func NewMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP tools in agentic workflows", + Long: `Manage Model Context Protocol (MCP) tools in agentic workflows. + +This command provides subcommands to add, list, and inspect MCP servers +configured in workflow files. You can manage tool configurations, allowed +tool lists, and inspect available capabilities from MCP servers. + +Examples: + gh aw mcp list weekly-research # List MCP tools in workflow + gh aw mcp add weekly-research github # Add GitHub MCP server + gh aw mcp inspect weekly-research # Inspect MCP servers in workflow + gh aw mcp inspect weekly-research --server github # Inspect specific server`, + Run: func(cmd *cobra.Command, args []string) { + _ = cmd.Help() + }, + } + + // Add subcommands + cmd.AddCommand(NewMCPAddCommand()) + cmd.AddCommand(NewMCPListCommand()) + cmd.AddCommand(NewMCPInspectCommand()) + + return cmd +} + +// NewMCPAddCommand creates the mcp add command +func NewMCPAddCommand() *cobra.Command { + var forceFlag bool + var allowedTools []string + + cmd := &cobra.Command{ + Use: "add [tool-config-args...]", + Short: "Add MCP tools to an agentic workflow", + Long: `Add Model Context Protocol (MCP) tools to an agentic workflow. + +This command adds MCP tool configurations to the frontmatter of a workflow file. +You can add built-in tools (github, playwright) or custom MCP servers. + +Built-in tools: + github - GitHub MCP server for repository operations + playwright - Playwright MCP server for browser automation + +Custom tools require additional configuration parameters. + +Examples: + gh aw mcp add weekly-research github # Add GitHub MCP server + gh aw mcp add weekly-research playwright # Add Playwright MCP server + gh aw mcp add weekly-research github --allowed create_issue,list_repos + gh aw mcp add weekly-research custom-tool --type stdio --command python --args="-m,server" + gh aw mcp add weekly-research api-server --type http --url https://api.example.com/mcp`, + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + toolName := args[1] + toolConfigArgs := args[2:] + + verbose, _ := cmd.Flags().GetBool("verbose") + if cmd.Parent() != nil && cmd.Parent().Parent() != nil { + parentVerbose, _ := cmd.Parent().Parent().PersistentFlags().GetBool("verbose") + verbose = verbose || parentVerbose + } + + return AddMCPToolWithFlags(workflowID, toolName, toolConfigArgs, cmd, verbose) + }, + } + + cmd.Flags().BoolVar(&forceFlag, "force", false, "Overwrite existing tool configuration") + cmd.Flags().StringSliceVar(&allowedTools, "allowed", nil, "Comma-separated list of allowed tools for this MCP server") + cmd.Flags().StringP("type", "t", "", "MCP server type (stdio, http) for custom tools") + cmd.Flags().StringP("command", "c", "", "Command to run for stdio MCP servers") + cmd.Flags().StringSliceP("args", "a", nil, "Command arguments for stdio MCP servers") + cmd.Flags().StringP("url", "u", "", "URL for HTTP MCP servers") + cmd.Flags().StringP("container", "", "", "Docker container for stdio MCP servers") + cmd.Flags().StringToStringP("env", "e", nil, "Environment variables (key=value)") + cmd.Flags().StringToStringP("headers", "", nil, "HTTP headers for HTTP MCP servers (key=value)") + cmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") + + return cmd +} + +// NewMCPListCommand creates the mcp list command +func NewMCPListCommand() *cobra.Command { + var showAllowedTools bool + var outputFormat string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List MCP tools in an agentic workflow", + Long: `List Model Context Protocol (MCP) tools configured in an agentic workflow. + +This command displays all MCP servers configured in the specified workflow file, +including their types, configurations, and allowed tools. + +Examples: + gh aw mcp list weekly-research # List all MCP tools + gh aw mcp list weekly-research --show-allowed # Include allowed tools list + gh aw mcp list weekly-research --format json # Output in JSON format`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + verbose, _ := cmd.Flags().GetBool("verbose") + if cmd.Parent() != nil && cmd.Parent().Parent() != nil { + parentVerbose, _ := cmd.Parent().Parent().PersistentFlags().GetBool("verbose") + verbose = verbose || parentVerbose + } + + return ListMCPTools(workflowID, showAllowedTools, outputFormat, verbose) + }, + } + + cmd.Flags().BoolVar(&showAllowedTools, "show-allowed", false, "Show allowed tools for each MCP server") + cmd.Flags().StringVar(&outputFormat, "format", "table", "Output format (table, json)") + cmd.Flags().BoolP("verbose", "v", false, "Enable verbose output") + + return cmd +} + +// AddMCPToolWithFlags adds an MCP tool using flags from the cobra command +func AddMCPToolWithFlags(workflowID, toolName string, toolConfigArgs []string, cmd *cobra.Command, verbose bool) error { + workflowPath, err := getWorkflowPath(workflowID) + if err != nil { + return err + } + + if verbose { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Adding MCP tool '%s' to workflow: %s", toolName, workflowPath))) + } + + // Extract flags + allowedTools, _ := cmd.Flags().GetStringSlice("allowed") + forceFlag, _ := cmd.Flags().GetBool("force") + mcpType, _ := cmd.Flags().GetString("type") + command, _ := cmd.Flags().GetString("command") + args, _ := cmd.Flags().GetStringSlice("args") + url, _ := cmd.Flags().GetString("url") + container, _ := cmd.Flags().GetString("container") + env, _ := cmd.Flags().GetStringToString("env") + headers, _ := cmd.Flags().GetStringToString("headers") + + // Read existing workflow file + content, err := os.ReadFile(workflowPath) + if err != nil { + return fmt.Errorf("failed to read workflow file: %w", err) + } + + // Parse frontmatter + workflowData, err := parser.ExtractFrontmatterFromContent(string(content)) + if err != nil { + return fmt.Errorf("failed to parse workflow frontmatter: %w", err) + } + + // Get or create tools section + toolsSection, hasTools := workflowData.Frontmatter["tools"] + var tools map[string]any + if hasTools { + if toolsMap, ok := toolsSection.(map[string]any); ok { + tools = toolsMap + } else { + return fmt.Errorf("tools section is not a valid map") + } + } else { + tools = make(map[string]any) + workflowData.Frontmatter["tools"] = tools + } + + // Check if tool already exists + if _, exists := tools[toolName]; exists && !forceFlag { + return fmt.Errorf("tool '%s' already exists in workflow (use --force to overwrite)", toolName) + } + + // Create tool configuration + toolConfig, err := createToolConfigWithFlags(toolName, mcpType, command, args, url, container, env, headers, allowedTools) + if err != nil { + return fmt.Errorf("failed to create tool configuration: %w", err) + } + + // Add tool to configuration + tools[toolName] = toolConfig + + if verbose { + fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Successfully added MCP tool '%s' to workflow", toolName))) + } + + // Write updated frontmatter back to file + return writeFrontmatterToFile(workflowPath, workflowData) +} + +// AddMCPTool adds an MCP tool to a workflow file (legacy function for compatibility) +func AddMCPTool(workflowID, toolName string, toolConfigArgs, allowedTools []string, force, verbose bool) error { + workflowPath, err := getWorkflowPath(workflowID) + if err != nil { + return err + } + + if verbose { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Adding MCP tool '%s' to workflow: %s", toolName, workflowPath))) + } + + // Read existing workflow file + content, err := os.ReadFile(workflowPath) + if err != nil { + return fmt.Errorf("failed to read workflow file: %w", err) + } + + // Parse frontmatter + workflowData, err := parser.ExtractFrontmatterFromContent(string(content)) + if err != nil { + return fmt.Errorf("failed to parse workflow frontmatter: %w", err) + } + + // Get or create tools section + toolsSection, hasTools := workflowData.Frontmatter["tools"] + var tools map[string]any + if hasTools { + if toolsMap, ok := toolsSection.(map[string]any); ok { + tools = toolsMap + } else { + return fmt.Errorf("tools section is not a valid map") + } + } else { + tools = make(map[string]any) + workflowData.Frontmatter["tools"] = tools + } + + // Check if tool already exists + if _, exists := tools[toolName]; exists && !force { + return fmt.Errorf("tool '%s' already exists in workflow (use --force to overwrite)", toolName) + } + + // Create tool configuration based on tool type + toolConfig, err := createToolConfig(toolName, toolConfigArgs, allowedTools) + if err != nil { + return fmt.Errorf("failed to create tool configuration: %w", err) + } + + // Add tool to configuration + tools[toolName] = toolConfig + + if verbose { + fmt.Println(console.FormatSuccessMessage(fmt.Sprintf("Successfully added MCP tool '%s' to workflow", toolName))) + } + + // Write updated frontmatter back to file + return writeFrontmatterToFile(workflowPath, workflowData) +} + +// ListMCPTools lists MCP tools in a workflow +func ListMCPTools(workflowID string, showAllowedTools bool, outputFormat string, verbose bool) error { + workflowPath, err := getWorkflowPath(workflowID) + if err != nil { + return err + } + + if verbose { + fmt.Println(console.FormatInfoMessage(fmt.Sprintf("Listing MCP tools in workflow: %s", workflowPath))) + } + + // Read and parse workflow file + content, err := os.ReadFile(workflowPath) + if err != nil { + return fmt.Errorf("failed to read workflow file: %w", err) + } + + workflowData, err := parser.ExtractFrontmatterFromContent(string(content)) + if err != nil { + return fmt.Errorf("failed to parse workflow frontmatter: %w", err) + } + + // Extract MCP configurations + mcpConfigs, err := parser.ExtractMCPConfigurations(workflowData.Frontmatter, "") + if err != nil { + return fmt.Errorf("failed to extract MCP configurations: %w", err) + } + + if len(mcpConfigs) == 0 { + fmt.Println(console.FormatInfoMessage("No MCP tools found in workflow")) + return nil + } + + // Display results based on format + switch outputFormat { + case "json": + return displayMCPToolsJSON(mcpConfigs) + case "table": + return displayMCPToolsTable(mcpConfigs, showAllowedTools) + default: + return fmt.Errorf("unsupported output format: %s", outputFormat) + } +} + +// Helper functions + +func getWorkflowPath(workflowID string) (string, error) { + workflowsDir := getWorkflowsDir() + + // Handle .md extension + if !strings.HasSuffix(workflowID, ".md") { + workflowID += ".md" + } + + workflowPath := filepath.Join(workflowsDir, workflowID) + + // Check if file exists + if _, err := os.Stat(workflowPath); os.IsNotExist(err) { + return "", fmt.Errorf("workflow file not found: %s", workflowPath) + } + + return workflowPath, nil +} + +func createToolConfigWithFlags(toolName, mcpType, command string, args []string, url, container string, env, headers map[string]string, allowedTools []string) (map[string]any, error) { + // Handle built-in tools + switch toolName { + case "github": + config := map[string]any{} + if len(allowedTools) > 0 { + config["allowed"] = allowedTools + } + return config, nil + + case "playwright": + config := map[string]any{} + if len(allowedTools) > 0 { + config["allowed"] = allowedTools + } + return config, nil + + default: + // Handle custom MCP tools + if mcpType == "" { + return nil, fmt.Errorf("custom MCP tool '%s' requires --type flag (stdio or http)", toolName) + } + + config := map[string]any{} + + // Add allowed tools if specified + if len(allowedTools) > 0 { + config["allowed"] = allowedTools + } + + // Create MCP configuration + mcpConfig := map[string]any{ + "type": mcpType, + } + + switch mcpType { + case "stdio": + if container != "" { + mcpConfig["container"] = container + if len(env) > 0 { + mcpConfig["env"] = env + } + } else { + if command == "" { + return nil, fmt.Errorf("stdio MCP tool requires --command or --container flag") + } + mcpConfig["command"] = command + if len(args) > 0 { + mcpConfig["args"] = args + } + if len(env) > 0 { + mcpConfig["env"] = env + } + } + + case "http": + if url == "" { + return nil, fmt.Errorf("http MCP tool requires --url flag") + } + mcpConfig["url"] = url + if len(headers) > 0 { + mcpConfig["headers"] = headers + } + + default: + return nil, fmt.Errorf("unsupported MCP type: %s (supported: stdio, http)", mcpType) + } + + config["mcp"] = mcpConfig + return config, nil + } +} + +func createToolConfig(toolName string, toolConfigArgs, allowedTools []string) (map[string]any, error) { + // Handle built-in tools + switch toolName { + case "github": + config := map[string]any{} + if len(allowedTools) > 0 { + config["allowed"] = allowedTools + } + return config, nil + + case "playwright": + config := map[string]any{} + if len(allowedTools) > 0 { + config["allowed"] = allowedTools + } + return config, nil + + default: + // Custom MCP tools require additional configuration + return nil, fmt.Errorf("custom MCP tool '%s' requires additional configuration (use --type, --command, --url, etc.)", toolName) + } +} + +func displayMCPToolsJSON(configs []parser.MCPServerConfig) error { + // Convert configs to a JSON-serializable format + type JsonMCPTool struct { + Name string `json:"name"` + Type string `json:"type"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + Container string `json:"container,omitempty"` + URL string `json:"url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Env map[string]string `json:"env,omitempty"` + Allowed []string `json:"allowed,omitempty"` + } + + var jsonTools []JsonMCPTool + for _, config := range configs { + tool := JsonMCPTool{ + Name: config.Name, + Type: config.Type, + Command: config.Command, + Args: config.Args, + Container: config.Container, + URL: config.URL, + Headers: config.Headers, + Env: config.Env, + Allowed: config.Allowed, + } + jsonTools = append(jsonTools, tool) + } + + jsonBytes, err := json.MarshalIndent(jsonTools, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + + fmt.Println(string(jsonBytes)) + return nil +} + +func displayMCPToolsTable(configs []parser.MCPServerConfig, showAllowed bool) error { + fmt.Println(console.FormatListHeader("MCP Tools")) + fmt.Println(console.FormatListHeader("=========")) + + for _, config := range configs { + fmt.Printf("• %s (%s)\n", config.Name, config.Type) + + if config.Type == "stdio" { + if config.Container != "" { + fmt.Printf(" Container: %s\n", config.Container) + } else { + fmt.Printf(" Command: %s\n", config.Command) + if len(config.Args) > 0 { + fmt.Printf(" Args: %s\n", strings.Join(config.Args, " ")) + } + } + } else if config.Type == "http" { + fmt.Printf(" URL: %s\n", config.URL) + } + + if showAllowed && len(config.Allowed) > 0 { + fmt.Printf(" Allowed: %s\n", strings.Join(config.Allowed, ", ")) + } + fmt.Println() + } + + return nil +} + +func writeFrontmatterToFile(workflowPath string, workflowData *parser.FrontmatterResult) error { + // Convert frontmatter back to YAML + yamlBytes, err := yaml.Marshal(workflowData.Frontmatter) + if err != nil { + return fmt.Errorf("failed to marshal frontmatter: %w", err) + } + + // Construct the full file content + var content strings.Builder + content.WriteString("---\n") + content.WriteString(string(yamlBytes)) + content.WriteString("---\n\n") + content.WriteString(workflowData.Markdown) + + // Write to file + if err := os.WriteFile(workflowPath, []byte(content.String()), 0644); err != nil { + return fmt.Errorf("failed to write workflow file: %w", err) + } + + return nil +} diff --git a/pkg/cli/mcp_inspect.go b/pkg/cli/mcp_inspect.go index 0471795e92..20491346ab 100644 --- a/pkg/cli/mcp_inspect.go +++ b/pkg/cli/mcp_inspect.go @@ -189,14 +189,14 @@ func listWorkflowsWithMCP(workflowsDir string, verbose bool) error { return nil } -// NewMCPInspectCommand creates the mcp-inspect command +// NewMCPInspectCommand creates the mcp inspect command (moved under mcp subcommand) func NewMCPInspectCommand() *cobra.Command { var serverFilter string var toolFilter string var spawnInspector bool cmd := &cobra.Command{ - Use: "mcp-inspect [workflow-file]", + Use: "inspect [workflow-file]", Short: "Inspect MCP servers and list available tools, resources, and roots", Long: `Inspect MCP servers used by a workflow and display available tools, resources, and roots. @@ -204,12 +204,12 @@ This command starts each MCP server configured in the workflow, queries its capa and displays the results in a formatted table. It supports stdio, Docker, and HTTP MCP servers. Examples: - gh aw mcp-inspect # List workflows with MCP servers - gh aw mcp-inspect weekly-research # Inspect MCP servers in weekly-research.md - gh aw mcp-inspect repomind --server repo-mind # Inspect only the repo-mind server - gh aw mcp-inspect weekly-research --server github --tool create_issue # Show details for a specific tool - gh aw mcp-inspect weekly-research -v # Verbose output with detailed connection info - gh aw mcp-inspect weekly-research --inspector # Launch @modelcontextprotocol/inspector + gh aw mcp inspect # List workflows with MCP servers + gh aw mcp inspect weekly-research # Inspect MCP servers in weekly-research.md + gh aw mcp inspect repomind --server repo-mind # Inspect only the repo-mind server + gh aw mcp inspect weekly-research --server github --tool create_issue # Show details for a specific tool + gh aw mcp inspect weekly-research -v # Verbose output with detailed connection info + gh aw mcp inspect weekly-research --inspector # Launch @modelcontextprotocol/inspector The command will: - Parse the workflow file to extract MCP server configurations @@ -225,8 +225,8 @@ The command will: } verbose, _ := cmd.Flags().GetBool("verbose") - if cmd.Parent() != nil { - parentVerbose, _ := cmd.Parent().PersistentFlags().GetBool("verbose") + if cmd.Parent() != nil && cmd.Parent().Parent() != nil { + parentVerbose, _ := cmd.Parent().Parent().PersistentFlags().GetBool("verbose") verbose = verbose || parentVerbose } diff --git a/pkg/cli/mcp_test.go b/pkg/cli/mcp_test.go new file mode 100644 index 0000000000..c2bebdaf85 --- /dev/null +++ b/pkg/cli/mcp_test.go @@ -0,0 +1,437 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/githubnext/gh-aw/pkg/parser" +) + +func TestMCPCommandIntegration(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + workflowsDir := filepath.Join(tempDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Change working directory to temp for tests + oldCwd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldCwd) }() + _ = os.Chdir(tempDir) + + // Create test workflow file + testWorkflow := `--- +on: + workflow_dispatch: + +permissions: read-all +--- + +# Test Workflow + +This is a test workflow for MCP management.` + + workflowPath := filepath.Join(workflowsDir, "test-workflow.md") + if err := os.WriteFile(workflowPath, []byte(testWorkflow), 0644); err != nil { + t.Fatalf("Failed to create test workflow file: %v", err) + } + + t.Run("AddBuiltinMCPTool", func(t *testing.T) { + // Test adding GitHub tool + err := AddMCPTool("test-workflow", "github", []string{}, []string{"create_issue", "list_repos"}, false, false) + if err != nil { + t.Fatalf("Failed to add GitHub MCP tool: %v", err) + } + + // Verify the tool was added + content, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("Failed to read workflow file: %v", err) + } + + if !strings.Contains(string(content), "github:") { + t.Error("GitHub tool not found in workflow file") + } + + if !strings.Contains(string(content), "create_issue") { + t.Error("Allowed tools not found in workflow file") + } + }) + + t.Run("ListMCPTools", func(t *testing.T) { + // Test listing MCP tools + err := ListMCPTools("test-workflow", false, "table", false) + if err != nil { + t.Fatalf("Failed to list MCP tools: %v", err) + } + }) + + t.Run("AddDuplicateToolShouldFail", func(t *testing.T) { + // Test adding duplicate tool without force + err := AddMCPTool("test-workflow", "github", []string{}, []string{}, false, false) + if err == nil { + t.Error("Expected error when adding duplicate tool, got nil") + } + + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("Expected 'already exists' error, got: %v", err) + } + }) + + t.Run("AddDuplicateToolWithForce", func(t *testing.T) { + // Test adding duplicate tool with force + err := AddMCPTool("test-workflow", "github", []string{}, []string{"new_tool"}, true, false) + if err != nil { + t.Fatalf("Failed to add duplicate tool with force: %v", err) + } + + // Verify the tool was updated + content, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("Failed to read workflow file: %v", err) + } + + if !strings.Contains(string(content), "new_tool") { + t.Error("Updated allowed tools not found in workflow file") + } + }) + + t.Run("NonExistentWorkflow", func(t *testing.T) { + // Test adding tool to non-existent workflow + err := AddMCPTool("non-existent", "github", []string{}, []string{}, false, false) + if err == nil { + t.Error("Expected error for non-existent workflow, got nil") + } + + if !strings.Contains(err.Error(), "not found") { + t.Errorf("Expected 'not found' error, got: %v", err) + } + }) +} + +func TestMCPToolConfiguration(t *testing.T) { + tests := []struct { + name string + toolName string + allowedTools []string + expectError bool + }{ + { + name: "GitHub tool with allowed tools", + toolName: "github", + allowedTools: []string{"create_issue", "list_repos"}, + expectError: false, + }, + { + name: "Playwright tool with allowed tools", + toolName: "playwright", + allowedTools: []string{"navigate", "click"}, + expectError: false, + }, + { + name: "GitHub tool without allowed tools", + toolName: "github", + allowedTools: []string{}, + expectError: false, + }, + { + name: "Custom tool without type", + toolName: "custom-tool", + allowedTools: []string{}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := createToolConfig(tt.toolName, []string{}, tt.allowedTools) + + if tt.expectError { + if err == nil { + t.Error("Expected error for custom tool without type, got nil") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Check configuration structure + if tt.toolName == "github" || tt.toolName == "playwright" { + if len(tt.allowedTools) > 0 { + allowed, exists := config["allowed"] + if !exists { + t.Error("Expected 'allowed' field in config") + } + + allowedSlice, ok := allowed.([]string) + if !ok { + t.Error("Expected 'allowed' to be []string") + } + + if len(allowedSlice) != len(tt.allowedTools) { + t.Errorf("Expected %d allowed tools, got %d", len(tt.allowedTools), len(allowedSlice)) + } + } + } + }) + } +} + +func TestCustomMCPToolConfiguration(t *testing.T) { + tests := []struct { + name string + toolName string + mcpType string + command string + args []string + url string + container string + env map[string]string + headers map[string]string + allowed []string + expectError bool + errorMsg string + }{ + { + name: "stdio tool with command", + toolName: "custom-stdio", + mcpType: "stdio", + command: "python", + args: []string{"-m", "server"}, + expectError: false, + }, + { + name: "stdio tool with container", + toolName: "custom-docker", + mcpType: "stdio", + container: "my/mcp-server:latest", + env: map[string]string{"API_KEY": "test"}, + expectError: false, + }, + { + name: "http tool with url", + toolName: "custom-http", + mcpType: "http", + url: "https://api.example.com/mcp", + headers: map[string]string{"Authorization": "Bearer token"}, + expectError: false, + }, + { + name: "stdio tool without command or container", + toolName: "invalid-stdio", + mcpType: "stdio", + expectError: true, + errorMsg: "requires --command or --container", + }, + { + name: "http tool without url", + toolName: "invalid-http", + mcpType: "http", + expectError: true, + errorMsg: "requires --url", + }, + { + name: "unsupported type", + toolName: "invalid-type", + mcpType: "websocket", + expectError: true, + errorMsg: "unsupported MCP type", + }, + { + name: "custom tool without type", + toolName: "no-type", + expectError: true, + errorMsg: "requires --type flag", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + config, err := createToolConfigWithFlags( + tt.toolName, tt.mcpType, tt.command, tt.args, + tt.url, tt.container, tt.env, tt.headers, tt.allowed, + ) + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + return + } + if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("Expected error containing '%s', got: %v", tt.errorMsg, err) + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify configuration structure + mcpConfig, exists := config["mcp"] + if !exists { + t.Error("Expected 'mcp' section in config") + return + } + + mcpMap, ok := mcpConfig.(map[string]any) + if !ok { + t.Error("Expected 'mcp' to be a map") + return + } + + // Check type + if mcpMap["type"] != tt.mcpType { + t.Errorf("Expected type '%s', got '%v'", tt.mcpType, mcpMap["type"]) + } + + // Check type-specific fields + switch tt.mcpType { + case "stdio": + if tt.container != "" { + if mcpMap["container"] != tt.container { + t.Errorf("Expected container '%s', got '%v'", tt.container, mcpMap["container"]) + } + } else { + if mcpMap["command"] != tt.command { + t.Errorf("Expected command '%s', got '%v'", tt.command, mcpMap["command"]) + } + } + case "http": + if mcpMap["url"] != tt.url { + t.Errorf("Expected url '%s', got '%v'", tt.url, mcpMap["url"]) + } + } + }) + } +} + +func TestGetWorkflowPath(t *testing.T) { + // Create temporary directory + tempDir := t.TempDir() + workflowsDir := filepath.Join(tempDir, ".github", "workflows") + if err := os.MkdirAll(workflowsDir, 0755); err != nil { + t.Fatalf("Failed to create test directory: %v", err) + } + + // Change working directory to temp for tests + oldCwd, _ := os.Getwd() + defer func() { _ = os.Chdir(oldCwd) }() + _ = os.Chdir(tempDir) + + // Create test workflow file + workflowPath := filepath.Join(workflowsDir, "test.md") + if err := os.WriteFile(workflowPath, []byte("test"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + tests := []struct { + name string + workflowID string + expectError bool + }{ + { + name: "existing workflow without extension", + workflowID: "test", + expectError: false, + }, + { + name: "existing workflow with extension", + workflowID: "test.md", + expectError: false, + }, + { + name: "non-existent workflow", + workflowID: "non-existent", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path, err := getWorkflowPath(tt.workflowID) + + if tt.expectError { + if err == nil { + t.Error("Expected error for non-existent workflow, got nil") + } + return + } + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + expectedPath := filepath.Join(".github", "workflows", "test.md") + if path != expectedPath { + t.Errorf("Expected path '%s', got '%s'", expectedPath, path) + } + }) + } +} + +func TestWriteFrontmatterToFile(t *testing.T) { + // Create temporary file + tempFile, err := os.CreateTemp("", "test-*.md") + if err != nil { + t.Fatalf("Failed to create temp file: %v", err) + } + defer os.Remove(tempFile.Name()) + tempFile.Close() + + // Create test frontmatter data + frontmatter := map[string]any{ + "on": map[string]any{ + "workflow_dispatch": nil, + }, + "permissions": "read-all", + "tools": map[string]any{ + "github": map[string]any{ + "allowed": []string{"create_issue"}, + }, + }, + } + + workflowData := &parser.FrontmatterResult{ + Frontmatter: frontmatter, + Markdown: "# Test Workflow\n\nThis is a test.", + } + + // Write frontmatter to file + err = writeFrontmatterToFile(tempFile.Name(), workflowData) + if err != nil { + t.Fatalf("Failed to write frontmatter: %v", err) + } + + // Read and verify file content + content, err := os.ReadFile(tempFile.Name()) + if err != nil { + t.Fatalf("Failed to read file: %v", err) + } + + contentStr := string(content) + + // Check that frontmatter is properly formatted + if !strings.HasPrefix(contentStr, "---\n") { + t.Error("File should start with '---'") + } + + if !strings.Contains(contentStr, "# Test Workflow") { + t.Error("Markdown content not found in file") + } + + if !strings.Contains(contentStr, "github:") { + t.Error("Tools section not found in file") + } + + // Parse the written file to ensure it's valid + _, err = parser.ExtractFrontmatterFromContent(contentStr) + if err != nil { + t.Fatalf("Written file contains invalid frontmatter: %v", err) + } +}