diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go new file mode 100644 index 0000000000..48022e17a8 --- /dev/null +++ b/pkg/workflow/mcp_renderer.go @@ -0,0 +1,175 @@ +package workflow + +import ( + "strings" + + "github.com/githubnext/gh-aw/pkg/constants" + "github.com/githubnext/gh-aw/pkg/logger" +) + +var mcpRendererLog = logger.New("workflow:mcp_renderer") + +// MCPRendererOptions contains configuration options for the unified MCP renderer +type MCPRendererOptions struct { + // IncludeCopilotFields indicates if the engine requires "type" and "tools" fields (true for copilot engine) + IncludeCopilotFields bool + // InlineArgs indicates if args should be rendered inline (true for copilot) or multi-line (false for claude/custom) + InlineArgs bool + // Format specifies the output format ("json" for JSON-like, "toml" for TOML-like) + Format string + // IsLast indicates if this is the last server in the configuration (affects trailing comma) + IsLast bool +} + +// MCPConfigRendererUnified provides unified rendering methods for MCP configurations +// across different engines (Claude, Copilot, Codex, Custom) +type MCPConfigRendererUnified struct { + options MCPRendererOptions +} + +// NewMCPConfigRenderer creates a new unified MCP config renderer with the specified options +func NewMCPConfigRenderer(opts MCPRendererOptions) *MCPConfigRendererUnified { + mcpRendererLog.Printf("Creating MCP renderer: format=%s, copilot_fields=%t, inline_args=%t, is_last=%t", + opts.Format, opts.IncludeCopilotFields, opts.InlineArgs, opts.IsLast) + return &MCPConfigRendererUnified{ + options: opts, + } +} + +// RenderGitHubMCP generates the GitHub MCP server configuration +// Supports both local (Docker) and remote (hosted) modes +func (r *MCPConfigRendererUnified) RenderGitHubMCP(yaml *strings.Builder, githubTool any, workflowData *WorkflowData) { + githubType := getGitHubType(githubTool) + readOnly := getGitHubReadOnly(githubTool) + toolsets := getGitHubToolsets(githubTool) + + mcpRendererLog.Printf("Rendering GitHub MCP: type=%s, read_only=%t, toolsets=%v, format=%s", + githubType, readOnly, toolsets, r.options.Format) + + if r.options.Format == "toml" { + // TOML format doesn't support GitHub MCP yet + return + } + + yaml.WriteString(" \"github\": {\n") + + // Check if remote mode is enabled (type: remote) + if githubType == "remote" { + // Use shell environment variable instead of GitHub Actions expression to prevent template injection + RenderGitHubMCPRemoteConfig(yaml, GitHubMCPRemoteOptions{ + ReadOnly: readOnly, + Toolsets: toolsets, + AuthorizationValue: "Bearer $GITHUB_MCP_SERVER_TOKEN", + IncludeToolsField: r.options.IncludeCopilotFields, + AllowedTools: getGitHubAllowedTools(githubTool), + IncludeEnvSection: r.options.IncludeCopilotFields, + }) + } else { + // Local mode - use Docker-based GitHub MCP server (default) + githubDockerImageVersion := getGitHubDockerImageVersion(githubTool) + customArgs := getGitHubCustomArgs(githubTool) + + RenderGitHubMCPDockerConfig(yaml, GitHubMCPDockerOptions{ + ReadOnly: readOnly, + Toolsets: toolsets, + DockerImageVersion: githubDockerImageVersion, + CustomArgs: customArgs, + IncludeTypeField: r.options.IncludeCopilotFields, + AllowedTools: getGitHubAllowedTools(githubTool), + EffectiveToken: "", // Token passed via env + }) + } + + if r.options.IsLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} + +// RenderPlaywrightMCP generates the Playwright MCP server configuration +func (r *MCPConfigRendererUnified) RenderPlaywrightMCP(yaml *strings.Builder, playwrightTool any) { + mcpRendererLog.Printf("Rendering Playwright MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs) + + if r.options.Format == "toml" { + r.renderPlaywrightTOML(yaml, playwrightTool) + return + } + + // JSON format + renderPlaywrightMCPConfigWithOptions(yaml, playwrightTool, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) +} + +// renderPlaywrightTOML generates Playwright MCP configuration in TOML format +func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, playwrightTool any) { + args := generatePlaywrightDockerArgs(playwrightTool) + customArgs := getPlaywrightCustomArgs(playwrightTool) + + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers.playwright]\n") + yaml.WriteString(" command = \"npx\"\n") + yaml.WriteString(" args = [\n") + yaml.WriteString(" \"@playwright/mcp@latest\",\n") + yaml.WriteString(" \"--output-dir\",\n") + yaml.WriteString(" \"/tmp/gh-aw/mcp-logs/playwright\"") + if len(args.AllowedDomains) > 0 { + yaml.WriteString(",\n") + yaml.WriteString(" \"--allowed-origins\",\n") + yaml.WriteString(" \"" + strings.Join(args.AllowedDomains, ";") + "\"") + } + + // Append custom args if present + writeArgsToYAML(yaml, customArgs, " ") + + yaml.WriteString("\n") + yaml.WriteString(" ]\n") +} + +// RenderSafeOutputsMCP generates the Safe Outputs MCP server configuration +func (r *MCPConfigRendererUnified) RenderSafeOutputsMCP(yaml *strings.Builder) { + mcpRendererLog.Printf("Rendering Safe Outputs MCP: format=%s", r.options.Format) + + if r.options.Format == "toml" { + r.renderSafeOutputsTOML(yaml) + return + } + + // JSON format + renderSafeOutputsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields) +} + +// renderSafeOutputsTOML generates Safe Outputs MCP configuration in TOML format +func (r *MCPConfigRendererUnified) renderSafeOutputsTOML(yaml *strings.Builder) { + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID + "]\n") + yaml.WriteString(" command = \"node\"\n") + yaml.WriteString(" args = [\n") + yaml.WriteString(" \"/tmp/gh-aw/safeoutputs/mcp-server.cjs\",\n") + yaml.WriteString(" ]\n") + yaml.WriteString(" env_vars = [\"GH_AW_SAFE_OUTPUTS\", \"GH_AW_ASSETS_BRANCH\", \"GH_AW_ASSETS_MAX_SIZE_KB\", \"GH_AW_ASSETS_ALLOWED_EXTS\", \"GITHUB_REPOSITORY\", \"GITHUB_SERVER_URL\"]\n") +} + +// RenderAgenticWorkflowsMCP generates the Agentic Workflows MCP server configuration +func (r *MCPConfigRendererUnified) RenderAgenticWorkflowsMCP(yaml *strings.Builder) { + mcpRendererLog.Printf("Rendering Agentic Workflows MCP: format=%s", r.options.Format) + + if r.options.Format == "toml" { + r.renderAgenticWorkflowsTOML(yaml) + return + } + + // JSON format + renderAgenticWorkflowsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields) +} + +// renderAgenticWorkflowsTOML generates Agentic Workflows MCP configuration in TOML format +func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Builder) { + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers.agentic_workflows]\n") + yaml.WriteString(" command = \"gh\"\n") + yaml.WriteString(" args = [\n") + yaml.WriteString(" \"aw\",\n") + yaml.WriteString(" \"mcp-server\",\n") + yaml.WriteString(" ]\n") + yaml.WriteString(" env_vars = [\"GITHUB_TOKEN\"]\n") +} diff --git a/pkg/workflow/mcp_renderer_test.go b/pkg/workflow/mcp_renderer_test.go new file mode 100644 index 0000000000..064392251d --- /dev/null +++ b/pkg/workflow/mcp_renderer_test.go @@ -0,0 +1,523 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestNewMCPConfigRenderer(t *testing.T) { + tests := []struct { + name string + options MCPRendererOptions + }{ + { + name: "copilot options", + options: MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: false, + }, + }, + { + name: "claude options", + options: MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", + IsLast: true, + }, + }, + { + name: "codex options", + options: MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "toml", + IsLast: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renderer := NewMCPConfigRenderer(tt.options) + if renderer == nil { + t.Fatal("Expected non-nil renderer") + } + if renderer.options.Format != tt.options.Format { + t.Errorf("Expected format %s, got %s", tt.options.Format, renderer.options.Format) + } + if renderer.options.IncludeCopilotFields != tt.options.IncludeCopilotFields { + t.Errorf("Expected IncludeCopilotFields %t, got %t", tt.options.IncludeCopilotFields, renderer.options.IncludeCopilotFields) + } + if renderer.options.InlineArgs != tt.options.InlineArgs { + t.Errorf("Expected InlineArgs %t, got %t", tt.options.InlineArgs, renderer.options.InlineArgs) + } + if renderer.options.IsLast != tt.options.IsLast { + t.Errorf("Expected IsLast %t, got %t", tt.options.IsLast, renderer.options.IsLast) + } + }) + } +} + +func TestRenderPlaywrightMCP_JSON_Copilot(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: false, + }) + + playwrightTool := map[string]any{ + "allowed-domains": []string{"example.com"}, + } + + var yaml strings.Builder + renderer.RenderPlaywrightMCP(&yaml, playwrightTool) + + output := yaml.String() + + // Verify Copilot-specific fields + if !strings.Contains(output, `"type": "local"`) { + t.Error("Expected 'type': 'local' field for Copilot") + } + if !strings.Contains(output, `"tools": ["*"]`) { + t.Error("Expected 'tools' field for Copilot") + } + if !strings.Contains(output, `"playwright": {`) { + t.Error("Expected playwright server ID") + } + if !strings.Contains(output, `"command": "npx"`) { + t.Error("Expected npx command") + } + // Check for trailing comma (not last) + if !strings.Contains(output, "},\n") { + t.Error("Expected trailing comma for non-last server") + } +} + +func TestRenderPlaywrightMCP_JSON_Claude(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", + IsLast: true, + }) + + playwrightTool := map[string]any{ + "allowed-domains": []string{"example.com"}, + } + + var yaml strings.Builder + renderer.RenderPlaywrightMCP(&yaml, playwrightTool) + + output := yaml.String() + + // Verify Claude format (no Copilot-specific fields) + if strings.Contains(output, `"type"`) { + t.Error("Should not contain 'type' field for Claude") + } + if strings.Contains(output, `"tools"`) { + t.Error("Should not contain 'tools' field for Claude") + } + if !strings.Contains(output, `"playwright": {`) { + t.Error("Expected playwright server ID") + } + // Check for no trailing comma (last) + if !strings.Contains(output, "}\n") || strings.Contains(output, "},\n") { + t.Error("Expected no trailing comma for last server") + } +} + +func TestRenderPlaywrightMCP_TOML(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "toml", + IsLast: false, + }) + + playwrightTool := map[string]any{ + "allowed-domains": []string{"example.com"}, + } + + var yaml strings.Builder + renderer.RenderPlaywrightMCP(&yaml, playwrightTool) + + output := yaml.String() + + // Verify TOML format + if !strings.Contains(output, "[mcp_servers.playwright]") { + t.Error("Expected TOML section header") + } + if !strings.Contains(output, `command = "npx"`) { + t.Error("Expected TOML command format") + } + if !strings.Contains(output, "args = [") { + t.Error("Expected TOML args array") + } +} + +func TestRenderSafeOutputsMCP_JSON_Copilot(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: false, + }) + + var yaml strings.Builder + renderer.RenderSafeOutputsMCP(&yaml) + + output := yaml.String() + + // Verify Copilot-specific fields + if !strings.Contains(output, `"type": "local"`) { + t.Error("Expected 'type': 'local' field for Copilot") + } + if !strings.Contains(output, `"tools": ["*"]`) { + t.Error("Expected 'tools' field for Copilot") + } + if !strings.Contains(output, `"safeoutputs": {`) { + t.Error("Expected safeoutputs server ID") + } + if !strings.Contains(output, `"command": "node"`) { + t.Error("Expected node command") + } + // Check for env var with backslash escaping (Copilot format) + if !strings.Contains(output, `\${`) { + t.Error("Expected backslash-escaped env vars for Copilot") + } +} + +func TestRenderSafeOutputsMCP_JSON_Claude(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", + IsLast: true, + }) + + var yaml strings.Builder + renderer.RenderSafeOutputsMCP(&yaml) + + output := yaml.String() + + // Verify Claude format (no Copilot-specific fields) + if strings.Contains(output, `"type"`) { + t.Error("Should not contain 'type' field for Claude") + } + if strings.Contains(output, `"tools"`) { + t.Error("Should not contain 'tools' field for Claude") + } + // Check for env var without backslash escaping (Claude format) + if strings.Contains(output, `\${`) { + t.Error("Should not have backslash-escaped env vars for Claude") + } + if !strings.Contains(output, `"$GH_AW_SAFE_OUTPUTS"`) { + t.Error("Expected direct shell variable reference for Claude") + } +} + +func TestRenderSafeOutputsMCP_TOML(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "toml", + IsLast: false, + }) + + var yaml strings.Builder + renderer.RenderSafeOutputsMCP(&yaml) + + output := yaml.String() + + // Verify TOML format + if !strings.Contains(output, "[mcp_servers.safeoutputs]") { + t.Error("Expected TOML section header") + } + if !strings.Contains(output, `command = "node"`) { + t.Error("Expected TOML command format") + } + if !strings.Contains(output, "env_vars = [") { + t.Error("Expected TOML env_vars array") + } +} + +func TestRenderAgenticWorkflowsMCP_JSON_Copilot(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: true, + }) + + var yaml strings.Builder + renderer.RenderAgenticWorkflowsMCP(&yaml) + + output := yaml.String() + + // Verify Copilot-specific fields + if !strings.Contains(output, `"type": "local"`) { + t.Error("Expected 'type': 'local' field for Copilot") + } + if !strings.Contains(output, `"tools": ["*"]`) { + t.Error("Expected 'tools' field for Copilot") + } + if !strings.Contains(output, `"agentic_workflows": {`) { + t.Error("Expected agentic_workflows server ID") + } + if !strings.Contains(output, `"command": "gh"`) { + t.Error("Expected gh command") + } +} + +func TestRenderAgenticWorkflowsMCP_JSON_Claude(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", + IsLast: false, + }) + + var yaml strings.Builder + renderer.RenderAgenticWorkflowsMCP(&yaml) + + output := yaml.String() + + // Verify Claude format (no Copilot-specific fields) + if strings.Contains(output, `"type"`) { + t.Error("Should not contain 'type' field for Claude") + } + if strings.Contains(output, `"tools"`) { + t.Error("Should not contain 'tools' field for Claude") + } +} + +func TestRenderAgenticWorkflowsMCP_TOML(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "toml", + IsLast: false, + }) + + var yaml strings.Builder + renderer.RenderAgenticWorkflowsMCP(&yaml) + + output := yaml.String() + + // Verify TOML format + if !strings.Contains(output, "[mcp_servers.agentic_workflows]") { + t.Error("Expected TOML section header") + } + if !strings.Contains(output, `command = "gh"`) { + t.Error("Expected TOML command format") + } + if !strings.Contains(output, "args = [") { + t.Error("Expected TOML args array") + } +} + +func TestRenderGitHubMCP_JSON_Copilot_Local(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: false, + }) + + githubTool := map[string]any{ + "mode": "local", + "toolsets": "default", + } + + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + var yaml strings.Builder + renderer.RenderGitHubMCP(&yaml, githubTool, workflowData) + + output := yaml.String() + + // Verify GitHub MCP config + if !strings.Contains(output, `"github": {`) { + t.Error("Expected github server ID") + } + if !strings.Contains(output, `"type": "local"`) { + t.Error("Expected 'type': 'local' field for Copilot") + } + if !strings.Contains(output, `"command": "docker"`) { + t.Error("Expected docker command for local mode") + } +} + +func TestRenderGitHubMCP_JSON_Claude_Local(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", + IsLast: true, + }) + + githubTool := map[string]any{ + "mode": "local", + "toolsets": "default", + } + + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + var yaml strings.Builder + renderer.RenderGitHubMCP(&yaml, githubTool, workflowData) + + output := yaml.String() + + // Verify GitHub MCP config for Claude (no type field) + if !strings.Contains(output, `"github": {`) { + t.Error("Expected github server ID") + } + if strings.Contains(output, `"type"`) { + t.Error("Should not contain 'type' field for Claude") + } + if !strings.Contains(output, `"command": "docker"`) { + t.Error("Expected docker command for local mode") + } +} + +func TestRenderGitHubMCP_JSON_Copilot_Remote(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: false, + }) + + githubTool := map[string]any{ + "mode": "remote", + "toolsets": "default", + } + + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + var yaml strings.Builder + renderer.RenderGitHubMCP(&yaml, githubTool, workflowData) + + output := yaml.String() + + // Verify remote GitHub MCP config + if !strings.Contains(output, `"github": {`) { + t.Error("Expected github server ID") + } + if !strings.Contains(output, `"type": "http"`) { + t.Error("Expected 'type': 'http' field for remote mode") + } + if !strings.Contains(output, `"url"`) { + t.Error("Expected url field for remote mode") + } +} + +func TestRenderGitHubMCP_TOML(t *testing.T) { + renderer := NewMCPConfigRenderer(MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "toml", + IsLast: false, + }) + + githubTool := map[string]any{ + "mode": "local", + "toolsets": "default", + } + + workflowData := &WorkflowData{ + Name: "test-workflow", + } + + var yaml strings.Builder + renderer.RenderGitHubMCP(&yaml, githubTool, workflowData) + + output := yaml.String() + + // TOML format doesn't support GitHub MCP yet, should be empty + if output != "" { + t.Error("Expected empty output for TOML format (not yet supported)") + } +} + +func TestOptionCombinations(t *testing.T) { + tests := []struct { + name string + options MCPRendererOptions + }{ + { + name: "all true", + options: MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: true, + Format: "json", + IsLast: true, + }, + }, + { + name: "all false", + options: MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: false, + Format: "json", + IsLast: false, + }, + }, + { + name: "mixed copilot inline", + options: MCPRendererOptions{ + IncludeCopilotFields: true, + InlineArgs: false, + Format: "json", + IsLast: false, + }, + }, + { + name: "mixed claude inline", + options: MCPRendererOptions{ + IncludeCopilotFields: false, + InlineArgs: true, + Format: "json", + IsLast: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renderer := NewMCPConfigRenderer(tt.options) + + // Test each render method doesn't panic + var yaml strings.Builder + + playwrightTool := map[string]any{ + "allowed-domains": []string{"example.com"}, + } + renderer.RenderPlaywrightMCP(&yaml, playwrightTool) + + yaml.Reset() + renderer.RenderSafeOutputsMCP(&yaml) + + yaml.Reset() + renderer.RenderAgenticWorkflowsMCP(&yaml) + + yaml.Reset() + githubTool := map[string]any{ + "mode": "local", + "toolsets": "default", + } + workflowData := &WorkflowData{Name: "test"} + renderer.RenderGitHubMCP(&yaml, githubTool, workflowData) + }) + } +}