Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions actions/setup/js/json_object_to_markdown.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function formatValue(value) {
* Convert a plain JavaScript object to Markdown bullet points.
* Nested objects and arrays are rendered as indented sub-lists.
*
* @param {Record<string, unknown>} obj - The object to render
* @param {object} obj - The object to render
* @param {number} [depth=0] - Current indentation depth
* @returns {string} - Markdown bullet list string
*/
Expand All @@ -60,15 +60,15 @@ function jsonObjectToMarkdown(obj, depth = 0) {
lines.push(`${indent}- **${label}**:`);
for (const item of value) {
if (typeof item === "object" && item !== null) {
lines.push(jsonObjectToMarkdown(/** @type {Record<string, unknown>} */ item, depth + 1));
lines.push(jsonObjectToMarkdown(item, depth + 1));
} else {
lines.push(`${" ".repeat(depth + 1)}- ${String(item)}`);
}
}
}
} else if (typeof value === "object" && value !== null) {
lines.push(`${indent}- **${label}**:`);
lines.push(jsonObjectToMarkdown(/** @type {Record<string, unknown>} */ value, depth + 1));
lines.push(jsonObjectToMarkdown(value, depth + 1));
} else {
const formatted = formatValue(value);
lines.push(`${indent}- **${label}**: ${formatted}`);
Expand Down
13 changes: 7 additions & 6 deletions pkg/workflow/claude_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
// Claude uses JSON format without Copilot-specific fields and multi-line args
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
return NewMCPConfigRenderer(MCPRendererOptions{
IncludeCopilotFields: false, // Claude doesn't use "type" and "tools" fields
InlineArgs: false, // Claude uses multi-line args format
Format: "json",
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
IncludeCopilotFields: false, // Claude doesn't use "type" and "tools" fields
InlineArgs: false, // Claude uses multi-line args format
Format: "json",
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
})
}

Expand Down Expand Up @@ -59,7 +60,7 @@ func (e *ClaudeEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
},
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
},
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
return e.renderClaudeMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData)
Expand Down
28 changes: 16 additions & 12 deletions pkg/workflow/codex_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
// Codex uses TOML format without Copilot-specific fields and multi-line args
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
return NewMCPConfigRenderer(MCPRendererOptions{
IncludeCopilotFields: false, // Codex doesn't use "type" and "tools" fields
InlineArgs: false, // Codex uses multi-line args format
Format: "toml",
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
IncludeCopilotFields: false, // Codex doesn't use "type" and "tools" fields
InlineArgs: false, // Codex uses multi-line args format
Format: "toml",
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
})
}

Expand Down Expand Up @@ -69,7 +70,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
renderer.RenderMCPScriptsMCP(yaml, workflowData.MCPScripts, workflowData)
}
case "web-fetch":
renderMCPFetchServerConfig(yaml, "toml", " ", false, false)
renderMCPFetchServerConfig(yaml, "toml", " ", false, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
default:
// Handle custom MCP tools using shared helper (with adapter for isLast parameter)
HandleCustomMCPToolInSwitch(yaml, toolName, expandedTools, false, func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
Expand Down Expand Up @@ -112,11 +113,12 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
actionMode = workflowData.ActionMode
}
return NewMCPConfigRenderer(MCPRendererOptions{
IncludeCopilotFields: false, // Gateway doesn't need Copilot fields
InlineArgs: false, // Use standard multi-line format
Format: "json",
IsLast: isLast,
ActionMode: actionMode,
IncludeCopilotFields: false, // Gateway doesn't need Copilot fields
InlineArgs: false, // Use standard multi-line format
Format: "json",
IsLast: isLast,
ActionMode: actionMode,
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
})
}

Expand Down Expand Up @@ -152,7 +154,7 @@ func (e *CodexEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]an
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
},
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
},
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
return e.renderCodexJSONMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData)
Expand All @@ -177,6 +179,7 @@ func (e *CodexEngine) renderCodexMCPConfigWithContext(yaml *strings.Builder, too
IndentLevel: " ",
Format: "toml",
RewriteLocalhostToDocker: rewriteLocalhost,
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
}

err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer)
Expand All @@ -200,6 +203,7 @@ func (e *CodexEngine) renderCodexJSONMCPConfigWithContext(yaml *strings.Builder,
Format: "json",
IndentLevel: " ",
RewriteLocalhostToDocker: rewriteLocalhost,
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
}

yaml.WriteString(" \"" + toolName + "\": {\n")
Expand Down
14 changes: 8 additions & 6 deletions pkg/workflow/copilot_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]
// Copilot uses JSON format with type and tools fields, and inline args
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
return NewMCPConfigRenderer(MCPRendererOptions{
IncludeCopilotFields: true, // Copilot uses "type" and "tools" fields
InlineArgs: true, // Copilot uses inline args format
Format: "json",
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
IncludeCopilotFields: true, // Copilot uses "type" and "tools" fields
InlineArgs: true, // Copilot uses inline args format
Format: "json",
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
})
}

Expand Down Expand Up @@ -64,7 +65,7 @@ func (e *CopilotEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
},
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
renderMCPFetchServerConfig(yaml, "json", " ", isLast, true)
renderMCPFetchServerConfig(yaml, "json", " ", isLast, true, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
},
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
return e.renderCopilotMCPConfigWithContext(yaml, toolName, toolConfig, isLast, workflowData)
Expand Down Expand Up @@ -96,6 +97,7 @@ func (e *CopilotEngine) renderCopilotMCPConfigWithContext(yaml *strings.Builder,
IndentLevel: " ",
RequiresCopilotFields: true,
RewriteLocalhostToDocker: rewriteLocalhost,
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
}

yaml.WriteString(" \"" + toolName + "\": {\n")
Expand Down
14 changes: 12 additions & 2 deletions pkg/workflow/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,21 @@ func AddMCPFetchServerIfNeeded(tools map[string]any, engine CodingAgentEngine) (
// renderMCPFetchServerConfig renders the MCP fetch server configuration
// This is a shared function that can be used by all engines
// includeTools parameter adds "tools": ["*"] field for engines that require it (e.g., Copilot)
func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent string, isLast bool, includeTools bool) {
// guardPolicies parameter adds write-sink guard policies when derived from the GitHub guard-policy
func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent string, isLast bool, includeTools bool, guardPolicies map[string]any) {
fetchLog.Printf("Rendering MCP fetch server config: format=%s, includeTools=%v", format, includeTools)

switch format {
case "json":
// JSON format (for Claude, Copilot, Custom engines)
// Use container key per MCP Gateway schema (container-based stdio server)
yaml.WriteString(indent + "\"web-fetch\": {\n")
yaml.WriteString(indent + " \"container\": \"mcp/fetch\"\n")
if len(guardPolicies) > 0 {
yaml.WriteString(indent + " \"container\": \"mcp/fetch\",\n")
renderGuardPoliciesJSON(yaml, guardPolicies, indent+" ")
} else {
yaml.WriteString(indent + " \"container\": \"mcp/fetch\"\n")
}
if isLast {
yaml.WriteString(indent + "}\n")
} else {
Expand All @@ -73,5 +79,9 @@ func renderMCPFetchServerConfig(yaml *strings.Builder, format string, indent str
yaml.WriteString(indent + "\n")
yaml.WriteString(indent + "[mcp_servers.\"web-fetch\"]\n")
yaml.WriteString(indent + "container = \"mcp/fetch\"\n")
// Add guard policies as a separate TOML section if configured
if len(guardPolicies) > 0 {
renderGuardPoliciesToml(yaml, guardPolicies, "web-fetch")
}
}
}
2 changes: 1 addition & 1 deletion pkg/workflow/fetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ func TestRenderMCPFetchServerConfig(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var yaml strings.Builder
renderMCPFetchServerConfig(&yaml, tt.format, tt.indent, tt.isLast, tt.includeTools)
renderMCPFetchServerConfig(&yaml, tt.format, tt.indent, tt.isLast, tt.includeTools, nil)
output := yaml.String()

for _, substr := range tt.expectSubstr {
Expand Down
13 changes: 7 additions & 6 deletions pkg/workflow/gemini_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
// Create unified renderer with Gemini-specific options
createRenderer := func(isLast bool) *MCPConfigRendererUnified {
return NewMCPConfigRenderer(MCPRendererOptions{
IncludeCopilotFields: false,
InlineArgs: false,
Format: "json", // Gemini uses JSON format like Claude/Codex
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
IncludeCopilotFields: false,
InlineArgs: false,
Format: "json", // Gemini uses JSON format like Claude/Codex
IsLast: isLast,
ActionMode: GetActionModeFromWorkflowData(workflowData),
WriteSinkGuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
})
}

Expand Down Expand Up @@ -54,7 +55,7 @@ func (e *GeminiEngine) RenderMCPConfig(yaml *strings.Builder, tools map[string]a
renderer.RenderMCPScriptsMCP(yaml, mcpScripts, workflowData)
},
RenderWebFetch: func(yaml *strings.Builder, isLast bool) {
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false)
renderMCPFetchServerConfig(yaml, "json", " ", isLast, false, deriveWriteSinkGuardPolicyFromWorkflow(workflowData))
},
RenderCustomMCPConfig: func(yaml *strings.Builder, toolName string, toolConfig map[string]any, isLast bool) error {
return renderCustomMCPConfigWrapperWithContext(yaml, toolName, toolConfig, isLast, workflowData)
Expand Down
10 changes: 8 additions & 2 deletions pkg/workflow/mcp_config_builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ 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, actionMode ActionMode) {
func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode, guardPolicies map[string]any) {
mcpBuiltinLog.Printf("Rendering Agentic Workflows MCP config: isLast=%v, includeCopilotFields=%v, actionMode=%v", isLast, includeCopilotFields, actionMode)

// Environment variables: map of env var name to value (literal) or source variable (reference)
Expand Down Expand Up @@ -288,7 +288,13 @@ func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bo

yaml.WriteString(" \"" + envVar.name + "\": \"" + valueStr + "\"" + comma + "\n")
}
yaml.WriteString(" }\n")
// Close env section - with or without trailing comma depending on whether guard policies follow
if len(guardPolicies) > 0 {
yaml.WriteString(" },\n")
renderGuardPoliciesJSON(yaml, guardPolicies, " ")
} else {
yaml.WriteString(" }\n")
}

if isLast {
yaml.WriteString(" }\n")
Expand Down
4 changes: 2 additions & 2 deletions pkg/workflow/mcp_config_comprehensive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,7 @@ func TestRenderSerenaMCPConfigWithOptions(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
var output strings.Builder

renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs)
renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs, nil)

result := output.String()

Expand Down Expand Up @@ -1153,7 +1153,7 @@ func TestRenderSerenaMCPConfigDockerMode(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
var output strings.Builder

renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs)
renderSerenaMCPConfigWithOptions(&output, tt.serenaTool, tt.isLast, tt.includeCopilotFields, tt.inlineArgs, nil)

result := output.String()

Expand Down
17 changes: 16 additions & 1 deletion pkg/workflow/mcp_config_custom.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func renderCustomMCPConfigWrapperWithContext(yaml *strings.Builder, toolName str
IndentLevel: " ",
Format: "json",
RewriteLocalhostToDocker: rewriteLocalhost,
GuardPolicies: deriveWriteSinkGuardPolicyFromWorkflow(workflowData),
}

err := renderSharedMCPConfig(yaml, toolName, toolConfig, renderer)
Expand Down Expand Up @@ -181,9 +182,14 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma
return nil
}

// When guard policies are present in JSON format, they become the actual last field.
// The last existing property must have a trailing comma to allow appending guard policies.
hasTrailingGuardPolicies := renderer.Format == "json" && len(renderer.GuardPolicies) > 0

// Render properties based on format
for propIndex, property := range existingProperties {
isLast := propIndex == len(existingProperties)-1
// In JSON format, if guard policies follow, the last existing property is no longer "last"
isLast := (propIndex == len(existingProperties)-1) && !hasTrailingGuardPolicies

switch property {
case "type":
Expand Down Expand Up @@ -497,6 +503,15 @@ func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig ma
}
}

// Render guard policies after all properties
if hasTrailingGuardPolicies {
// JSON format: guard policies are the last field inside the server object
renderGuardPoliciesJSON(yaml, renderer.GuardPolicies, renderer.IndentLevel)
} else if renderer.Format == "toml" && len(renderer.GuardPolicies) > 0 {
// TOML format: guard policies are a separate TOML section after the server config
renderGuardPoliciesToml(yaml, renderer.GuardPolicies, toolName)
}

return nil
}

Expand Down
10 changes: 8 additions & 2 deletions pkg/workflow/mcp_config_playwright_renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ var mcpPlaywrightLog = logger.New("workflow:mcp_config_playwright_renderer")
// renderPlaywrightMCPConfigWithOptions generates the Playwright 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, entrypointArgs, mounts, and args fields.
func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig, isLast bool, includeCopilotFields bool, inlineArgs bool) {
func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfig *PlaywrightToolConfig, isLast bool, includeCopilotFields bool, inlineArgs bool, guardPolicies map[string]any) {
mcpPlaywrightLog.Printf("Rendering Playwright MCP config options: copilot_fields=%t, inline_args=%t", includeCopilotFields, inlineArgs)
customArgs := getPlaywrightCustomArgs(playwrightConfig)

Expand Down Expand Up @@ -155,7 +155,13 @@ func renderPlaywrightMCPConfigWithOptions(yaml *strings.Builder, playwrightConfi
}

// Add volume mounts
yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\n")
// When guard policies follow, mounts is not the last field (add trailing comma)
if len(guardPolicies) > 0 {
yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"],\n")
renderGuardPoliciesJSON(yaml, guardPolicies, " ")
} else {
yaml.WriteString(" \"mounts\": [\"/tmp/gh-aw/mcp-logs:/tmp/gh-aw/mcp-logs:rw\"]\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.
Expand Down
2 changes: 1 addition & 1 deletion pkg/workflow/mcp_config_refactor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func TestRenderAgenticWorkflowsMCPConfigWithOptions(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
var output strings.Builder

renderAgenticWorkflowsMCPConfigWithOptions(&output, tt.isLast, tt.includeCopilotFields, tt.actionMode)
renderAgenticWorkflowsMCPConfigWithOptions(&output, tt.isLast, tt.includeCopilotFields, tt.actionMode, nil)

result := output.String()

Expand Down
Loading
Loading