Skip to content

Commit 7121441

Browse files
authored
Refactor: Extract shared MCP config renderer to eliminate duplication (#3504)
1 parent d501d3c commit 7121441

2 files changed

Lines changed: 293 additions & 61 deletions

File tree

pkg/workflow/mcp-config.go

Lines changed: 72 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -91,24 +91,29 @@ func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightTool
9191
}
9292
}
9393

94-
// renderSafeOutputsMCPConfig generates the Safe Outputs MCP server configuration
95-
// This is a shared function used by both Claude and Custom engines
96-
func renderSafeOutputsMCPConfig(yaml *strings.Builder, isLast bool) {
97-
mcpLog.Print("Rendering Safe Outputs MCP configuration")
98-
renderSafeOutputsMCPConfigWithOptions(yaml, isLast, false)
99-
}
100-
101-
// renderSafeOutputsMCPConfigWithOptions generates the Safe Outputs MCP server configuration with engine-specific options
102-
func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool) {
103-
yaml.WriteString(" \"" + constants.SafeOutputsMCPServerID + "\": {\n")
94+
// renderBuiltinMCPServerBlock is a shared helper function that renders MCP server configuration blocks
95+
// for built-in servers (Safe Outputs and Agentic Workflows) with consistent formatting.
96+
// This eliminates code duplication between renderSafeOutputsMCPConfigWithOptions and
97+
// renderAgenticWorkflowsMCPConfigWithOptions by extracting the common YAML generation pattern.
98+
func renderBuiltinMCPServerBlock(yaml *strings.Builder, serverID string, command string, args []string, envVars []string, isLast bool, includeCopilotFields bool) {
99+
yaml.WriteString(" \"" + serverID + "\": {\n")
104100

105101
// Add type field for Copilot
106102
if includeCopilotFields {
107103
yaml.WriteString(" \"type\": \"local\",\n")
108104
}
109105

110-
yaml.WriteString(" \"command\": \"node\",\n")
111-
yaml.WriteString(" \"args\": [\"/tmp/gh-aw/safeoutputs/mcp-server.cjs\"],\n")
106+
yaml.WriteString(" \"command\": \"" + command + "\",\n")
107+
108+
// Write args array
109+
yaml.WriteString(" \"args\": [")
110+
for i, arg := range args {
111+
if i > 0 {
112+
yaml.WriteString(", ")
113+
}
114+
yaml.WriteString("\"" + arg + "\"")
115+
}
116+
yaml.WriteString("],\n")
112117

113118
// Add tools field for Copilot
114119
if includeCopilotFields {
@@ -117,24 +122,21 @@ func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, i
117122

118123
yaml.WriteString(" \"env\": {\n")
119124

120-
// Use shell environment variables instead of GitHub Actions expressions to prevent template injection
121-
// For both Copilot and Claude/Custom engines, reference shell env vars
122-
// The actual GitHub expressions are set in the step's env: block
123-
// Config is now read from file, so we don't pass GH_AW_SAFE_OUTPUTS_CONFIG
124-
if includeCopilotFields {
125-
yaml.WriteString(" \"GH_AW_SAFE_OUTPUTS\": \"\\${GH_AW_SAFE_OUTPUTS}\",\n")
126-
yaml.WriteString(" \"GH_AW_ASSETS_BRANCH\": \"\\${GH_AW_ASSETS_BRANCH}\",\n")
127-
yaml.WriteString(" \"GH_AW_ASSETS_MAX_SIZE_KB\": \"\\${GH_AW_ASSETS_MAX_SIZE_KB}\",\n")
128-
yaml.WriteString(" \"GH_AW_ASSETS_ALLOWED_EXTS\": \"\\${GH_AW_ASSETS_ALLOWED_EXTS}\",\n")
129-
yaml.WriteString(" \"GITHUB_REPOSITORY\": \"\\${GITHUB_REPOSITORY}\",\n")
130-
yaml.WriteString(" \"GITHUB_SERVER_URL\": \"\\${GITHUB_SERVER_URL}\"\n")
131-
} else {
132-
yaml.WriteString(" \"GH_AW_SAFE_OUTPUTS\": \"$GH_AW_SAFE_OUTPUTS\",\n")
133-
yaml.WriteString(" \"GH_AW_ASSETS_BRANCH\": \"$GH_AW_ASSETS_BRANCH\",\n")
134-
yaml.WriteString(" \"GH_AW_ASSETS_MAX_SIZE_KB\": \"$GH_AW_ASSETS_MAX_SIZE_KB\",\n")
135-
yaml.WriteString(" \"GH_AW_ASSETS_ALLOWED_EXTS\": \"$GH_AW_ASSETS_ALLOWED_EXTS\",\n")
136-
yaml.WriteString(" \"GITHUB_REPOSITORY\": \"$GITHUB_REPOSITORY\",\n")
137-
yaml.WriteString(" \"GITHUB_SERVER_URL\": \"$GITHUB_SERVER_URL\"\n")
125+
// Write environment variables with appropriate escaping
126+
for i, envVar := range envVars {
127+
isLastEnvVar := i == len(envVars)-1
128+
comma := ""
129+
if !isLastEnvVar {
130+
comma = ","
131+
}
132+
133+
if includeCopilotFields {
134+
// Copilot format: backslash-escaped shell variable reference
135+
yaml.WriteString(" \"" + envVar + "\": \"\\${" + envVar + "}\"" + comma + "\n")
136+
} else {
137+
// Claude/Custom format: direct shell variable reference
138+
yaml.WriteString(" \"" + envVar + "\": \"$" + envVar + "\"" + comma + "\n")
139+
}
138140
}
139141

140142
yaml.WriteString(" }\n")
@@ -146,6 +148,35 @@ func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, i
146148
}
147149
}
148150

151+
// renderSafeOutputsMCPConfig generates the Safe Outputs MCP server configuration
152+
// This is a shared function used by both Claude and Custom engines
153+
func renderSafeOutputsMCPConfig(yaml *strings.Builder, isLast bool) {
154+
mcpLog.Print("Rendering Safe Outputs MCP configuration")
155+
renderSafeOutputsMCPConfigWithOptions(yaml, isLast, false)
156+
}
157+
158+
// renderSafeOutputsMCPConfigWithOptions generates the Safe Outputs MCP server configuration with engine-specific options
159+
func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool) {
160+
envVars := []string{
161+
"GH_AW_SAFE_OUTPUTS",
162+
"GH_AW_ASSETS_BRANCH",
163+
"GH_AW_ASSETS_MAX_SIZE_KB",
164+
"GH_AW_ASSETS_ALLOWED_EXTS",
165+
"GITHUB_REPOSITORY",
166+
"GITHUB_SERVER_URL",
167+
}
168+
169+
renderBuiltinMCPServerBlock(
170+
yaml,
171+
constants.SafeOutputsMCPServerID,
172+
"node",
173+
[]string{"/tmp/gh-aw/safeoutputs/mcp-server.cjs"},
174+
envVars,
175+
isLast,
176+
includeCopilotFields,
177+
)
178+
}
179+
149180
// renderAgenticWorkflowsMCPConfig generates the Agentic Workflows MCP server configuration
150181
// This is a shared function used by both Claude and Custom engines
151182
func renderAgenticWorkflowsMCPConfig(yaml *strings.Builder, isLast bool) {
@@ -154,39 +185,19 @@ func renderAgenticWorkflowsMCPConfig(yaml *strings.Builder, isLast bool) {
154185

155186
// renderAgenticWorkflowsMCPConfigWithOptions generates the Agentic Workflows MCP server configuration with engine-specific options
156187
func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool) {
157-
yaml.WriteString(" \"agentic_workflows\": {\n")
158-
159-
// Add type field for Copilot
160-
if includeCopilotFields {
161-
yaml.WriteString(" \"type\": \"local\",\n")
162-
}
163-
164-
yaml.WriteString(" \"command\": \"gh\",\n")
165-
yaml.WriteString(" \"args\": [\"aw\", \"mcp-server\"],\n")
166-
167-
// Add tools field for Copilot
168-
if includeCopilotFields {
169-
yaml.WriteString(" \"tools\": [\"*\"],\n")
188+
envVars := []string{
189+
"GITHUB_TOKEN",
170190
}
171191

172-
yaml.WriteString(" \"env\": {\n")
173-
174-
// Use shell environment variables instead of GitHub Actions expressions to prevent template injection
175-
// For both Copilot and Claude/Custom engines, reference shell env vars
176-
// The actual GitHub expressions are set in the step's env: block
177-
if includeCopilotFields {
178-
yaml.WriteString(" \"GITHUB_TOKEN\": \"\\${GITHUB_TOKEN}\"\n")
179-
} else {
180-
yaml.WriteString(" \"GITHUB_TOKEN\": \"$GITHUB_TOKEN\"\n")
181-
}
182-
183-
yaml.WriteString(" }\n")
184-
185-
if isLast {
186-
yaml.WriteString(" }\n")
187-
} else {
188-
yaml.WriteString(" },\n")
189-
}
192+
renderBuiltinMCPServerBlock(
193+
yaml,
194+
"agentic_workflows",
195+
"gh",
196+
[]string{"aw", "mcp-server"},
197+
envVars,
198+
isLast,
199+
includeCopilotFields,
200+
)
190201
}
191202

192203
// renderPlaywrightMCPConfigTOML generates the Playwright MCP server configuration in TOML format for Codex
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package workflow
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/githubnext/gh-aw/pkg/constants"
8+
)
9+
10+
// TestRenderBuiltinMCPServerBlock verifies the shared helper function that eliminated code duplication
11+
func TestRenderBuiltinMCPServerBlock(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
serverID string
15+
command string
16+
args []string
17+
envVars []string
18+
isLast bool
19+
includeCopilotFields bool
20+
expectedContent []string
21+
unexpectedContent []string
22+
}{
23+
{
24+
name: "SafeOutputs Copilot format",
25+
serverID: constants.SafeOutputsMCPServerID,
26+
command: "node",
27+
args: []string{"/tmp/gh-aw/safeoutputs/mcp-server.cjs"},
28+
envVars: []string{
29+
"GH_AW_SAFE_OUTPUTS",
30+
"GH_AW_ASSETS_BRANCH",
31+
},
32+
isLast: true,
33+
includeCopilotFields: true,
34+
expectedContent: []string{
35+
`"safeoutputs": {`,
36+
`"type": "local"`,
37+
`"command": "node"`,
38+
`"args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"]`,
39+
`"tools": ["*"]`,
40+
`"env": {`,
41+
`"GH_AW_SAFE_OUTPUTS": "\${GH_AW_SAFE_OUTPUTS}"`,
42+
`"GH_AW_ASSETS_BRANCH": "\${GH_AW_ASSETS_BRANCH}"`,
43+
` }`, // isLast = true, no comma
44+
},
45+
unexpectedContent: []string{},
46+
},
47+
{
48+
name: "SafeOutputs Claude format",
49+
serverID: constants.SafeOutputsMCPServerID,
50+
command: "node",
51+
args: []string{"/tmp/gh-aw/safeoutputs/mcp-server.cjs"},
52+
envVars: []string{
53+
"GH_AW_SAFE_OUTPUTS",
54+
"GH_AW_ASSETS_BRANCH",
55+
},
56+
isLast: false,
57+
includeCopilotFields: false,
58+
expectedContent: []string{
59+
`"safeoutputs": {`,
60+
`"command": "node"`,
61+
`"args": ["/tmp/gh-aw/safeoutputs/mcp-server.cjs"]`,
62+
`"env": {`,
63+
`"GH_AW_SAFE_OUTPUTS": "$GH_AW_SAFE_OUTPUTS"`,
64+
`"GH_AW_ASSETS_BRANCH": "$GH_AW_ASSETS_BRANCH"`,
65+
` },`, // isLast = false, with comma
66+
},
67+
unexpectedContent: []string{
68+
`"type"`,
69+
`"tools"`,
70+
`\\${`, // Should not have backslash-escaped variables in Claude format
71+
},
72+
},
73+
{
74+
name: "AgenticWorkflows Copilot format",
75+
serverID: "agentic_workflows",
76+
command: "gh",
77+
args: []string{"aw", "mcp-server"},
78+
envVars: []string{"GITHUB_TOKEN"},
79+
isLast: false,
80+
includeCopilotFields: true,
81+
expectedContent: []string{
82+
`"agentic_workflows": {`,
83+
`"type": "local"`,
84+
`"command": "gh"`,
85+
`"args": ["aw", "mcp-server"]`,
86+
`"tools": ["*"]`,
87+
`"env": {`,
88+
`"GITHUB_TOKEN": "\${GITHUB_TOKEN}"`,
89+
` },`, // isLast = false, with comma
90+
},
91+
unexpectedContent: []string{},
92+
},
93+
{
94+
name: "AgenticWorkflows Claude format",
95+
serverID: "agentic_workflows",
96+
command: "gh",
97+
args: []string{"aw", "mcp-server"},
98+
envVars: []string{"GITHUB_TOKEN"},
99+
isLast: true,
100+
includeCopilotFields: false,
101+
expectedContent: []string{
102+
`"agentic_workflows": {`,
103+
`"command": "gh"`,
104+
`"args": ["aw", "mcp-server"]`,
105+
`"env": {`,
106+
`"GITHUB_TOKEN": "$GITHUB_TOKEN"`,
107+
` }`, // isLast = true, no comma
108+
},
109+
unexpectedContent: []string{
110+
`"type"`,
111+
`"tools"`,
112+
`\\${`, // Should not have backslash-escaped variables in Claude format
113+
},
114+
},
115+
{
116+
name: "Multiple args formatting",
117+
serverID: "test_server",
118+
command: "testcmd",
119+
args: []string{"arg1", "arg2", "arg3"},
120+
envVars: []string{"VAR1", "VAR2"},
121+
isLast: false,
122+
includeCopilotFields: true,
123+
expectedContent: []string{
124+
`"test_server": {`,
125+
`"args": ["arg1", "arg2", "arg3"]`,
126+
`"VAR1": "\${VAR1}"`,
127+
`"VAR2": "\${VAR2}"`,
128+
},
129+
unexpectedContent: []string{},
130+
},
131+
}
132+
133+
for _, tt := range tests {
134+
t.Run(tt.name, func(t *testing.T) {
135+
var output strings.Builder
136+
137+
renderBuiltinMCPServerBlock(&output, tt.serverID, tt.command, tt.args, tt.envVars, tt.isLast, tt.includeCopilotFields)
138+
139+
result := output.String()
140+
141+
// Check expected content
142+
for _, expected := range tt.expectedContent {
143+
if !strings.Contains(result, expected) {
144+
t.Errorf("Expected content not found: %q\nActual output:\n%s", expected, result)
145+
}
146+
}
147+
148+
// Check unexpected content
149+
for _, unexpected := range tt.unexpectedContent {
150+
if strings.Contains(result, unexpected) {
151+
t.Errorf("Unexpected content found: %q\nActual output:\n%s", unexpected, result)
152+
}
153+
}
154+
})
155+
}
156+
}
157+
158+
// TestBuiltinMCPServerBlockCommaHandling specifically tests comma handling for isLast parameter
159+
func TestBuiltinMCPServerBlockCommaHandling(t *testing.T) {
160+
tests := []struct {
161+
name string
162+
isLast bool
163+
expectedEnding string
164+
}{
165+
{
166+
name: "Not last - should have comma",
167+
isLast: false,
168+
expectedEnding: " },\n",
169+
},
170+
{
171+
name: "Is last - should not have comma",
172+
isLast: true,
173+
expectedEnding: " }\n",
174+
},
175+
}
176+
177+
for _, tt := range tests {
178+
t.Run(tt.name, func(t *testing.T) {
179+
var output strings.Builder
180+
181+
renderBuiltinMCPServerBlock(&output, "test", "node", []string{"arg"}, []string{"VAR"}, tt.isLast, false)
182+
183+
result := output.String()
184+
185+
if !strings.HasSuffix(result, tt.expectedEnding) {
186+
t.Errorf("Expected ending %q but got:\n%s", tt.expectedEnding, result)
187+
}
188+
})
189+
}
190+
}
191+
192+
// TestBuiltinMCPServerBlockEnvVarOrdering tests that environment variables maintain order
193+
func TestBuiltinMCPServerBlockEnvVarOrdering(t *testing.T) {
194+
envVars := []string{"VAR_A", "VAR_B", "VAR_C", "VAR_D"}
195+
196+
var output strings.Builder
197+
renderBuiltinMCPServerBlock(&output, "test", "cmd", []string{"arg"}, envVars, true, false)
198+
199+
result := output.String()
200+
201+
// Find positions of each variable in the output
202+
positions := make(map[string]int)
203+
for _, envVar := range envVars {
204+
pos := strings.Index(result, `"`+envVar+`"`)
205+
if pos == -1 {
206+
t.Errorf("Environment variable %s not found in output", envVar)
207+
continue
208+
}
209+
positions[envVar] = pos
210+
}
211+
212+
// Verify ordering
213+
for i := 0; i < len(envVars)-1; i++ {
214+
currentVar := envVars[i]
215+
nextVar := envVars[i+1]
216+
217+
if positions[currentVar] >= positions[nextVar] {
218+
t.Errorf("Environment variables out of order: %s should come before %s", currentVar, nextVar)
219+
}
220+
}
221+
}

0 commit comments

Comments
 (0)