From 5526420039af0bb4dbcdab75f9c85bca64d03fa7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:01:36 +0000 Subject: [PATCH 1/4] Initial plan From 8ffe3d4be46f7445886be910e51ae59b0d44cbd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:22:09 +0000 Subject: [PATCH 2/4] feat: containerize agentic_workflows MCP server per Gateway Spec v1.0.0 Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- .../agent-performance-analyzer.lock.yml | 8 +- .../workflows/daily-firewall-report.lock.yml | 8 +- .github/workflows/dev-hawk.lock.yml | 8 +- .../example-workflow-analyzer.lock.yml | 8 +- .github/workflows/metrics-collector.lock.yml | 8 +- .github/workflows/python-data-charts.lock.yml | 8 +- .github/workflows/smoke-copilot.lock.yml | 8 +- pkg/constants/constants.go | 4 + pkg/workflow/docker.go | 10 + pkg/workflow/importable_tools_test.go | 12 +- pkg/workflow/mcp-config.go | 130 ++++------ pkg/workflow/mcp_config_builtin_test.go | 245 ------------------ pkg/workflow/mcp_config_refactor_test.go | 37 ++- pkg/workflow/mcp_renderer.go | 10 +- pkg/workflow/mcp_renderer_test.go | 31 ++- 15 files changed, 151 insertions(+), 384 deletions(-) delete mode 100644 pkg/workflow/mcp_config_builtin_test.go diff --git a/.github/workflows/agent-performance-analyzer.lock.yml b/.github/workflows/agent-performance-analyzer.lock.yml index a921e1decf..51b6175b8d 100644 --- a/.github/workflows/agent-performance-analyzer.lock.yml +++ b/.github/workflows/agent-performance-analyzer.lock.yml @@ -182,7 +182,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine - name: Install gh-aw extension env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -503,8 +503,10 @@ jobs: "mcpServers": { "agentic_workflows": { "type": "stdio", - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } diff --git a/.github/workflows/daily-firewall-report.lock.yml b/.github/workflows/daily-firewall-report.lock.yml index 6095f879c3..5fab5ae8f5 100644 --- a/.github/workflows/daily-firewall-report.lock.yml +++ b/.github/workflows/daily-firewall-report.lock.yml @@ -234,7 +234,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p /opt/gh-aw/safeoutputs @@ -463,8 +463,10 @@ jobs: "mcpServers": { "agentic_workflows": { "type": "stdio", - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } diff --git a/.github/workflows/dev-hawk.lock.yml b/.github/workflows/dev-hawk.lock.yml index e4b75010ba..874130c056 100644 --- a/.github/workflows/dev-hawk.lock.yml +++ b/.github/workflows/dev-hawk.lock.yml @@ -197,7 +197,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine - name: Write Safe Outputs Config run: | mkdir -p /opt/gh-aw/safeoutputs @@ -380,8 +380,10 @@ jobs: "mcpServers": { "agentic_workflows": { "type": "stdio", - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } diff --git a/.github/workflows/example-workflow-analyzer.lock.yml b/.github/workflows/example-workflow-analyzer.lock.yml index ea0e445270..699c36e81f 100644 --- a/.github/workflows/example-workflow-analyzer.lock.yml +++ b/.github/workflows/example-workflow-analyzer.lock.yml @@ -167,7 +167,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine - name: Install gh-aw extension env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -378,8 +378,10 @@ jobs: { "mcpServers": { "agentic_workflows": { - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "$GITHUB_TOKEN" } diff --git a/.github/workflows/metrics-collector.lock.yml b/.github/workflows/metrics-collector.lock.yml index 1377f79f55..20031a7ce4 100644 --- a/.github/workflows/metrics-collector.lock.yml +++ b/.github/workflows/metrics-collector.lock.yml @@ -170,7 +170,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 - name: Install gh-aw extension env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -212,8 +212,10 @@ jobs: "mcpServers": { "agentic_workflows": { "type": "stdio", - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } diff --git a/.github/workflows/python-data-charts.lock.yml b/.github/workflows/python-data-charts.lock.yml index e7ac4d6c64..5ccfeca293 100644 --- a/.github/workflows/python-data-charts.lock.yml +++ b/.github/workflows/python-data-charts.lock.yml @@ -206,7 +206,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:v0.0.60 node:lts-alpine - name: Install gh-aw extension env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -448,8 +448,10 @@ jobs: "mcpServers": { "agentic_workflows": { "type": "stdio", - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml index cc86aa90dd..8781903d0c 100644 --- a/.github/workflows/smoke-copilot.lock.yml +++ b/.github/workflows/smoke-copilot.lock.yml @@ -206,7 +206,7 @@ jobs: const determineAutomaticLockdown = require('/opt/gh-aw/actions/determine_automatic_lockdown.cjs'); await determineAutomaticLockdown(github, context, core); - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:latest mcr.microsoft.com/playwright/mcp node:lts-alpine + run: bash /opt/gh-aw/actions/download_docker_images.sh alpine:latest ghcr.io/github/github-mcp-server:v0.28.1 ghcr.io/githubnext/gh-aw-mcpg:latest mcr.microsoft.com/playwright/mcp node:lts-alpine - name: Install gh-aw extension env: GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} @@ -514,8 +514,10 @@ jobs: "mcpServers": { "agentic_workflows": { "type": "stdio", - "command": "gh", - "args": ["aw", "mcp-server"], + "container": "alpine:latest", + "entrypoint": "/opt/gh-aw/gh-aw", + "entrypointArgs": ["mcp-server"], + "mounts": ["/opt/gh-aw:/opt/gh-aw:ro"], "env": { "GITHUB_TOKEN": "\${GITHUB_TOKEN}" } diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 9285b6b8a4..32152f21d7 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -313,6 +313,10 @@ const DefaultNodeAlpineLTSImage = "node:lts-alpine" // Using python:alpine provides the latest stable version with minimal footprint const DefaultPythonAlpineLTSImage = "python:alpine" +// DefaultAlpineImage is the default minimal Alpine container image for running Go binaries +// Used for MCP servers that run statically-linked Go binaries like gh-aw mcp-server +const DefaultAlpineImage = "alpine:latest" + // DefaultPythonVersion is the default version of Python for runtime setup const DefaultPythonVersion Version = "3.12" diff --git a/pkg/workflow/docker.go b/pkg/workflow/docker.go index f8af0aa18e..c6cf734b1b 100644 --- a/pkg/workflow/docker.go +++ b/pkg/workflow/docker.go @@ -49,6 +49,16 @@ func collectDockerImages(tools map[string]any, workflowData *WorkflowData) []str } } + // Check for agentic-workflows tool (uses alpine container for gh-aw mcp-server) + if _, hasAgenticWorkflows := tools["agentic-workflows"]; hasAgenticWorkflows { + image := constants.DefaultAlpineImage + if !imageSet[image] { + images = append(images, image) + imageSet[image] = true + dockerLog.Printf("Added agentic-workflows MCP server container: %s", image) + } + } + // Collect sandbox.mcp container (MCP gateway) // Skip if sandbox is disabled (sandbox: false) if workflowData != nil && workflowData.SandboxConfig != nil { diff --git a/pkg/workflow/importable_tools_test.go b/pkg/workflow/importable_tools_test.go index 7fc5bd5a85..9d9e9392a0 100644 --- a/pkg/workflow/importable_tools_test.go +++ b/pkg/workflow/importable_tools_test.go @@ -226,14 +226,14 @@ Uses imported agentic-workflows tool. workflowData := string(lockFileContent) - // Verify gh aw mcp-server command is present - if !strings.Contains(workflowData, `"aw", "mcp-server"`) { - t.Error("Expected compiled workflow to contain 'aw', 'mcp-server' command") + // Verify containerized agentic_workflows server is present (per MCP Gateway Specification v1.0.0) + if !strings.Contains(workflowData, `"entrypointArgs": ["mcp-server"]`) { + t.Error("Expected compiled workflow to contain 'mcp-server' entrypointArgs") } - // Verify gh CLI is used - if !strings.Contains(workflowData, `"command": "gh"`) { - t.Error("Expected compiled workflow to contain gh CLI command for agentic-workflows") + // Verify container format is used (not command format) + if !strings.Contains(workflowData, `"container": "alpine:latest"`) { + t.Error("Expected compiled workflow to contain alpine container for agentic-workflows") } } diff --git a/pkg/workflow/mcp-config.go b/pkg/workflow/mcp-config.go index 2396abf4c4..ac9b027024 100644 --- a/pkg/workflow/mcp-config.go +++ b/pkg/workflow/mcp-config.go @@ -226,72 +226,6 @@ func renderSerenaMCPConfigWithOptions(yaml *strings.Builder, serenaTool any, isL } } -// BuiltinMCPServerOptions contains the options for rendering a built-in MCP server block -type BuiltinMCPServerOptions struct { - Yaml *strings.Builder - ServerID string - Command string - Args []string - EnvVars []string - IsLast bool - IncludeCopilotFields bool -} - -// renderBuiltinMCPServerBlock is a shared helper function that renders MCP server configuration blocks -// for built-in servers (Safe Outputs and Agentic Workflows) with consistent formatting. -// This eliminates code duplication between renderSafeOutputsMCPConfigWithOptions and -// renderAgenticWorkflowsMCPConfigWithOptions by extracting the common YAML generation pattern. -func renderBuiltinMCPServerBlock(opts BuiltinMCPServerOptions) { - opts.Yaml.WriteString(" \"" + opts.ServerID + "\": {\n") - - // Add type field for Copilot (per MCP Gateway Specification v1.0.0, use "stdio" for containerized servers) - if opts.IncludeCopilotFields { - opts.Yaml.WriteString(" \"type\": \"stdio\",\n") - } - - opts.Yaml.WriteString(" \"command\": \"" + opts.Command + "\",\n") - - // Write args array - opts.Yaml.WriteString(" \"args\": [") - for i, arg := range opts.Args { - if i > 0 { - opts.Yaml.WriteString(", ") - } - opts.Yaml.WriteString("\"" + arg + "\"") - } - opts.Yaml.WriteString("],\n") - - // Note: tools field is NOT included here - the converter script adds it back - // for Copilot. This keeps the gateway config compatible with the schema. - - opts.Yaml.WriteString(" \"env\": {\n") - - // Write environment variables with appropriate escaping - for i, envVar := range opts.EnvVars { - isLastEnvVar := i == len(opts.EnvVars)-1 - comma := "" - if !isLastEnvVar { - comma = "," - } - - if opts.IncludeCopilotFields { - // Copilot format: backslash-escaped shell variable reference - opts.Yaml.WriteString(" \"" + envVar + "\": \"\\${" + envVar + "}\"" + comma + "\n") - } else { - // Claude/Custom format: direct shell variable reference - opts.Yaml.WriteString(" \"" + envVar + "\": \"$" + envVar + "\"" + comma + "\n") - } - } - - opts.Yaml.WriteString(" }\n") - - if opts.IsLast { - opts.Yaml.WriteString(" }\n") - } else { - opts.Yaml.WriteString(" },\n") - } -} - // renderSafeOutputsMCPConfig generates the Safe Outputs MCP server configuration // This is a shared function used by both Claude and Custom engines func renderSafeOutputsMCPConfig(yaml *strings.Builder, isLast bool) { @@ -363,20 +297,55 @@ func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, i } // renderAgenticWorkflowsMCPConfigWithOptions generates the Agentic Workflows MCP server configuration with engine-specific options +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. +// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool) { envVars := []string{ "GITHUB_TOKEN", } - renderBuiltinMCPServerBlock(BuiltinMCPServerOptions{ - Yaml: yaml, - ServerID: "agentic_workflows", - Command: "gh", - Args: []string{"aw", "mcp-server"}, - EnvVars: envVars, - IsLast: isLast, - IncludeCopilotFields: includeCopilotFields, - }) + // Use MCP Gateway spec format with container, entrypoint, entrypointArgs, and mounts + // The gh-aw binary is mounted from /opt/gh-aw and executed directly inside a minimal Alpine container + yaml.WriteString(" \"agentic_workflows\": {\n") + + // Add type field for Copilot (per MCP Gateway Specification v1.0.0, use "stdio" for containerized servers) + if includeCopilotFields { + yaml.WriteString(" \"type\": \"stdio\",\n") + } + + // MCP Gateway spec fields for containerized stdio servers + yaml.WriteString(" \"container\": \"" + constants.DefaultAlpineImage + "\",\n") + yaml.WriteString(" \"entrypoint\": \"/opt/gh-aw/gh-aw\",\n") + yaml.WriteString(" \"entrypointArgs\": [\"mcp-server\"],\n") + yaml.WriteString(" \"mounts\": [\"/opt/gh-aw:/opt/gh-aw:ro\"],\n") + + // Note: tools field is NOT included here - the converter script adds it back + // for Copilot. This keeps the gateway config compatible with the schema. + + // Write environment variables + yaml.WriteString(" \"env\": {\n") + for i, envVar := range envVars { + isLastEnvVar := i == len(envVars)-1 + comma := "" + if !isLastEnvVar { + comma = "," + } + + if includeCopilotFields { + // Copilot format: backslash-escaped shell variable reference + yaml.WriteString(" \"" + envVar + "\": \"\\${" + envVar + "}\"" + comma + "\n") + } else { + // Claude/Custom format: direct shell variable reference + yaml.WriteString(" \"" + envVar + "\": \"$" + envVar + "\"" + comma + "\n") + } + } + yaml.WriteString(" }\n") + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } } // renderPlaywrightMCPConfigTOML generates the Playwright MCP server configuration in TOML format for Codex @@ -438,14 +407,15 @@ func renderSafeOutputsMCPConfigTOML(yaml *strings.Builder) { } // renderAgenticWorkflowsMCPConfigTOML generates the Agentic Workflows MCP server configuration in TOML format for Codex +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. +// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. func renderAgenticWorkflowsMCPConfigTOML(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(" container = \"" + constants.DefaultAlpineImage + "\"\n") + yaml.WriteString(" entrypoint = \"/opt/gh-aw/gh-aw\"\n") + yaml.WriteString(" entrypointArgs = [\"mcp-server\"]\n") + yaml.WriteString(" mounts = [\"/opt/gh-aw:/opt/gh-aw:ro\"]\n") // Use env_vars array to reference environment variables instead of embedding secrets yaml.WriteString(" env_vars = [\"GITHUB_TOKEN\"]\n") } diff --git a/pkg/workflow/mcp_config_builtin_test.go b/pkg/workflow/mcp_config_builtin_test.go deleted file mode 100644 index 0be2238275..0000000000 --- a/pkg/workflow/mcp_config_builtin_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package workflow - -import ( - "strings" - "testing" - - "github.com/githubnext/gh-aw/pkg/constants" -) - -// TestRenderBuiltinMCPServerBlock verifies the shared helper function that eliminated code duplication -func TestRenderBuiltinMCPServerBlock(t *testing.T) { - tests := []struct { - name string - serverID string - command string - args []string - envVars []string - isLast bool - includeCopilotFields bool - expectedContent []string - unexpectedContent []string - }{ - { - name: "SafeOutputs Copilot format", - serverID: constants.SafeOutputsMCPServerID, - command: "node", - args: []string{"/opt/gh-aw/safeoutputs/mcp-server.cjs"}, - envVars: []string{ - "GH_AW_SAFE_OUTPUTS", - "GH_AW_ASSETS_BRANCH", - }, - isLast: true, - includeCopilotFields: true, - expectedContent: []string{ - `"safeoutputs": {`, - `"type": "local"`, - `"command": "node"`, - `"args": ["/opt/gh-aw/safeoutputs/mcp-server.cjs"]`, - `"tools": ["*"]`, - `"env": {`, - `"GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}"`, - `"GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}"`, - ` }`, // isLast = true, no comma - }, - unexpectedContent: []string{}, - }, - { - name: "SafeOutputs Claude format", - serverID: constants.SafeOutputsMCPServerID, - command: "node", - args: []string{"/opt/gh-aw/safeoutputs/mcp-server.cjs"}, - envVars: []string{ - "GH_AW_SAFE_OUTPUTS", - "GH_AW_ASSETS_BRANCH", - }, - isLast: false, - includeCopilotFields: false, - expectedContent: []string{ - `"safeoutputs": {`, - `"command": "node"`, - `"args": ["/opt/gh-aw/safeoutputs/mcp-server.cjs"]`, - `"env": {`, - `"GH_AW_SAFE_OUTPUTS": "$GH_AW_SAFE_OUTPUTS"`, - `"GH_AW_ASSETS_BRANCH": "$GH_AW_ASSETS_BRANCH"`, - ` },`, // isLast = false, with comma - }, - unexpectedContent: []string{ - `"type"`, - `"tools"`, - `\\${`, // Should not have backslash-escaped variables in Claude format - }, - }, - { - name: "AgenticWorkflows Copilot format", - serverID: "agentic_workflows", - command: "gh", - args: []string{"aw", "mcp-server"}, - envVars: []string{"GITHUB_TOKEN"}, - isLast: false, - includeCopilotFields: true, - expectedContent: []string{ - `"agentic_workflows": {`, - `"type": "local"`, - `"command": "gh"`, - `"args": ["aw", "mcp-server"]`, - `"tools": ["*"]`, - `"env": {`, - `"GITHUB_TOKEN": "\${GITHUB_TOKEN}"`, - ` },`, // isLast = false, with comma - }, - unexpectedContent: []string{}, - }, - { - name: "AgenticWorkflows Claude format", - serverID: "agentic_workflows", - command: "gh", - args: []string{"aw", "mcp-server"}, - envVars: []string{"GITHUB_TOKEN"}, - isLast: true, - includeCopilotFields: false, - expectedContent: []string{ - `"agentic_workflows": {`, - `"command": "gh"`, - `"args": ["aw", "mcp-server"]`, - `"env": {`, - `"GITHUB_TOKEN": "$GITHUB_TOKEN"`, - ` }`, // isLast = true, no comma - }, - unexpectedContent: []string{ - `"type"`, - `"tools"`, - `\\${`, // Should not have backslash-escaped variables in Claude format - }, - }, - { - name: "Multiple args formatting", - serverID: "test_server", - command: "testcmd", - args: []string{"arg1", "arg2", "arg3"}, - envVars: []string{"VAR1", "VAR2"}, - isLast: false, - includeCopilotFields: true, - expectedContent: []string{ - `"test_server": {`, - `"args": ["arg1", "arg2", "arg3"]`, - `"VAR1": "\${VAR1}"`, - `"VAR2": "\${VAR2}"`, - }, - unexpectedContent: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var output strings.Builder - - renderBuiltinMCPServerBlock(BuiltinMCPServerOptions{ - Yaml: &output, - ServerID: tt.serverID, - Command: tt.command, - Args: tt.args, - EnvVars: tt.envVars, - IsLast: tt.isLast, - IncludeCopilotFields: tt.includeCopilotFields, - }) - - result := output.String() - - // Check expected content - for _, expected := range tt.expectedContent { - if !strings.Contains(result, expected) { - t.Errorf("Expected content not found: %q\nActual output:\n%s", expected, result) - } - } - - // Check unexpected content - for _, unexpected := range tt.unexpectedContent { - if strings.Contains(result, unexpected) { - t.Errorf("Unexpected content found: %q\nActual output:\n%s", unexpected, result) - } - } - }) - } -} - -// TestBuiltinMCPServerBlockCommaHandling specifically tests comma handling for isLast parameter -func TestBuiltinMCPServerBlockCommaHandling(t *testing.T) { - tests := []struct { - name string - isLast bool - expectedEnding string - }{ - { - name: "Not last - should have comma", - isLast: false, - expectedEnding: " },\n", - }, - { - name: "Is last - should not have comma", - isLast: true, - expectedEnding: " }\n", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var output strings.Builder - - renderBuiltinMCPServerBlock(BuiltinMCPServerOptions{ - Yaml: &output, - ServerID: "test", - Command: "node", - Args: []string{"arg"}, - EnvVars: []string{"VAR"}, - IsLast: tt.isLast, - IncludeCopilotFields: false, - }) - - result := output.String() - - if !strings.HasSuffix(result, tt.expectedEnding) { - t.Errorf("Expected ending %q but got:\n%s", tt.expectedEnding, result) - } - }) - } -} - -// TestBuiltinMCPServerBlockEnvVarOrdering tests that environment variables maintain order -func TestBuiltinMCPServerBlockEnvVarOrdering(t *testing.T) { - envVars := []string{"VAR_A", "VAR_B", "VAR_C", "VAR_D"} - - var output strings.Builder - renderBuiltinMCPServerBlock(BuiltinMCPServerOptions{ - Yaml: &output, - ServerID: "test", - Command: "cmd", - Args: []string{"arg"}, - EnvVars: envVars, - IsLast: true, - IncludeCopilotFields: false, - }) - - result := output.String() - - // Find positions of each variable in the output - positions := make(map[string]int) - for _, envVar := range envVars { - pos := strings.Index(result, `"`+envVar+`"`) - if pos == -1 { - t.Errorf("Environment variable %s not found in output", envVar) - continue - } - positions[envVar] = pos - } - - // Verify ordering - for i := 0; i < len(envVars)-1; i++ { - currentVar := envVars[i] - nextVar := envVars[i+1] - - if positions[currentVar] >= positions[nextVar] { - t.Errorf("Environment variables out of order: %s should come before %s", currentVar, nextVar) - } - } -} diff --git a/pkg/workflow/mcp_config_refactor_test.go b/pkg/workflow/mcp_config_refactor_test.go index da4b3cc4b2..b99ae632f8 100644 --- a/pkg/workflow/mcp_config_refactor_test.go +++ b/pkg/workflow/mcp_config_refactor_test.go @@ -200,6 +200,7 @@ func TestRenderSafeOutputsMCPConfigWithOptions(t *testing.T) { // TestRenderAgenticWorkflowsMCPConfigWithOptions verifies the shared Agentic Workflows config helper // works correctly with both Copilot and non-Copilot engines +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) { tests := []struct { name string @@ -209,40 +210,44 @@ func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) { unexpectedContent []string }{ { - name: "Copilot with type/tools and escaped env vars", + name: "Copilot with type and escaped env vars", isLast: false, includeCopilotFields: true, expectedContent: []string{ `"agentic_workflows": {`, - `"type": "local"`, - `"command": "gh"`, - `"args": ["aw", "mcp-server"]`, - `"tools": ["*"]`, + `"type": "stdio"`, + `"container": "alpine:latest"`, + `"entrypoint": "/opt/gh-aw/gh-aw"`, + `"entrypointArgs": ["mcp-server"]`, + `"mounts": ["/opt/gh-aw:/opt/gh-aw:ro"]`, `"GITHUB_TOKEN": "\${GITHUB_TOKEN}"`, ` },`, }, unexpectedContent: []string{ `${{ secrets.`, + `"command":`, // Should NOT use command - must use container }, }, { - name: "Claude/Custom without type/tools, with shell env vars", + name: "Claude/Custom without type, with shell env vars", isLast: true, includeCopilotFields: false, expectedContent: []string{ `"agentic_workflows": {`, - `"command": "gh"`, - `"args": ["aw", "mcp-server"]`, + `"container": "alpine:latest"`, + `"entrypoint": "/opt/gh-aw/gh-aw"`, + `"entrypointArgs": ["mcp-server"]`, + `"mounts": ["/opt/gh-aw:/opt/gh-aw:ro"]`, // Security fix: Now uses shell variable instead of GitHub secret expression `"GITHUB_TOKEN": "$GITHUB_TOKEN"`, ` }`, }, unexpectedContent: []string{ `"type"`, - `"tools"`, `\\${`, // Verify GitHub expressions are NOT in the output (security fix) `${{ secrets.`, + `"command":`, // Should NOT use command - must use container }, }, } @@ -369,6 +374,7 @@ func TestRenderSafeOutputsMCPConfigTOML(t *testing.T) { } // TestRenderAgenticWorkflowsMCPConfigTOML verifies the Agentic Workflows TOML format helper +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. func TestRenderAgenticWorkflowsMCPConfigTOML(t *testing.T) { var output strings.Builder @@ -378,15 +384,18 @@ func TestRenderAgenticWorkflowsMCPConfigTOML(t *testing.T) { expectedContent := []string{ `[mcp_servers.agentic_workflows]`, - `command = "gh"`, - `args = [`, - `"aw"`, - `"mcp-server"`, + `container = "alpine:latest"`, + `entrypoint = "/opt/gh-aw/gh-aw"`, + `entrypointArgs = ["mcp-server"]`, + `mounts = ["/opt/gh-aw:/opt/gh-aw:ro"]`, `env_vars = ["GITHUB_TOKEN"]`, } unexpectedContent := []string{ - `env = {`, // Should use env_vars instead + `env = {`, // Should use env_vars instead + `command = "gh"`, // Should NOT use command - must use container + `"aw"`, // Old arg format + `args = [`, // Old args format } for _, expected := range expectedContent { diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index 21f632fde2..d911f4d834 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -321,14 +321,14 @@ func (r *MCPConfigRendererUnified) RenderAgenticWorkflowsMCP(yaml *strings.Build } // 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.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(" container = \"" + constants.DefaultAlpineImage + "\"\n") + yaml.WriteString(" entrypoint = \"/opt/gh-aw/gh-aw\"\n") + yaml.WriteString(" entrypointArgs = [\"mcp-server\"]\n") + yaml.WriteString(" mounts = [\"/opt/gh-aw:/opt/gh-aw:ro\"]\n") yaml.WriteString(" env_vars = [\"GITHUB_TOKEN\"]\n") } diff --git a/pkg/workflow/mcp_renderer_test.go b/pkg/workflow/mcp_renderer_test.go index 4fe15721ae..e32eec5d3a 100644 --- a/pkg/workflow/mcp_renderer_test.go +++ b/pkg/workflow/mcp_renderer_test.go @@ -271,18 +271,19 @@ func TestRenderAgenticWorkflowsMCP_JSON_Copilot(t *testing.T) { 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") + // Verify Copilot-specific fields (per MCP Gateway Specification v1.0.0) + if !strings.Contains(output, `"type": "stdio"`) { + t.Error("Expected 'type': 'stdio' 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") + // Per MCP Gateway Specification v1.0.0, stdio servers MUST use container format + if !strings.Contains(output, `"container": "alpine:latest"`) { + t.Error("Expected container field for containerized server") + } + if !strings.Contains(output, `"entrypoint": "/opt/gh-aw/gh-aw"`) { + t.Error("Expected entrypoint field for containerized server") } } @@ -321,15 +322,19 @@ func TestRenderAgenticWorkflowsMCP_TOML(t *testing.T) { output := yaml.String() - // Verify TOML format + // Verify TOML format (per MCP Gateway Specification v1.0.0) 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") + // Per MCP Gateway Specification v1.0.0, stdio servers MUST use container format + if !strings.Contains(output, `container = "alpine:latest"`) { + t.Error("Expected TOML container field for containerized server") } - if !strings.Contains(output, "args = [") { - t.Error("Expected TOML args array") + if !strings.Contains(output, `entrypoint = "/opt/gh-aw/gh-aw"`) { + t.Error("Expected TOML entrypoint field for containerized server") + } + if !strings.Contains(output, `entrypointArgs = ["mcp-server"]`) { + t.Error("Expected TOML entrypointArgs field") } } From 70e658a0cfe23c5787d3ce59f5d745c4c83fdad1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:28:14 +0000 Subject: [PATCH 3/4] fix: update TestImportAllThreeTools to check for containerized format Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- pkg/workflow/importable_tools_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/importable_tools_test.go b/pkg/workflow/importable_tools_test.go index 9d9e9392a0..dd269b53a8 100644 --- a/pkg/workflow/importable_tools_test.go +++ b/pkg/workflow/importable_tools_test.go @@ -310,7 +310,8 @@ Uses all imported tools. if !strings.Contains(workflowData, `"serena"`) { t.Error("Expected compiled workflow to contain serena tool") } - if !strings.Contains(workflowData, `"aw", "mcp-server"`) { + // Per MCP Gateway Specification v1.0.0, agentic-workflows uses containerized format + if !strings.Contains(workflowData, `"agentic_workflows"`) { t.Error("Expected compiled workflow to contain agentic-workflows tool") } From 9f8bfd70f8105ffae168c1337487d1b0a53d3d43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Jan 2026 02:41:27 +0000 Subject: [PATCH 4/4] refactor: use constants for mount paths in MCP configurations Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- pkg/constants/constants.go | 8 ++++++++ pkg/workflow/mcp-config.go | 8 ++++---- pkg/workflow/mcp_renderer.go | 2 +- pkg/workflow/mcp_renderer_test.go | 4 ++-- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 32152f21d7..0d0c4c6026 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -317,6 +317,14 @@ const DefaultPythonAlpineLTSImage = "python:alpine" // Used for MCP servers that run statically-linked Go binaries like gh-aw mcp-server const DefaultAlpineImage = "alpine:latest" +// DefaultGhAwMount is the mount path for the gh-aw directory in containerized MCP servers +// The gh-aw binary and supporting files are mounted read-only from /opt/gh-aw +const DefaultGhAwMount = "/opt/gh-aw:/opt/gh-aw:ro" + +// DefaultTmpGhAwMount is the mount path for temporary gh-aw files in containerized MCP servers +// Used for logs, cache, and other runtime data that needs read-write access +const DefaultTmpGhAwMount = "/tmp/gh-aw:/tmp/gh-aw:rw" + // DefaultPythonVersion is the default version of Python for runtime setup const DefaultPythonVersion Version = "3.12" diff --git a/pkg/workflow/mcp-config.go b/pkg/workflow/mcp-config.go index ac9b027024..d2d617d4a0 100644 --- a/pkg/workflow/mcp-config.go +++ b/pkg/workflow/mcp-config.go @@ -265,7 +265,7 @@ func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, i yaml.WriteString(" \"container\": \"" + constants.DefaultNodeAlpineLTSImage + "\",\n") yaml.WriteString(" \"entrypoint\": \"node\",\n") yaml.WriteString(" \"entrypointArgs\": [\"/opt/gh-aw/safeoutputs/mcp-server.cjs\"],\n") - yaml.WriteString(" \"mounts\": [\"/opt/gh-aw:/opt/gh-aw:ro\", \"/tmp/gh-aw:/tmp/gh-aw:rw\"],\n") + yaml.WriteString(" \"mounts\": [\"" + constants.DefaultGhAwMount + "\", \"" + constants.DefaultTmpGhAwMount + "\"],\n") // Note: tools field is NOT included here - the converter script adds it back // for Copilot. This keeps the gateway config compatible with the schema. @@ -317,7 +317,7 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo yaml.WriteString(" \"container\": \"" + constants.DefaultAlpineImage + "\",\n") yaml.WriteString(" \"entrypoint\": \"/opt/gh-aw/gh-aw\",\n") yaml.WriteString(" \"entrypointArgs\": [\"mcp-server\"],\n") - yaml.WriteString(" \"mounts\": [\"/opt/gh-aw:/opt/gh-aw:ro\"],\n") + yaml.WriteString(" \"mounts\": [\"" + constants.DefaultGhAwMount + "\"],\n") // Note: tools field is NOT included here - the converter script adds it back // for Copilot. This keeps the gateway config compatible with the schema. @@ -401,7 +401,7 @@ func renderSafeOutputsMCPConfigTOML(yaml *strings.Builder) { yaml.WriteString(" container = \"" + constants.DefaultNodeAlpineLTSImage + "\"\n") yaml.WriteString(" entrypoint = \"node\"\n") yaml.WriteString(" entrypointArgs = [\"/opt/gh-aw/safeoutputs/mcp-server.cjs\"]\n") - yaml.WriteString(" mounts = [\"/opt/gh-aw:/opt/gh-aw:ro\", \"/tmp/gh-aw:/tmp/gh-aw:rw\"]\n") + yaml.WriteString(" mounts = [\"" + constants.DefaultGhAwMount + "\", \"" + constants.DefaultTmpGhAwMount + "\"]\n") // Use env_vars array to reference environment variables instead of embedding GitHub Actions expressions 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\", \"GITHUB_SHA\", \"GITHUB_WORKSPACE\", \"DEFAULT_BRANCH\"]\n") } @@ -415,7 +415,7 @@ func renderAgenticWorkflowsMCPConfigTOML(yaml *strings.Builder) { yaml.WriteString(" container = \"" + constants.DefaultAlpineImage + "\"\n") yaml.WriteString(" entrypoint = \"/opt/gh-aw/gh-aw\"\n") yaml.WriteString(" entrypointArgs = [\"mcp-server\"]\n") - yaml.WriteString(" mounts = [\"/opt/gh-aw:/opt/gh-aw:ro\"]\n") + yaml.WriteString(" mounts = [\"" + constants.DefaultGhAwMount + "\"]\n") // Use env_vars array to reference environment variables instead of embedding secrets yaml.WriteString(" env_vars = [\"GITHUB_TOKEN\"]\n") } diff --git a/pkg/workflow/mcp_renderer.go b/pkg/workflow/mcp_renderer.go index d911f4d834..9206b028cf 100644 --- a/pkg/workflow/mcp_renderer.go +++ b/pkg/workflow/mcp_renderer.go @@ -328,7 +328,7 @@ func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Buil yaml.WriteString(" container = \"" + constants.DefaultAlpineImage + "\"\n") yaml.WriteString(" entrypoint = \"/opt/gh-aw/gh-aw\"\n") yaml.WriteString(" entrypointArgs = [\"mcp-server\"]\n") - yaml.WriteString(" mounts = [\"/opt/gh-aw:/opt/gh-aw:ro\"]\n") + yaml.WriteString(" mounts = [\"" + constants.DefaultGhAwMount + "\"]\n") yaml.WriteString(" env_vars = [\"GITHUB_TOKEN\"]\n") } diff --git a/pkg/workflow/mcp_renderer_test.go b/pkg/workflow/mcp_renderer_test.go index e32eec5d3a..a6c0049e04 100644 --- a/pkg/workflow/mcp_renderer_test.go +++ b/pkg/workflow/mcp_renderer_test.go @@ -271,9 +271,9 @@ func TestRenderAgenticWorkflowsMCP_JSON_Copilot(t *testing.T) { output := yaml.String() - // Verify Copilot-specific fields (per MCP Gateway Specification v1.0.0) + // Verify MCP Gateway Specification v1.0.0 fields if !strings.Contains(output, `"type": "stdio"`) { - t.Error("Expected 'type': 'stdio' field for Copilot") + t.Error("Expected 'type': 'stdio' field per MCP Gateway Specification") } if !strings.Contains(output, `"agentic_workflows": {`) { t.Error("Expected agentic_workflows server ID")