diff --git a/.github/workflows/mcp-inspector.lock.yml b/.github/workflows/mcp-inspector.lock.yml index b924918d3f..6aacc72bf4 100644 --- a/.github/workflows/mcp-inspector.lock.yml +++ b/.github/workflows/mcp-inspector.lock.yml @@ -390,7 +390,7 @@ jobs: with: python-version: '3.12' - name: Setup uv - uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0 + uses: astral-sh/setup-uv@e06108dd0aef18192324c70427afc47652e63a82 # v7.5.0 - name: Create gh-aw temp directory run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh # Cache memory file share configuration from frontmatter processed below diff --git a/pkg/workflow/data/action_pins.json b/pkg/workflow/data/action_pins.json index a98a9f788a..e4dac97476 100644 --- a/pkg/workflow/data/action_pins.json +++ b/pkg/workflow/data/action_pins.json @@ -10,21 +10,11 @@ "version": "v4.1.0", "sha": "a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32" }, - "actions/cache/restore@v4": { - "repo": "actions/cache/restore", - "version": "v4", - "sha": "0057852bfaa89a56745cba8c7296529d2fc39830" - }, "actions/cache/restore@v5.0.3": { "repo": "actions/cache/restore", "version": "v5.0.3", "sha": "cdf6c1fa76f9f475f3d7449005a359c84ca0f306" }, - "actions/cache/save@v4": { - "repo": "actions/cache/save", - "version": "v4", - "sha": "0057852bfaa89a56745cba8c7296529d2fc39830" - }, "actions/cache/save@v5.0.3": { "repo": "actions/cache/save", "version": "v5.0.3", @@ -40,10 +30,10 @@ "version": "v6.0.2", "sha": "de0fac2e4500dabe0009e67214ff5f5447ce83dd" }, - "actions/create-github-app-token@v3.0.0-beta.2": { + "actions/create-github-app-token@v3.0.0-beta.4": { "repo": "actions/create-github-app-token", - "version": "v3.0.0-beta.2", - "sha": "bf559f85448f9380bcfa2899dbdc01eb5b37be3a" + "version": "v3.0.0-beta.4", + "sha": "a7f885bf4560200d03183ed941cb6fb072e4b343" }, "actions/download-artifact@v8.0.1": { "repo": "actions/download-artifact", @@ -70,11 +60,6 @@ "version": "v5.2.0", "sha": "be666c2fcd27ec809703dec50e508c2fdc7f6654" }, - "actions/setup-node@v4": { - "repo": "actions/setup-node", - "version": "v4", - "sha": "49933ea5288caeca8642d1e84afbd3f7d6820020" - }, "actions/setup-node@v6.3.0": { "repo": "actions/setup-node", "version": "v6.3.0", @@ -95,10 +80,10 @@ "version": "v0.23.1", "sha": "57aae528053a48a3f6235f2d9461b05fbcb7366d" }, - "astral-sh/setup-uv@v7.4.0": { + "astral-sh/setup-uv@v7.5.0": { "repo": "astral-sh/setup-uv", - "version": "v7.4.0", - "sha": "6ee6290f1cbc4156c0bdd66691b2c144ef8df19a" + "version": "v7.5.0", + "sha": "e06108dd0aef18192324c70427afc47652e63a82" }, "cli/gh-extension-precompile@v2.1.0": { "repo": "cli/gh-extension-precompile", diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index 1fd02958c1..9a2d1bfbd5 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -1,22 +1,23 @@ // Package workflow provides YAML rendering for MCP server configurations. // -// # MCP Configuration Renderer +// # MCP Configuration Renderer Module // -// This file implements the unified MCP configuration renderer that generates -// YAML configuration for various MCP servers across different AI engines -// (Copilot, Claude, Codex, Custom). It provides a consistent interface for -// rendering MCP configurations while handling engine-specific format requirements. +// The renderer subsystem is split across focused files for maintainability: // -// Key responsibilities: -// - Unified rendering interface for all MCP server types -// - Engine-specific format handling (JSON-like vs TOML-like) -// - GitHub MCP server configuration (local Docker and remote hosted) -// - Playwright MCP server configuration -// - Safe-outputs and mcp-scripts MCP server configuration -// - Agentic-workflows MCP server configuration -// - Cache-memory MCP server configuration -// - Serena MCP server configuration -// - Custom HTTP and stdio MCP server configuration +// - mcp_renderer.go — Factory (NewMCPConfigRenderer), custom-tool switch handler +// (HandleCustomMCPToolInSwitch), top-level JSON orchestrator (RenderJSONMCPConfig), +// and shared utility (sortedMapKeys). +// - mcp_renderer_types.go — All struct and func-type definitions (MCPRendererOptions, +// MCPConfigRendererUnified, RenderCustomMCPToolConfigHandler, MCPToolRenderers, +// JSONMCPConfigOptions, GitHubMCPDockerOptions, GitHubMCPRemoteOptions). +// - mcp_renderer_github.go — GitHub MCP rendering: RenderGitHubMCP, renderGitHubTOML, +// RenderGitHubMCPDockerConfig, RenderGitHubMCPRemoteConfig. +// - mcp_renderer_builtin.go — Built-in MCP server renderers: Playwright, Serena, +// SafeOutputs, MCPScripts, AgenticWorkflows (JSON + TOML for each). +// - mcp_renderer_guard.go — Guard / access-control policy rendering: +// renderGuardPoliciesJSON, renderGuardPoliciesToml. +// +// All files belong to package workflow — no import path changes required. // // Renderer architecture: // The renderer uses the MCPConfigRendererUnified struct with MCPRendererOptions @@ -26,87 +27,44 @@ // - Format: "json" for JSON-like or "toml" for TOML-like output // - IsLast: Control trailing commas in rendered configuration // -// Supported MCP server types: -// - GitHub: Local (Docker) or remote (hosted) GitHub API access -// - Playwright: Browser automation with domain restrictions -// - Safe-outputs: Controlled output storage for AI agents -// - MCP Scripts: Custom tool execution with secret passthrough -// - Cache-memory: Memory/knowledge base management -// - Agentic-workflows: Workflow execution via gh-aw extension -// - Serena: Local search functionality -// - Custom HTTP: User-defined HTTP-based MCP servers -// - Custom stdio: User-defined stdio-based MCP servers -// // Engine-specific rendering: // - Copilot: JSON format with "type" and "tools" fields, inline args // - Claude: JSON format without Copilot fields, multi-line args // - Codex: TOML format for MCP configuration // - Custom: Same as Claude (JSON, multi-line args) // -// Configuration structure: -// All MCP servers follow the MCP Gateway Specification v1.0.0: -// - HTTP servers: type, url, headers -// - Stdio servers: type, container, entrypoint, entrypointArgs, mounts, env -// -// Variable resolution: -// The renderer handles two types of variable syntax: -// - Shell variables: $VAR or ${VAR} - resolved by shell before gateway -// - Gateway variables: ${VAR} in JSON - resolved by gateway at runtime -// -// Copilot-specific features: -// Copilot uses backslash-escaped variables (\${VAR}) for proper MCP passthrough -// and includes additional fields required by the Copilot MCP specification. -// -// Related files: -// - mcp_github_config.go: GitHub MCP server configuration -// - mcp_config_playwright_renderer.go: Playwright-specific rendering -// - mcp_config_builtin.go: Built-in MCP server rendering -// - mcp_config_custom.go: Custom MCP server rendering -// - mcp_setup_generator.go: Calls renderer for configuration generation -// // Example usage: // // renderer := NewMCPConfigRenderer(MCPRendererOptions{ -// IncludeCopilotFields: true, -// InlineArgs: true, -// Format: "json", -// IsLast: false, +// IncludeCopilotFields: true, +// InlineArgs: true, +// Format: "json", +// IsLast: false, // }) -// renderer.RenderGitHubMCP(yaml, githubTool, workflowData) +// +// renderer.RenderGitHubMCP(yaml, githubTool, workflowData) package workflow import ( - "encoding/json" "fmt" "os" "sort" "strings" - "time" - "github.com/github/gh-aw/pkg/constants" "github.com/github/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 - // ActionMode indicates the action mode for workflow compilation (dev, release, script) - ActionMode ActionMode -} - -// MCPConfigRendererUnified provides unified rendering methods for MCP configurations -// across different engines (Claude, Copilot, Codex, Custom) -type MCPConfigRendererUnified struct { - options MCPRendererOptions +// sortedMapKeys returns the keys of a map[string]string in sorted order. +// Used to produce deterministic output when writing environment variables. +func sortedMapKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys } // NewMCPConfigRenderer creates a new unified MCP config renderer with the specified options @@ -118,484 +76,6 @@ func NewMCPConfigRenderer(opts MCPRendererOptions) *MCPConfigRendererUnified { } } -// 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) - - // Get lockdown value - use detected value if lockdown wasn't explicitly set - lockdown := getGitHubLockdown(githubTool) - - // Check if automatic lockdown determination step will be generated. - // The step is skipped when lockdown is explicitly set, or when a GitHub App is configured - // (app tokens are already repo-scoped, so automatic lockdown detection is not needed). - shouldUseStepOutput := !hasGitHubLockdownExplicitlySet(githubTool) && !hasGitHubApp(githubTool) - - if shouldUseStepOutput { - // Use the detected lockdown value from the step output - // This will be evaluated at runtime based on repository visibility - lockdown = true // This is a placeholder - actual value comes from step output - } - - toolsets := getGitHubToolsets(githubTool) - - mcpRendererLog.Printf("Rendering GitHub MCP: type=%s, read_only=%t, lockdown=%t (explicit=%t, use_step=%t), toolsets=%v, format=%s", - githubType, readOnly, lockdown, hasGitHubLockdownExplicitlySet(githubTool), shouldUseStepOutput, toolsets, r.options.Format) - - if r.options.Format == "toml" { - r.renderGitHubTOML(yaml, githubTool, workflowData) - return - } - - yaml.WriteString(" \"github\": {\n") - - // Check if remote mode is enabled (type: remote) - if githubType == "remote" { - // Determine authorization value based on engine requirements - // Copilot uses MCP passthrough syntax: "Bearer \${GITHUB_PERSONAL_ACCESS_TOKEN}" - // Other engines use shell variable: "Bearer $GITHUB_MCP_SERVER_TOKEN" - authValue := "Bearer $GITHUB_MCP_SERVER_TOKEN" - if r.options.IncludeCopilotFields { - authValue = "Bearer \\${GITHUB_PERSONAL_ACCESS_TOKEN}" - } - - RenderGitHubMCPRemoteConfig(yaml, GitHubMCPRemoteOptions{ - ReadOnly: readOnly, - Lockdown: lockdown, - LockdownFromStep: shouldUseStepOutput, - Toolsets: toolsets, - AuthorizationValue: authValue, - IncludeToolsField: r.options.IncludeCopilotFields, - AllowedTools: getGitHubAllowedTools(githubTool), - IncludeEnvSection: r.options.IncludeCopilotFields, - GuardPolicies: getGitHubGuardPolicies(githubTool), - }) - } else { - // Local mode - use Docker-based GitHub MCP server (default) - githubDockerImageVersion := getGitHubDockerImageVersion(githubTool) - customArgs := getGitHubCustomArgs(githubTool) - mounts := getGitHubMounts(githubTool) - - RenderGitHubMCPDockerConfig(yaml, GitHubMCPDockerOptions{ - ReadOnly: readOnly, - Lockdown: lockdown, - LockdownFromStep: shouldUseStepOutput, - Toolsets: toolsets, - DockerImageVersion: githubDockerImageVersion, - CustomArgs: customArgs, - Mounts: mounts, - IncludeTypeField: r.options.IncludeCopilotFields, - AllowedTools: getGitHubAllowedTools(githubTool), - EffectiveToken: "", // Token passed via env - GuardPolicies: getGitHubGuardPolicies(githubTool), - }) - } - - 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) - - // Parse playwright tool configuration to strongly-typed struct - playwrightConfig := parsePlaywrightTool(playwrightTool) - - if r.options.Format == "toml" { - r.renderPlaywrightTOML(yaml, playwrightConfig) - return - } - - // JSON format - renderPlaywrightMCPConfigWithOptions(yaml, playwrightConfig, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) -} - -// renderPlaywrightTOML generates Playwright MCP configuration in TOML format -// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. -// Uses MCP Gateway spec format: container, entrypointArgs, mounts, and args fields. -func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig) { - customArgs := getPlaywrightCustomArgs(playwrightConfig) - - // Use official Playwright MCP Docker image (no version tag - only one image) - playwrightImage := "mcr.microsoft.com/playwright/mcp" - - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers.playwright]\n") - yaml.WriteString(" container = \"" + playwrightImage + "\"\n") - - // Docker runtime args (goes before container image in docker run command) - // Add security-opt and ipc flags for Chromium browser compatibility in GitHub Actions - // --security-opt seccomp=unconfined: Required for Chromium sandbox to function properly - // --ipc=host: Provides shared memory access required by Chromium - yaml.WriteString(" args = [\n") - yaml.WriteString(" \"--init\",\n") - yaml.WriteString(" \"--network\",\n") - yaml.WriteString(" \"host\",\n") - yaml.WriteString(" \"--security-opt\",\n") - yaml.WriteString(" \"seccomp=unconfined\",\n") - yaml.WriteString(" \"--ipc=host\",\n") - yaml.WriteString(" ]\n") - - // Entrypoint args for Playwright MCP server (goes after container image) - yaml.WriteString(" entrypointArgs = [\n") - yaml.WriteString(" \"--output-dir\",\n") - yaml.WriteString(" \"/tmp/gh-aw/mcp-logs/playwright\"") - - // Append custom args if present - writeArgsToYAML(yaml, customArgs, " ") - - yaml.WriteString("\n") - yaml.WriteString(" ]\n") - - // Add volume mounts - yaml.WriteString(" mounts = [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n") -} - -// RenderSerenaMCP generates Serena MCP server configuration -func (r *MCPConfigRendererUnified) RenderSerenaMCP(yaml *strings.Builder, serenaTool any) { - mcpRendererLog.Printf("Rendering Serena MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs) - - if r.options.Format == "toml" { - r.renderSerenaTOML(yaml, serenaTool) - return - } - - // JSON format - renderSerenaMCPConfigWithOptions(yaml, serenaTool, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) -} - -// renderSerenaTOML generates Serena MCP configuration in TOML format -// Supports two modes: -// - "docker" (default): Uses Docker container with stdio transport -// - "local": Uses local uvx with HTTP transport -func (r *MCPConfigRendererUnified) renderSerenaTOML(yaml *strings.Builder, serenaTool any) { - customArgs := getSerenaCustomArgs(serenaTool) - - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers.serena]\n") - - // Docker mode: use stdio transport (default) - // Select the appropriate Serena container based on requested languages - containerImage := selectSerenaContainer(serenaTool) - yaml.WriteString(" container = \"" + containerImage + ":latest\"\n") - - // Docker runtime args (--network host for network access) - yaml.WriteString(" args = [\n") - yaml.WriteString(" \"--network\",\n") - yaml.WriteString(" \"host\",\n") - yaml.WriteString(" ]\n") - - // Serena entrypoint - yaml.WriteString(" entrypoint = \"serena\"\n") - - // Entrypoint args for Serena MCP server - yaml.WriteString(" entrypointArgs = [\n") - yaml.WriteString(" \"start-mcp-server\",\n") - yaml.WriteString(" \"--context\",\n") - yaml.WriteString(" \"codex\",\n") - yaml.WriteString(" \"--project\",\n") - // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection - yaml.WriteString(" \"${GITHUB_WORKSPACE}\"") - - // Append custom args if present - for _, arg := range customArgs { - yaml.WriteString(",\n") - fmt.Fprintf(yaml, " \"%s\"", arg) - } - - yaml.WriteString("\n") - yaml.WriteString(" ]\n") - - // Add volume mount for workspace access - // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection - yaml.WriteString(" mounts = [\"${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw\"]\n") -} - -// RenderSafeOutputsMCP generates the Safe Outputs MCP server configuration -func (r *MCPConfigRendererUnified) RenderSafeOutputsMCP(yaml *strings.Builder, workflowData *WorkflowData) { - mcpRendererLog.Printf("Rendering Safe Outputs MCP: format=%s", r.options.Format) - - if r.options.Format == "toml" { - r.renderSafeOutputsTOML(yaml, workflowData) - return - } - - // JSON format - renderSafeOutputsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, workflowData) -} - -// renderSafeOutputsTOML generates Safe Outputs MCP configuration in TOML format -// Now uses HTTP transport instead of stdio, similar to mcp-scripts -func (r *MCPConfigRendererUnified) renderSafeOutputsTOML(yaml *strings.Builder, workflowData *WorkflowData) { - // Determine host based on whether agent is disabled - host := "host.docker.internal" - if workflowData != nil && workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { - // When agent is disabled (no firewall), use localhost instead of host.docker.internal - host = "localhost" - } - - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID.String() + "]\n") - yaml.WriteString(" type = \"http\"\n") - yaml.WriteString(" url = \"http://" + host + ":$GH_AW_SAFE_OUTPUTS_PORT\"\n") - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID.String() + ".headers]\n") - yaml.WriteString(" Authorization = \"$GH_AW_SAFE_OUTPUTS_API_KEY\"\n") - - // Check if GitHub tool has guard-policies configured - // If so, generate a linked write-sink guard-policy for safeoutputs - if workflowData != nil && workflowData.Tools != nil { - if githubTool, hasGitHub := workflowData.Tools["github"]; hasGitHub { - guardPolicies := deriveSafeOutputsGuardPolicyFromGitHub(githubTool) - if len(guardPolicies) > 0 { - mcpRendererLog.Print("Adding guard-policies to safeoutputs TOML (derived from GitHub guard-policy)") - // Render guard-policies in TOML format - renderGuardPoliciesToml(yaml, guardPolicies, constants.SafeOutputsMCPServerID.String()) - } - } - } -} - -// RenderMCPScriptsMCP generates the MCP Scripts server configuration -func (r *MCPConfigRendererUnified) RenderMCPScriptsMCP(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, workflowData *WorkflowData) { - mcpRendererLog.Printf("Rendering MCP Scripts: format=%s", r.options.Format) - - if r.options.Format == "toml" { - r.renderMCPScriptsTOML(yaml, mcpScripts, workflowData) - return - } - - // JSON format - renderMCPScriptsMCPConfigWithOptions(yaml, mcpScripts, r.options.IsLast, r.options.IncludeCopilotFields, workflowData) -} - -// renderMCPScriptsTOML generates MCP Scripts configuration in TOML format -// Uses HTTP transport exclusively -func (r *MCPConfigRendererUnified) renderMCPScriptsTOML(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, workflowData *WorkflowData) { - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.MCPScriptsMCPServerID.String() + "]\n") - yaml.WriteString(" type = \"http\"\n") - - // Determine host based on whether agent is disabled - host := "host.docker.internal" - if workflowData != nil && workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { - // When agent is disabled (no firewall), use localhost instead of host.docker.internal - host = "localhost" - mcpRendererLog.Print("Using localhost for mcp-scripts (agent disabled)") - } else { - mcpRendererLog.Print("Using host.docker.internal for mcp-scripts (agent enabled)") - } - - yaml.WriteString(" url = \"http://" + host + ":$GH_AW_MCP_SCRIPTS_PORT\"\n") - yaml.WriteString(" headers = { Authorization = \"$GH_AW_MCP_SCRIPTS_API_KEY\" }\n") - // Note: env_vars is not supported for HTTP transport in MCP configuration - // Environment variables are passed via the workflow job's env: section instead -} - -// RenderAgenticWorkflowsMCP generates the Agentic Workflows MCP server configuration -func (r *MCPConfigRendererUnified) RenderAgenticWorkflowsMCP(yaml *strings.Builder) { - mcpRendererLog.Printf("Rendering Agentic Workflows MCP: format=%s, action_mode=%s", r.options.Format, r.options.ActionMode) - - if r.options.Format == "toml" { - r.renderAgenticWorkflowsTOML(yaml) - return - } - - // JSON format - renderAgenticWorkflowsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.ActionMode) -} - -// renderAgenticWorkflowsTOML generates Agentic Workflows MCP configuration in TOML format -// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. -func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Builder) { - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + constants.AgenticWorkflowsMCPServerID.String() + "]\n") - - containerImage := constants.DefaultAlpineImage - var entrypoint string - var entrypointArgs []string - var mounts []string - - if r.options.ActionMode.IsDev() { - // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI - // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--validate-actor"] - // So we don't need to specify entrypoint or entrypointArgs - containerImage = constants.DevModeGhAwImage - entrypoint = "" // Use container's default ENTRYPOINT - entrypointArgs = nil // Use container's default CMD - // Only mount workspace and temp directory - binary and gh CLI are in the image - mounts = []string{constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} - } else { - // Release mode: Use minimal Alpine image with mounted binaries - entrypoint = "/opt/gh-aw/gh-aw" - entrypointArgs = []string{"mcp-server", "--validate-actor"} - // Mount gh-aw binary, gh CLI binary, workspace, and temp directory - mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} - } - - yaml.WriteString(" container = \"" + containerImage + "\"\n") - - // Only write entrypoint if it's specified (release mode) - // In dev mode, use the container's default ENTRYPOINT - if entrypoint != "" { - yaml.WriteString(" entrypoint = \"" + entrypoint + "\"\n") - } - - // Only write entrypointArgs if specified (release mode) - // In dev mode, use the container's default CMD - if entrypointArgs != nil { - yaml.WriteString(" entrypointArgs = [") - for i, arg := range entrypointArgs { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + arg + "\"") - } - yaml.WriteString("]\n") - } - - // Write mounts - yaml.WriteString(" mounts = [") - for i, mount := range mounts { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + mount + "\"") - } - yaml.WriteString("]\n") - - yaml.WriteString(" env_vars = [\"DEBUG\", \"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_ACTOR\", \"GITHUB_REPOSITORY\"]\n") -} - -// renderGitHubTOML generates GitHub MCP configuration in TOML format (for Codex engine) -func (r *MCPConfigRendererUnified) renderGitHubTOML(yaml *strings.Builder, githubTool any, workflowData *WorkflowData) { - githubType := getGitHubType(githubTool) - readOnly := getGitHubReadOnly(githubTool) - lockdown := getGitHubLockdown(githubTool) - toolsets := getGitHubToolsets(githubTool) - - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers.github]\n") - - // Add user_agent field defaulting to workflow identifier - userAgent := "github-agentic-workflow" - if workflowData != nil { - // Check if user_agent is configured in engine config first - if workflowData.EngineConfig != nil && workflowData.EngineConfig.UserAgent != "" { - userAgent = workflowData.EngineConfig.UserAgent - } else if workflowData.Name != "" { - // Fall back to sanitizing workflow name to identifier - userAgent = SanitizeIdentifier(workflowData.Name) - } - } - yaml.WriteString(" user_agent = \"" + userAgent + "\"\n") - - // Use tools.startup-timeout if specified, otherwise default to DefaultMCPStartupTimeout - startupTimeout := int(constants.DefaultMCPStartupTimeout / time.Second) - if workflowData != nil && workflowData.ToolsStartupTimeout > 0 { - startupTimeout = workflowData.ToolsStartupTimeout - } - fmt.Fprintf(yaml, " startup_timeout_sec = %d\n", startupTimeout) - - // Use tools.timeout if specified, otherwise default to DefaultToolTimeout - toolTimeout := int(constants.DefaultToolTimeout / time.Second) - if workflowData != nil && workflowData.ToolsTimeout > 0 { - toolTimeout = workflowData.ToolsTimeout - } - fmt.Fprintf(yaml, " tool_timeout_sec = %d\n", toolTimeout) - - // Check if remote mode is enabled - if githubType == "remote" { - // Remote mode - use hosted GitHub MCP server with streamable HTTP - // Use readonly endpoint if read-only mode is enabled - if readOnly { - yaml.WriteString(" url = \"https://api.githubcopilot.com/mcp-readonly/\"\n") - } else { - yaml.WriteString(" url = \"https://api.githubcopilot.com/mcp/\"\n") - } - - // Use bearer_token_env_var for authentication - yaml.WriteString(" bearer_token_env_var = \"GH_AW_GITHUB_TOKEN\"\n") - } else { - // Local mode - use Docker-based GitHub MCP server with MCP Gateway spec format - githubDockerImageVersion := getGitHubDockerImageVersion(githubTool) - customArgs := getGitHubCustomArgs(githubTool) - mounts := getGitHubMounts(githubTool) - - // MCP Gateway spec fields for containerized stdio servers - yaml.WriteString(" container = \"ghcr.io/github/github-mcp-server:" + githubDockerImageVersion + "\"\n") - - // Append custom args if present (these are Docker runtime args, go before container image) - if len(customArgs) > 0 { - yaml.WriteString(" args = [\n") - for _, arg := range customArgs { - yaml.WriteString(" \"" + arg + "\",\n") - } - yaml.WriteString(" ]\n") - } - - // Add volume mounts if present - if len(mounts) > 0 { - yaml.WriteString(" mounts = [") - for i, mount := range mounts { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + mount + "\"") - } - yaml.WriteString("]\n") - } - - // Build environment variables - envVars := make(map[string]string) - envVars["GITHUB_PERSONAL_ACCESS_TOKEN"] = "$GH_AW_GITHUB_TOKEN" - - if readOnly { - envVars["GITHUB_READ_ONLY"] = "1" - } - - if lockdown { - envVars["GITHUB_LOCKDOWN_MODE"] = "1" - } - - envVars["GITHUB_TOOLSETS"] = toolsets - - // Write environment variables in sorted order for deterministic output - envKeys := make([]string, 0, len(envVars)) - for key := range envVars { - envKeys = append(envKeys, key) - } - sort.Strings(envKeys) - - yaml.WriteString(" env = { ") - for i, key := range envKeys { - if i > 0 { - yaml.WriteString(", ") - } - fmt.Fprintf(yaml, "\"%s\" = \"%s\"", key, envVars[key]) - } - yaml.WriteString(" }\n") - - // Use env_vars array to reference environment variables - yaml.WriteString(" env_vars = [") - for i, key := range envKeys { - if i > 0 { - yaml.WriteString(", ") - } - fmt.Fprintf(yaml, "\"%s\"", key) - } - yaml.WriteString("]\n") - } -} - -// RenderCustomMCPToolConfigHandler is a function type for rendering custom MCP tool configurations -type RenderCustomMCPToolConfigHandler func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error - // HandleCustomMCPToolInSwitch processes custom MCP tools in the default case of a switch statement. // This shared function extracts the common pattern used across all workflow engines. // @@ -627,324 +107,6 @@ func HandleCustomMCPToolInSwitch( return false } -// MCPToolRenderers holds engine-specific rendering functions for each MCP tool type -type MCPToolRenderers struct { - RenderGitHub func(yaml *strings.Builder, githubTool any, isLast bool, workflowData *WorkflowData) - RenderPlaywright func(yaml *strings.Builder, playwrightTool any, isLast bool) - RenderSerena func(yaml *strings.Builder, serenaTool any, isLast bool) - RenderCacheMemory func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) - RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool) - RenderSafeOutputs func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) - RenderMCPScripts func(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool) - RenderWebFetch func(yaml *strings.Builder, isLast bool) - RenderCustomMCPConfig RenderCustomMCPToolConfigHandler -} - -// JSONMCPConfigOptions defines configuration for JSON-based MCP config rendering -type JSONMCPConfigOptions struct { - // ConfigPath is the file path for the MCP config (e.g., "/tmp/gh-aw/mcp-config/mcp-servers.json") - ConfigPath string - // Renderers contains engine-specific rendering functions for each tool - Renderers MCPToolRenderers - // FilterTool is an optional function to filter out tools before processing - // Returns true if the tool should be included, false to skip it - FilterTool func(toolName string) bool - // PostEOFCommands is an optional function to add commands after the EOF (e.g., debug output) - PostEOFCommands func(yaml *strings.Builder) - // GatewayConfig is an optional gateway configuration to include in the MCP config - // When set, adds a "gateway" section with port and apiKey for awmg to use - GatewayConfig *MCPGatewayRuntimeConfig -} - -// GitHubMCPDockerOptions defines configuration for GitHub MCP Docker rendering -type GitHubMCPDockerOptions struct { - // ReadOnly enables read-only mode for GitHub API operations - ReadOnly bool - // Lockdown enables lockdown mode for GitHub MCP server (limits content from public repos) - Lockdown bool - // LockdownFromStep indicates if lockdown value should be read from step output - LockdownFromStep bool - // Toolsets specifies the GitHub toolsets to enable - Toolsets string - // DockerImageVersion specifies the GitHub MCP server Docker image version - DockerImageVersion string - // CustomArgs are additional arguments to append to the Docker command - CustomArgs []string - // IncludeTypeField indicates whether to include the "type": "stdio" field (Copilot needs it, Claude doesn't) - IncludeTypeField bool - // AllowedTools specifies the list of allowed tools (Copilot uses this, Claude doesn't) - AllowedTools []string - // EffectiveToken is the GitHub token to use (Claude uses this, Copilot uses env passthrough) - EffectiveToken string - // Mounts specifies volume mounts for the GitHub MCP server container (format: "host:container:mode") - Mounts []string - // GuardPolicies specifies access control policies for the MCP gateway (e.g., allow-only repos/integrity) - GuardPolicies map[string]any -} - -// RenderGitHubMCPDockerConfig renders the GitHub MCP server configuration for Docker (local mode). -// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. -// Uses MCP Gateway spec format: container, entrypointArgs, and env fields. -// -// Parameters: -// - yaml: The string builder for YAML output -// - options: GitHub MCP Docker rendering options -func RenderGitHubMCPDockerConfig(yaml *strings.Builder, options GitHubMCPDockerOptions) { - // Add type field if needed (Copilot requires this, Claude doesn't) - // Per MCP Gateway Specification v1.0.0 section 4.1.2, use "stdio" for containerized servers - if options.IncludeTypeField { - yaml.WriteString(" \"type\": \"stdio\",\n") - } - - // MCP Gateway spec fields for containerized stdio servers - yaml.WriteString(" \"container\": \"ghcr.io/github/github-mcp-server:" + options.DockerImageVersion + "\",\n") - - // Append custom args if present (these are Docker runtime args, go before container image) - if len(options.CustomArgs) > 0 { - yaml.WriteString(" \"args\": [\n") - for _, arg := range options.CustomArgs { - yaml.WriteString(" \"" + arg + "\",\n") - } - yaml.WriteString(" ],\n") - } - - // Add volume mounts if present - if len(options.Mounts) > 0 { - yaml.WriteString(" \"mounts\": [\n") - for i, mount := range options.Mounts { - yaml.WriteString(" \"" + mount + "\"") - if i < len(options.Mounts)-1 { - yaml.WriteString(",") - } - yaml.WriteString("\n") - } - yaml.WriteString(" ],\n") - } - - // Note: tools field is NOT included here - the converter script adds it back - // for Copilot (see convert_gateway_config_copilot.sh). This keeps the gateway - // config compatible with the schema which doesn't have the tools field. - - // Add env section for GitHub MCP server environment variables - yaml.WriteString(" \"env\": {\n") - - // Build environment variables map - envVars := make(map[string]string) - - // GitHub token (always required) - if options.IncludeTypeField { - // Copilot engine: use escaped variable for Copilot CLI to interpolate - envVars["GITHUB_PERSONAL_ACCESS_TOKEN"] = "\\${GITHUB_MCP_SERVER_TOKEN}" - } else { - // Non-Copilot engines (Claude/Custom): use plain shell variable - envVars["GITHUB_PERSONAL_ACCESS_TOKEN"] = "$GITHUB_MCP_SERVER_TOKEN" - } - - // Read-only mode - if options.ReadOnly { - envVars["GITHUB_READ_ONLY"] = "1" - } - - // GitHub lockdown mode - if options.LockdownFromStep { - // Security: Use environment variable instead of template expression to prevent template injection - // The GITHUB_MCP_LOCKDOWN env var is set in Start MCP Gateway step from step output - // Value is already converted to "1" or "0" in the environment variable - envVars["GITHUB_LOCKDOWN_MODE"] = "$GITHUB_MCP_LOCKDOWN" - } else if options.Lockdown { - // Use explicit lockdown value from configuration - envVars["GITHUB_LOCKDOWN_MODE"] = "1" - } - - // Toolsets (always configured, defaults to "default") - envVars["GITHUB_TOOLSETS"] = options.Toolsets - - // Write environment variables in sorted order for deterministic output - envKeys := make([]string, 0, len(envVars)) - for key := range envVars { - envKeys = append(envKeys, key) - } - sort.Strings(envKeys) - - for i, key := range envKeys { - isLast := i == len(envKeys)-1 - comma := "" - if !isLast { - comma = "," - } - fmt.Fprintf(yaml, " \"%s\": \"%s\"%s\n", key, envVars[key], comma) - } - - // Close env section, with trailing comma if guard-policies follows - if len(options.GuardPolicies) > 0 { - yaml.WriteString(" },\n") - renderGuardPoliciesJSON(yaml, options.GuardPolicies, " ") - } else { - yaml.WriteString(" }\n") - } -} - -// GitHubMCPRemoteOptions defines configuration for GitHub MCP remote mode rendering -type GitHubMCPRemoteOptions struct { - // ReadOnly enables read-only mode for GitHub API operations - ReadOnly bool - // Lockdown enables lockdown mode for GitHub MCP server (limits content from public repos) - Lockdown bool - // LockdownFromStep indicates if lockdown value should be read from step output - LockdownFromStep bool - // Toolsets specifies the GitHub toolsets to enable - Toolsets string - // AuthorizationValue is the value for the Authorization header - // For Claude: "Bearer {effectiveToken}" - // For Copilot: "Bearer \\${GITHUB_PERSONAL_ACCESS_TOKEN}" - AuthorizationValue string - // IncludeToolsField indicates whether to include the "tools" field (Copilot needs it, Claude doesn't) - IncludeToolsField bool - // AllowedTools specifies the list of allowed tools (Copilot uses this, Claude doesn't) - AllowedTools []string - // IncludeEnvSection indicates whether to include the env section (Copilot needs it, Claude doesn't) - IncludeEnvSection bool - // GuardPolicies specifies access control policies for the MCP gateway (e.g., allow-only repos/integrity) - GuardPolicies map[string]any -} - -// RenderGitHubMCPRemoteConfig renders the GitHub MCP server configuration for remote (hosted) mode. -// This shared function extracts the duplicate pattern from Claude and Copilot engines. -// -// Parameters: -// - yaml: The string builder for YAML output -// - options: GitHub MCP remote rendering options -func RenderGitHubMCPRemoteConfig(yaml *strings.Builder, options GitHubMCPRemoteOptions) { - // Remote mode - use hosted GitHub MCP server - yaml.WriteString(" \"type\": \"http\",\n") - yaml.WriteString(" \"url\": \"https://api.githubcopilot.com/mcp/\",\n") - yaml.WriteString(" \"headers\": {\n") - - // Collect headers in a map - headers := make(map[string]string) - headers["Authorization"] = options.AuthorizationValue - - // Add X-MCP-Readonly header if read-only mode is enabled - if options.ReadOnly { - headers["X-MCP-Readonly"] = "true" - } - - // Add X-MCP-Lockdown header if lockdown mode is enabled - if options.LockdownFromStep { - // Security: Use environment variable instead of template expression to prevent template injection - // The GITHUB_MCP_LOCKDOWN env var contains "1" or "0", convert to "true" or "false" for header - headers["X-MCP-Lockdown"] = "$([ \"$GITHUB_MCP_LOCKDOWN\" = \"1\" ] && echo true || echo false)" - } else if options.Lockdown { - // Use explicit lockdown value from configuration - headers["X-MCP-Lockdown"] = "true" - } - - // Add X-MCP-Toolsets header if toolsets are configured - if options.Toolsets != "" { - headers["X-MCP-Toolsets"] = options.Toolsets - } - - // Write headers using helper - writeHeadersToYAML(yaml, headers, " ") - - // Close headers section - if options.IncludeToolsField || options.IncludeEnvSection || len(options.GuardPolicies) > 0 { - yaml.WriteString(" },\n") - } else { - yaml.WriteString(" }\n") - } - - // Add tools field if requested (Copilot needs it, Claude doesn't) - // Note: This is added here when IncludeToolsField is true, but in some cases - // the converter script also adds it back (see convert_gateway_config_copilot.sh). - if options.IncludeToolsField && len(options.AllowedTools) > 0 { - yaml.WriteString(" \"tools\": [\n") - for i, tool := range options.AllowedTools { - yaml.WriteString(" \"") - yaml.WriteString(tool) - yaml.WriteString("\"") - if i < len(options.AllowedTools)-1 { - yaml.WriteString(",") - } - yaml.WriteString("\n") - } - if options.IncludeEnvSection || len(options.GuardPolicies) > 0 { - yaml.WriteString(" ],\n") - } else { - yaml.WriteString(" ]\n") - } - } - - // Add env section if needed (Copilot uses this, Claude doesn't) - if options.IncludeEnvSection { - yaml.WriteString(" \"env\": {\n") - yaml.WriteString(" \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"\\${GITHUB_MCP_SERVER_TOKEN}\"\n") - // Close env section, with trailing comma if guard-policies follows - if len(options.GuardPolicies) > 0 { - yaml.WriteString(" },\n") - } else { - yaml.WriteString(" }\n") - } - } - - // Add guard-policies if configured - if len(options.GuardPolicies) > 0 { - renderGuardPoliciesJSON(yaml, options.GuardPolicies, " ") - } -} - -// renderGuardPoliciesJSON renders a "guard-policies" JSON field at the given indent level. -// The policies map contains policy names (e.g., "allow-only") mapped to their configurations. -// Renders as the last field (no trailing comma) with the given base indent. -func renderGuardPoliciesJSON(yaml *strings.Builder, policies map[string]any, indent string) { - if len(policies) == 0 { - return - } - - // Marshal to JSON with indentation, then re-indent to match the current indent level - jsonBytes, err := json.MarshalIndent(policies, indent, " ") - if err != nil { - mcpRendererLog.Printf("Failed to marshal guard-policies: %v", err) - return - } - - fmt.Fprintf(yaml, "%s\"guard-policies\": %s\n", indent, string(jsonBytes)) -} - -// renderGuardPoliciesToml renders a "guard-policies" section in TOML format for a given server. -// The policies map contains policy names (e.g., "write-sink") mapped to their configurations. -func renderGuardPoliciesToml(yaml *strings.Builder, policies map[string]any, serverID string) { - if len(policies) == 0 { - return - } - - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + serverID + ".\"guard-policies\"]\n") - - // Iterate over each policy (e.g., "write-sink") - for policyName, policyConfig := range policies { - yaml.WriteString(" \n") - yaml.WriteString(" [mcp_servers." + serverID + ".\"guard-policies\"." + policyName + "]\n") - - // Extract policy fields (e.g., "accept") - if configMap, ok := policyConfig.(map[string]any); ok { - for fieldName, fieldValue := range configMap { - // Handle array values (e.g., accept = ["private:github/gh-aw*"]) - if arrayValue, ok := fieldValue.([]string); ok { - yaml.WriteString(" " + fieldName + " = [") - for i, item := range arrayValue { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + item + "\"") - } - yaml.WriteString("]\n") - } - } - } - } -} - // RenderJSONMCPConfig renders MCP configuration in JSON format with the common mcpServers structure. // This shared function extracts the duplicate pattern from Claude, Copilot, and Custom engines. // diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go new file mode 100644 index 0000000000..796033a365 --- /dev/null +++ b/pkg/workflow/mcp_renderer_builtin.go @@ -0,0 +1,281 @@ +package workflow + +import ( + "strconv" + "strings" + + "github.com/github/gh-aw/pkg/constants" +) + +// 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) + + // Parse playwright tool configuration to strongly-typed struct + playwrightConfig := parsePlaywrightTool(playwrightTool) + + if r.options.Format == "toml" { + r.renderPlaywrightTOML(yaml, playwrightConfig) + return + } + + // JSON format + renderPlaywrightMCPConfigWithOptions(yaml, playwrightConfig, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) +} + +// renderPlaywrightTOML generates Playwright MCP configuration in TOML format +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. +// Uses MCP Gateway spec format: container, entrypointArgs, mounts, and args fields. +func (r *MCPConfigRendererUnified) renderPlaywrightTOML(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig) { + customArgs := getPlaywrightCustomArgs(playwrightConfig) + + // Use official Playwright MCP Docker image (no version tag - only one image) + playwrightImage := "mcr.microsoft.com/playwright/mcp" + + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers.playwright]\n") + yaml.WriteString(" container = \"" + playwrightImage + "\"\n") + + // Docker runtime args (goes before container image in docker run command) + // Add security-opt and ipc flags for Chromium browser compatibility in GitHub Actions + // --security-opt seccomp=unconfined: Required for Chromium sandbox to function properly + // --ipc=host: Provides shared memory access required by Chromium + yaml.WriteString(" args = [\n") + yaml.WriteString(" \"--init\",\n") + yaml.WriteString(" \"--network\",\n") + yaml.WriteString(" \"host\",\n") + yaml.WriteString(" \"--security-opt\",\n") + yaml.WriteString(" \"seccomp=unconfined\",\n") + yaml.WriteString(" \"--ipc=host\",\n") + yaml.WriteString(" ]\n") + + // Entrypoint args for Playwright MCP server (goes after container image) + yaml.WriteString(" entrypointArgs = [\n") + yaml.WriteString(" \"--output-dir\",\n") + yaml.WriteString(" \"/tmp/gh-aw/mcp-logs/playwright\"") + + // Append custom args if present + writeArgsToYAML(yaml, customArgs, " ") + + yaml.WriteString("\n") + yaml.WriteString(" ]\n") + + // Add volume mounts + yaml.WriteString(" mounts = [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n") +} + +// RenderSerenaMCP generates Serena MCP server configuration +func (r *MCPConfigRendererUnified) RenderSerenaMCP(yaml *strings.Builder, serenaTool any) { + mcpRendererLog.Printf("Rendering Serena MCP: format=%s, inline_args=%t", r.options.Format, r.options.InlineArgs) + + if r.options.Format == "toml" { + r.renderSerenaTOML(yaml, serenaTool) + return + } + + // JSON format + renderSerenaMCPConfigWithOptions(yaml, serenaTool, r.options.IsLast, r.options.IncludeCopilotFields, r.options.InlineArgs) +} + +// renderSerenaTOML generates Serena MCP configuration in TOML format +// Supports two modes: +// - "docker" (default): Uses Docker container with stdio transport +// - "local": Uses local uvx with HTTP transport +func (r *MCPConfigRendererUnified) renderSerenaTOML(yaml *strings.Builder, serenaTool any) { + customArgs := getSerenaCustomArgs(serenaTool) + + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers.serena]\n") + + // Docker mode: use stdio transport (default) + // Select the appropriate Serena container based on requested languages + containerImage := selectSerenaContainer(serenaTool) + yaml.WriteString(" container = \"" + containerImage + ":latest\"\n") + + // Docker runtime args (--network host for network access) + yaml.WriteString(" args = [\n") + yaml.WriteString(" \"--network\",\n") + yaml.WriteString(" \"host\",\n") + yaml.WriteString(" ]\n") + + // Serena entrypoint + yaml.WriteString(" entrypoint = \"serena\"\n") + + // Entrypoint args for Serena MCP server + yaml.WriteString(" entrypointArgs = [\n") + yaml.WriteString(" \"start-mcp-server\",\n") + yaml.WriteString(" \"--context\",\n") + yaml.WriteString(" \"codex\",\n") + yaml.WriteString(" \"--project\",\n") + // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection + yaml.WriteString(" \"${GITHUB_WORKSPACE}\"") + + // Append custom args if present + for _, arg := range customArgs { + yaml.WriteString(",\n") + yaml.WriteString(" " + strconv.Quote(arg)) + } + + yaml.WriteString("\n") + yaml.WriteString(" ]\n") + + // Add volume mount for workspace access + // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection + yaml.WriteString(" mounts = [\"${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}:rw\"]\n") +} + +// RenderSafeOutputsMCP generates the Safe Outputs MCP server configuration +func (r *MCPConfigRendererUnified) RenderSafeOutputsMCP(yaml *strings.Builder, workflowData *WorkflowData) { + mcpRendererLog.Printf("Rendering Safe Outputs MCP: format=%s", r.options.Format) + + if r.options.Format == "toml" { + r.renderSafeOutputsTOML(yaml, workflowData) + return + } + + // JSON format + renderSafeOutputsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, workflowData) +} + +// renderSafeOutputsTOML generates Safe Outputs MCP configuration in TOML format +// Now uses HTTP transport instead of stdio, similar to mcp-scripts +func (r *MCPConfigRendererUnified) renderSafeOutputsTOML(yaml *strings.Builder, workflowData *WorkflowData) { + // Determine host based on whether agent is disabled + host := "host.docker.internal" + if workflowData != nil && workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { + // When agent is disabled (no firewall), use localhost instead of host.docker.internal + host = "localhost" + } + + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID.String() + "]\n") + yaml.WriteString(" type = \"http\"\n") + yaml.WriteString(" url = \"http://" + host + ":$GH_AW_SAFE_OUTPUTS_PORT\"\n") + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + constants.SafeOutputsMCPServerID.String() + ".headers]\n") + yaml.WriteString(" Authorization = \"$GH_AW_SAFE_OUTPUTS_API_KEY\"\n") + + // Check if GitHub tool has guard-policies configured + // If so, generate a linked write-sink guard-policy for safeoutputs + if workflowData != nil && workflowData.Tools != nil { + if githubTool, hasGitHub := workflowData.Tools["github"]; hasGitHub { + guardPolicies := deriveSafeOutputsGuardPolicyFromGitHub(githubTool) + if len(guardPolicies) > 0 { + mcpRendererLog.Print("Adding guard-policies to safeoutputs TOML (derived from GitHub guard-policy)") + // Render guard-policies in TOML format + renderGuardPoliciesToml(yaml, guardPolicies, constants.SafeOutputsMCPServerID.String()) + } + } + } +} + +// RenderMCPScriptsMCP generates the MCP Scripts server configuration +func (r *MCPConfigRendererUnified) RenderMCPScriptsMCP(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, workflowData *WorkflowData) { + mcpRendererLog.Printf("Rendering MCP Scripts: format=%s", r.options.Format) + + if r.options.Format == "toml" { + r.renderMCPScriptsTOML(yaml, mcpScripts, workflowData) + return + } + + // JSON format + renderMCPScriptsMCPConfigWithOptions(yaml, mcpScripts, r.options.IsLast, r.options.IncludeCopilotFields, workflowData) +} + +// renderMCPScriptsTOML generates MCP Scripts configuration in TOML format +// Uses HTTP transport exclusively +func (r *MCPConfigRendererUnified) renderMCPScriptsTOML(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, workflowData *WorkflowData) { + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + constants.MCPScriptsMCPServerID.String() + "]\n") + yaml.WriteString(" type = \"http\"\n") + + // Determine host based on whether agent is disabled + host := "host.docker.internal" + if workflowData != nil && workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { + // When agent is disabled (no firewall), use localhost instead of host.docker.internal + host = "localhost" + mcpRendererLog.Print("Using localhost for mcp-scripts (agent disabled)") + } else { + mcpRendererLog.Print("Using host.docker.internal for mcp-scripts (agent enabled)") + } + + yaml.WriteString(" url = \"http://" + host + ":$GH_AW_MCP_SCRIPTS_PORT\"\n") + yaml.WriteString(" headers = { Authorization = \"$GH_AW_MCP_SCRIPTS_API_KEY\" }\n") + // Note: env_vars is not supported for HTTP transport in MCP configuration + // Environment variables are passed via the workflow job's env: section instead +} + +// RenderAgenticWorkflowsMCP generates the Agentic Workflows MCP server configuration +func (r *MCPConfigRendererUnified) RenderAgenticWorkflowsMCP(yaml *strings.Builder) { + mcpRendererLog.Printf("Rendering Agentic Workflows MCP: format=%s, action_mode=%s", r.options.Format, r.options.ActionMode) + + if r.options.Format == "toml" { + r.renderAgenticWorkflowsTOML(yaml) + return + } + + // JSON format + renderAgenticWorkflowsMCPConfigWithOptions(yaml, r.options.IsLast, r.options.IncludeCopilotFields, r.options.ActionMode) +} + +// renderAgenticWorkflowsTOML generates Agentic Workflows MCP configuration in TOML format +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. +func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Builder) { + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + constants.AgenticWorkflowsMCPServerID.String() + "]\n") + + containerImage := constants.DefaultAlpineImage + var entrypoint string + var entrypointArgs []string + var mounts []string + + if r.options.ActionMode.IsDev() { + // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI + // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--validate-actor"] + // So we don't need to specify entrypoint or entrypointArgs + containerImage = constants.DevModeGhAwImage + entrypoint = "" // Use container's default ENTRYPOINT + entrypointArgs = nil // Use container's default CMD + // Only mount workspace and temp directory - binary and gh CLI are in the image + mounts = []string{constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} + } else { + // Release mode: Use minimal Alpine image with mounted binaries + entrypoint = "/opt/gh-aw/gh-aw" + entrypointArgs = []string{"mcp-server", "--validate-actor"} + // Mount gh-aw binary, gh CLI binary, workspace, and temp directory + mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} + } + + yaml.WriteString(" container = \"" + containerImage + "\"\n") + + // Only write entrypoint if it's specified (release mode) + // In dev mode, use the container's default ENTRYPOINT + if entrypoint != "" { + yaml.WriteString(" entrypoint = \"" + entrypoint + "\"\n") + } + + // Only write entrypointArgs if specified (release mode) + // In dev mode, use the container's default CMD + if entrypointArgs != nil { + yaml.WriteString(" entrypointArgs = [") + for i, arg := range entrypointArgs { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + arg + "\"") + } + yaml.WriteString("]\n") + } + + // Write mounts + yaml.WriteString(" mounts = [") + for i, mount := range mounts { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + mount + "\"") + } + yaml.WriteString("]\n") + + yaml.WriteString(" env_vars = [\"DEBUG\", \"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_ACTOR\", \"GITHUB_REPOSITORY\"]\n") +} diff --git a/pkg/workflow/mcp_renderer_github.go b/pkg/workflow/mcp_renderer_github.go new file mode 100644 index 0000000000..30ac3c45e5 --- /dev/null +++ b/pkg/workflow/mcp_renderer_github.go @@ -0,0 +1,394 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/github/gh-aw/pkg/constants" +) + +// 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) + + // Get lockdown value - use detected value if lockdown wasn't explicitly set + lockdown := getGitHubLockdown(githubTool) + + // Check if automatic lockdown determination step will be generated. + // The step is skipped when lockdown is explicitly set, or when a GitHub App is configured + // (app tokens are already repo-scoped, so automatic lockdown detection is not needed). + shouldUseStepOutput := !hasGitHubLockdownExplicitlySet(githubTool) && !hasGitHubApp(githubTool) + + if shouldUseStepOutput { + // Use the detected lockdown value from the step output + // This will be evaluated at runtime based on repository visibility + lockdown = true // This is a placeholder - actual value comes from step output + } + + toolsets := getGitHubToolsets(githubTool) + + mcpRendererLog.Printf("Rendering GitHub MCP: type=%s, read_only=%t, lockdown=%t (explicit=%t, use_step=%t), toolsets=%v, format=%s", + githubType, readOnly, lockdown, hasGitHubLockdownExplicitlySet(githubTool), shouldUseStepOutput, toolsets, r.options.Format) + + if r.options.Format == "toml" { + r.renderGitHubTOML(yaml, githubTool, workflowData) + return + } + + yaml.WriteString(" \"github\": {\n") + + // Check if remote mode is enabled (type: remote) + if githubType == "remote" { + // Determine authorization value based on engine requirements + // Copilot uses MCP passthrough syntax: "Bearer \${GITHUB_PERSONAL_ACCESS_TOKEN}" + // Other engines use shell variable: "Bearer $GITHUB_MCP_SERVER_TOKEN" + authValue := "Bearer $GITHUB_MCP_SERVER_TOKEN" + if r.options.IncludeCopilotFields { + authValue = "Bearer \\${GITHUB_PERSONAL_ACCESS_TOKEN}" + } + + RenderGitHubMCPRemoteConfig(yaml, GitHubMCPRemoteOptions{ + ReadOnly: readOnly, + Lockdown: lockdown, + LockdownFromStep: shouldUseStepOutput, + Toolsets: toolsets, + AuthorizationValue: authValue, + IncludeToolsField: r.options.IncludeCopilotFields, + AllowedTools: getGitHubAllowedTools(githubTool), + IncludeEnvSection: r.options.IncludeCopilotFields, + GuardPolicies: getGitHubGuardPolicies(githubTool), + }) + } else { + // Local mode - use Docker-based GitHub MCP server (default) + githubDockerImageVersion := getGitHubDockerImageVersion(githubTool) + customArgs := getGitHubCustomArgs(githubTool) + mounts := getGitHubMounts(githubTool) + + RenderGitHubMCPDockerConfig(yaml, GitHubMCPDockerOptions{ + ReadOnly: readOnly, + Lockdown: lockdown, + LockdownFromStep: shouldUseStepOutput, + Toolsets: toolsets, + DockerImageVersion: githubDockerImageVersion, + CustomArgs: customArgs, + Mounts: mounts, + IncludeTypeField: r.options.IncludeCopilotFields, + AllowedTools: getGitHubAllowedTools(githubTool), + EffectiveToken: "", // Token passed via env + GuardPolicies: getGitHubGuardPolicies(githubTool), + }) + } + + if r.options.IsLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} + +// renderGitHubTOML generates GitHub MCP configuration in TOML format (for Codex engine) +func (r *MCPConfigRendererUnified) renderGitHubTOML(yaml *strings.Builder, githubTool any, workflowData *WorkflowData) { + githubType := getGitHubType(githubTool) + readOnly := getGitHubReadOnly(githubTool) + lockdown := getGitHubLockdown(githubTool) + toolsets := getGitHubToolsets(githubTool) + + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers.github]\n") + + // Add user_agent field defaulting to workflow identifier + userAgent := "github-agentic-workflow" + if workflowData != nil { + // Check if user_agent is configured in engine config first + if workflowData.EngineConfig != nil && workflowData.EngineConfig.UserAgent != "" { + userAgent = workflowData.EngineConfig.UserAgent + } else if workflowData.Name != "" { + // Fall back to sanitizing workflow name to identifier + userAgent = SanitizeIdentifier(workflowData.Name) + } + } + yaml.WriteString(" user_agent = \"" + userAgent + "\"\n") + + // Use tools.startup-timeout if specified, otherwise default to DefaultMCPStartupTimeout + startupTimeout := int(constants.DefaultMCPStartupTimeout / time.Second) + if workflowData != nil && workflowData.ToolsStartupTimeout > 0 { + startupTimeout = workflowData.ToolsStartupTimeout + } + fmt.Fprintf(yaml, " startup_timeout_sec = %d\n", startupTimeout) + + // Use tools.timeout if specified, otherwise default to DefaultToolTimeout + toolTimeout := int(constants.DefaultToolTimeout / time.Second) + if workflowData != nil && workflowData.ToolsTimeout > 0 { + toolTimeout = workflowData.ToolsTimeout + } + fmt.Fprintf(yaml, " tool_timeout_sec = %d\n", toolTimeout) + + // Check if remote mode is enabled + if githubType == "remote" { + // Remote mode - use hosted GitHub MCP server with streamable HTTP + // Use readonly endpoint if read-only mode is enabled + if readOnly { + yaml.WriteString(" url = \"https://api.githubcopilot.com/mcp-readonly/\"\n") + } else { + yaml.WriteString(" url = \"https://api.githubcopilot.com/mcp/\"\n") + } + + // Use bearer_token_env_var for authentication + yaml.WriteString(" bearer_token_env_var = \"GH_AW_GITHUB_TOKEN\"\n") + } else { + // Local mode - use Docker-based GitHub MCP server with MCP Gateway spec format + githubDockerImageVersion := getGitHubDockerImageVersion(githubTool) + customArgs := getGitHubCustomArgs(githubTool) + mounts := getGitHubMounts(githubTool) + + // MCP Gateway spec fields for containerized stdio servers + yaml.WriteString(" container = \"ghcr.io/github/github-mcp-server:" + githubDockerImageVersion + "\"\n") + + // Append custom args if present (these are Docker runtime args, go before container image) + if len(customArgs) > 0 { + yaml.WriteString(" args = [\n") + for _, arg := range customArgs { + yaml.WriteString(" " + strconv.Quote(arg) + ",\n") + } + yaml.WriteString(" ]\n") + } + + // Add volume mounts if present + if len(mounts) > 0 { + yaml.WriteString(" mounts = [") + for i, mount := range mounts { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString(strconv.Quote(mount)) + } + yaml.WriteString("]\n") + } + + // Build environment variables + envVars := make(map[string]string) + envVars["GITHUB_PERSONAL_ACCESS_TOKEN"] = "$GH_AW_GITHUB_TOKEN" + + if readOnly { + envVars["GITHUB_READ_ONLY"] = "1" + } + + if lockdown { + envVars["GITHUB_LOCKDOWN_MODE"] = "1" + } + + envVars["GITHUB_TOOLSETS"] = toolsets + + // Write environment variables in sorted order for deterministic output + envKeys := sortedMapKeys(envVars) + + yaml.WriteString(" env = { ") + for i, key := range envKeys { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "\"%s\" = \"%s\"", key, envVars[key]) + } + yaml.WriteString(" }\n") + + // Use env_vars array to reference environment variables + yaml.WriteString(" env_vars = [") + for i, key := range envKeys { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "\"%s\"", key) + } + yaml.WriteString("]\n") + } +} + +// RenderGitHubMCPDockerConfig renders the GitHub MCP server configuration for Docker (local mode). +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. +// Uses MCP Gateway spec format: container, entrypointArgs, and env fields. +// +// Parameters: +// - yaml: The string builder for YAML output +// - options: GitHub MCP Docker rendering options +func RenderGitHubMCPDockerConfig(yaml *strings.Builder, options GitHubMCPDockerOptions) { + // Add type field if needed (Copilot requires this, Claude doesn't) + // Per MCP Gateway Specification v1.0.0 section 4.1.2, use "stdio" for containerized servers + if options.IncludeTypeField { + yaml.WriteString(" \"type\": \"stdio\",\n") + } + + // MCP Gateway spec fields for containerized stdio servers + yaml.WriteString(" \"container\": \"ghcr.io/github/github-mcp-server:" + options.DockerImageVersion + "\",\n") + + // Append custom args if present (these are Docker runtime args, go before container image) + if len(options.CustomArgs) > 0 { + yaml.WriteString(" \"args\": [\n") + for _, arg := range options.CustomArgs { + quotedArg, _ := json.Marshal(arg) + yaml.WriteString(" " + string(quotedArg) + ",\n") + } + yaml.WriteString(" ],\n") + } + + // Add volume mounts if present + if len(options.Mounts) > 0 { + yaml.WriteString(" \"mounts\": [\n") + for i, mount := range options.Mounts { + quotedMount, _ := json.Marshal(mount) + yaml.WriteString(" " + string(quotedMount)) + if i < len(options.Mounts)-1 { + yaml.WriteString(",") + } + yaml.WriteString("\n") + } + yaml.WriteString(" ],\n") + } + + // Note: tools field is NOT included here - the converter script adds it back + // for Copilot (see convert_gateway_config_copilot.sh). This keeps the gateway + // config compatible with the schema which doesn't have the tools field. + + // Add env section for GitHub MCP server environment variables + yaml.WriteString(" \"env\": {\n") + + // Build environment variables map + envVars := make(map[string]string) + + // GitHub token (always required) + if options.IncludeTypeField { + // Copilot engine: use escaped variable for Copilot CLI to interpolate + envVars["GITHUB_PERSONAL_ACCESS_TOKEN"] = "\\${GITHUB_MCP_SERVER_TOKEN}" + } else { + // Non-Copilot engines (Claude/Custom): use plain shell variable + envVars["GITHUB_PERSONAL_ACCESS_TOKEN"] = "$GITHUB_MCP_SERVER_TOKEN" + } + + // Read-only mode + if options.ReadOnly { + envVars["GITHUB_READ_ONLY"] = "1" + } + + // GitHub lockdown mode + if options.LockdownFromStep { + // Security: Use environment variable instead of template expression to prevent template injection + // The GITHUB_MCP_LOCKDOWN env var is set in Start MCP Gateway step from step output + // Value is already converted to "1" or "0" in the environment variable + envVars["GITHUB_LOCKDOWN_MODE"] = "$GITHUB_MCP_LOCKDOWN" + } else if options.Lockdown { + // Use explicit lockdown value from configuration + envVars["GITHUB_LOCKDOWN_MODE"] = "1" + } + + // Toolsets (always configured, defaults to "default") + envVars["GITHUB_TOOLSETS"] = options.Toolsets + + // Write environment variables in sorted order for deterministic output + envKeys := sortedMapKeys(envVars) + + for i, key := range envKeys { + isLast := i == len(envKeys)-1 + comma := "" + if !isLast { + comma = "," + } + fmt.Fprintf(yaml, " \"%s\": \"%s\"%s\n", key, envVars[key], comma) + } + + // Close env section, with trailing comma if guard-policies follows + if len(options.GuardPolicies) > 0 { + yaml.WriteString(" },\n") + renderGuardPoliciesJSON(yaml, options.GuardPolicies, " ") + } else { + yaml.WriteString(" }\n") + } +} + +// RenderGitHubMCPRemoteConfig renders the GitHub MCP server configuration for remote (hosted) mode. +// This shared function extracts the duplicate pattern from Claude and Copilot engines. +// +// Parameters: +// - yaml: The string builder for YAML output +// - options: GitHub MCP remote rendering options +func RenderGitHubMCPRemoteConfig(yaml *strings.Builder, options GitHubMCPRemoteOptions) { + // Remote mode - use hosted GitHub MCP server + yaml.WriteString(" \"type\": \"http\",\n") + yaml.WriteString(" \"url\": \"https://api.githubcopilot.com/mcp/\",\n") + yaml.WriteString(" \"headers\": {\n") + + // Collect headers in a map + headers := make(map[string]string) + headers["Authorization"] = options.AuthorizationValue + + // Add X-MCP-Readonly header if read-only mode is enabled + if options.ReadOnly { + headers["X-MCP-Readonly"] = "true" + } + + // Add X-MCP-Lockdown header if lockdown mode is enabled + if options.LockdownFromStep { + // Security: Use environment variable instead of template expression to prevent template injection + // The GITHUB_MCP_LOCKDOWN env var contains "1" or "0", convert to "true" or "false" for header + headers["X-MCP-Lockdown"] = "$([ \"$GITHUB_MCP_LOCKDOWN\" = \"1\" ] && echo true || echo false)" + } else if options.Lockdown { + // Use explicit lockdown value from configuration + headers["X-MCP-Lockdown"] = "true" + } + + // Add X-MCP-Toolsets header if toolsets are configured + if options.Toolsets != "" { + headers["X-MCP-Toolsets"] = options.Toolsets + } + + // Write headers using helper + writeHeadersToYAML(yaml, headers, " ") + + // Close headers section + if options.IncludeToolsField || options.IncludeEnvSection || len(options.GuardPolicies) > 0 { + yaml.WriteString(" },\n") + } else { + yaml.WriteString(" }\n") + } + + // Add tools field if requested (Copilot needs it, Claude doesn't) + // Note: This is added here when IncludeToolsField is true, but in some cases + // the converter script also adds it back (see convert_gateway_config_copilot.sh). + if options.IncludeToolsField && len(options.AllowedTools) > 0 { + yaml.WriteString(" \"tools\": [\n") + for i, tool := range options.AllowedTools { + yaml.WriteString(" \"") + yaml.WriteString(tool) + yaml.WriteString("\"") + if i < len(options.AllowedTools)-1 { + yaml.WriteString(",") + } + yaml.WriteString("\n") + } + if options.IncludeEnvSection || len(options.GuardPolicies) > 0 { + yaml.WriteString(" ],\n") + } else { + yaml.WriteString(" ]\n") + } + } + + // Add env section if needed (Copilot uses this, Claude doesn't) + if options.IncludeEnvSection { + yaml.WriteString(" \"env\": {\n") + yaml.WriteString(" \"GITHUB_PERSONAL_ACCESS_TOKEN\": \"\\${GITHUB_MCP_SERVER_TOKEN}\"\n") + // Close env section, with trailing comma if guard-policies follows + if len(options.GuardPolicies) > 0 { + yaml.WriteString(" },\n") + } else { + yaml.WriteString(" }\n") + } + } + + // Add guard-policies if configured + if len(options.GuardPolicies) > 0 { + renderGuardPoliciesJSON(yaml, options.GuardPolicies, " ") + } +} diff --git a/pkg/workflow/mcp_renderer_guard.go b/pkg/workflow/mcp_renderer_guard.go new file mode 100644 index 0000000000..6e53501215 --- /dev/null +++ b/pkg/workflow/mcp_renderer_guard.go @@ -0,0 +1,59 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "strings" +) + +// renderGuardPoliciesJSON renders a "guard-policies" JSON field at the given indent level. +// The policies map contains policy names (e.g., "allow-only") mapped to their configurations. +// Renders as the last field (no trailing comma) with the given base indent. +func renderGuardPoliciesJSON(yaml *strings.Builder, policies map[string]any, indent string) { + if len(policies) == 0 { + return + } + + // Marshal to JSON with indentation, then re-indent to match the current indent level + jsonBytes, err := json.MarshalIndent(policies, indent, " ") + if err != nil { + mcpRendererLog.Printf("Failed to marshal guard-policies: %v", err) + return + } + + fmt.Fprintf(yaml, "%s\"guard-policies\": %s\n", indent, string(jsonBytes)) +} + +// renderGuardPoliciesToml renders a "guard-policies" section in TOML format for a given server. +// The policies map contains policy names (e.g., "write-sink") mapped to their configurations. +func renderGuardPoliciesToml(yaml *strings.Builder, policies map[string]any, serverID string) { + if len(policies) == 0 { + return + } + + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + serverID + ".\"guard-policies\"]\n") + + // Iterate over each policy (e.g., "write-sink") + for policyName, policyConfig := range policies { + yaml.WriteString(" \n") + yaml.WriteString(" [mcp_servers." + serverID + ".\"guard-policies\"." + policyName + "]\n") + + // Extract policy fields (e.g., "accept") + if configMap, ok := policyConfig.(map[string]any); ok { + for fieldName, fieldValue := range configMap { + // Handle array values (e.g., accept = ["private:github/gh-aw*"]) + if arrayValue, ok := fieldValue.([]string); ok { + yaml.WriteString(" " + fieldName + " = [") + for i, item := range arrayValue { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + item + "\"") + } + yaml.WriteString("]\n") + } + } + } + } +} diff --git a/pkg/workflow/mcp_renderer_types.go b/pkg/workflow/mcp_renderer_types.go new file mode 100644 index 0000000000..de0b74aa45 --- /dev/null +++ b/pkg/workflow/mcp_renderer_types.go @@ -0,0 +1,105 @@ +package workflow + +import "strings" + +// 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 + // ActionMode indicates the action mode for workflow compilation (dev, release, script) + ActionMode ActionMode +} + +// MCPConfigRendererUnified provides unified rendering methods for MCP configurations +// across different engines (Claude, Copilot, Codex, Custom) +type MCPConfigRendererUnified struct { + options MCPRendererOptions +} + +// RenderCustomMCPToolConfigHandler is a function type for rendering custom MCP tool configurations +type RenderCustomMCPToolConfigHandler func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error + +// MCPToolRenderers holds engine-specific rendering functions for each MCP tool type +type MCPToolRenderers struct { + RenderGitHub func(yaml *strings.Builder, githubTool any, isLast bool, workflowData *WorkflowData) + RenderPlaywright func(yaml *strings.Builder, playwrightTool any, isLast bool) + RenderSerena func(yaml *strings.Builder, serenaTool any, isLast bool) + RenderCacheMemory func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) + RenderAgenticWorkflows func(yaml *strings.Builder, isLast bool) + RenderSafeOutputs func(yaml *strings.Builder, isLast bool, workflowData *WorkflowData) + RenderMCPScripts func(yaml *strings.Builder, mcpScripts *MCPScriptsConfig, isLast bool) + RenderWebFetch func(yaml *strings.Builder, isLast bool) + RenderCustomMCPConfig RenderCustomMCPToolConfigHandler +} + +// JSONMCPConfigOptions defines configuration for JSON-based MCP config rendering +type JSONMCPConfigOptions struct { + // ConfigPath is the file path for the MCP config (e.g., "/tmp/gh-aw/mcp-config/mcp-servers.json") + ConfigPath string + // Renderers contains engine-specific rendering functions for each tool + Renderers MCPToolRenderers + // FilterTool is an optional function to filter out tools before processing + // Returns true if the tool should be included, false to skip it + FilterTool func(toolName string) bool + // PostEOFCommands is an optional function to add commands after the EOF (e.g., debug output) + PostEOFCommands func(yaml *strings.Builder) + // GatewayConfig is an optional gateway configuration to include in the MCP config + // When set, adds a "gateway" section with port and apiKey for awmg to use + GatewayConfig *MCPGatewayRuntimeConfig +} + +// GitHubMCPDockerOptions defines configuration for GitHub MCP Docker rendering +type GitHubMCPDockerOptions struct { + // ReadOnly enables read-only mode for GitHub API operations + ReadOnly bool + // Lockdown enables lockdown mode for GitHub MCP server (limits content from public repos) + Lockdown bool + // LockdownFromStep indicates if lockdown value should be read from step output + LockdownFromStep bool + // Toolsets specifies the GitHub toolsets to enable + Toolsets string + // DockerImageVersion specifies the GitHub MCP server Docker image version + DockerImageVersion string + // CustomArgs are additional arguments to append to the Docker command + CustomArgs []string + // IncludeTypeField indicates whether to include the "type": "stdio" field (Copilot needs it, Claude doesn't) + IncludeTypeField bool + // AllowedTools specifies the list of allowed tools (Copilot uses this, Claude doesn't) + AllowedTools []string + // EffectiveToken is the GitHub token to use (Claude uses this, Copilot uses env passthrough) + EffectiveToken string + // Mounts specifies volume mounts for the GitHub MCP server container (format: "host:container:mode") + Mounts []string + // GuardPolicies specifies access control policies for the MCP gateway (e.g., allow-only repos/integrity) + GuardPolicies map[string]any +} + +// GitHubMCPRemoteOptions defines configuration for GitHub MCP remote mode rendering +type GitHubMCPRemoteOptions struct { + // ReadOnly enables read-only mode for GitHub API operations + ReadOnly bool + // Lockdown enables lockdown mode for GitHub MCP server (limits content from public repos) + Lockdown bool + // LockdownFromStep indicates if lockdown value should be read from step output + LockdownFromStep bool + // Toolsets specifies the GitHub toolsets to enable + Toolsets string + // AuthorizationValue is the value for the Authorization header + // For Claude: "Bearer {effectiveToken}" + // For Copilot: "Bearer \\${GITHUB_PERSONAL_ACCESS_TOKEN}" + AuthorizationValue string + // IncludeToolsField indicates whether to include the "tools" field (Copilot needs it, Claude doesn't) + IncludeToolsField bool + // AllowedTools specifies the list of allowed tools (Copilot uses this, Claude doesn't) + AllowedTools []string + // IncludeEnvSection indicates whether to include the env section (Copilot needs it, Claude doesn't) + IncludeEnvSection bool + // GuardPolicies specifies access control policies for the MCP gateway (e.g., allow-only repos/integrity) + GuardPolicies map[string]any +}