diff --git a/.github/workflows/smoke-copilot.lock.yml b/.github/workflows/smoke-copilot.lock.yml
index fc9db2a022..c2571f82ea 100644
--- a/.github/workflows/smoke-copilot.lock.yml
+++ b/.github/workflows/smoke-copilot.lock.yml
@@ -1,4 +1,4 @@
-# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"6d0a385e47ce5ed241f4358e1578525037722f288b64d3dc18289d01bd352fbd","agent_id":"copilot"}
+# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"a351500ae6b2fe9704233bd8fa7e9e656214c7854022682cb391c274c87abf65","agent_id":"copilot"}
# ___ _ _
# / _ \ | | (_)
# | |_| | __ _ ___ _ __ | |_ _ ___
@@ -220,9 +220,9 @@ jobs:
run: |
bash ${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh
{
- cat << 'GH_AW_PROMPT_2d91fec7281e9c47_EOF'
+ cat << 'GH_AW_PROMPT_caa193c5cfb3b282_EOF'
- GH_AW_PROMPT_2d91fec7281e9c47_EOF
+ GH_AW_PROMPT_caa193c5cfb3b282_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
@@ -230,10 +230,13 @@ jobs:
cat "${RUNNER_TEMP}/gh-aw/prompts/agentic_workflows_guide.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md"
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
- cat << 'GH_AW_PROMPT_2d91fec7281e9c47_EOF'
+ cat << 'GH_AW_PROMPT_caa193c5cfb3b282_EOF'
Tools: add_comment(max:2), create_issue, create_discussion, create_pull_request_review_comment(max:5), submit_pull_request_review, reply_to_pull_request_review_comment(max:5), add_labels, remove_labels, set_issue_type, dispatch_workflow, missing_tool, missing_data, noop, send_slack_message
+ GH_AW_PROMPT_caa193c5cfb3b282_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
+ cat << 'GH_AW_PROMPT_caa193c5cfb3b282_EOF'
The following GitHub context information is available for this workflow:
{{#if __GH_AW_GITHUB_ACTOR__ }}
@@ -262,9 +265,9 @@ jobs:
{{/if}}
- GH_AW_PROMPT_2d91fec7281e9c47_EOF
+ GH_AW_PROMPT_caa193c5cfb3b282_EOF
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
- cat << 'GH_AW_PROMPT_2d91fec7281e9c47_EOF'
+ cat << 'GH_AW_PROMPT_caa193c5cfb3b282_EOF'
## Serena Code Analysis
@@ -304,7 +307,7 @@ jobs:
{{#runtime-import .github/workflows/shared/mcp/serena-go.md}}
{{#runtime-import .github/workflows/shared/observability-otlp.md}}
{{#runtime-import .github/workflows/smoke-copilot.md}}
- GH_AW_PROMPT_2d91fec7281e9c47_EOF
+ GH_AW_PROMPT_caa193c5cfb3b282_EOF
} > "$GH_AW_PROMPT"
- name: Interpolate variables and render templates
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
@@ -337,6 +340,7 @@ jobs:
GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
GH_AW_GITHUB_SERVER_URL: ${{ github.server_url }}
GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_MCP_CLI_SERVERS_LIST: "- `agenticworkflows` — run `agenticworkflows --help` to see available tools\n- `github` — run `github --help` to see available tools\n- `playwright` — run `playwright --help` to see available tools\n- `serena` — run `serena --help` to see available tools"
GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
with:
script: |
@@ -361,6 +365,7 @@ jobs:
GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
GH_AW_GITHUB_SERVER_URL: process.env.GH_AW_GITHUB_SERVER_URL,
GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST,
GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
}
});
@@ -566,12 +571,12 @@ jobs:
mkdir -p ${RUNNER_TEMP}/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/safeoutputs
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
- cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_8c3103569671ea37_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_6351b36318a2ecfb_EOF'
{"add_comment":{"allowed_repos":["github/gh-aw"],"hide_older_comments":true,"max":2},"add_labels":{"allowed":["smoke-copilot"],"allowed_repos":["github/gh-aw"]},"create_discussion":{"category":"announcements","close_older_discussions":true,"close_older_key":"smoke-copilot","expires":2,"fallback_to_issue":true,"labels":["ai-generated"],"max":1},"create_issue":{"close_older_issues":true,"close_older_key":"smoke-copilot","expires":2,"group":true,"labels":["automation","testing"],"max":1},"create_pull_request_review_comment":{"max":5,"side":"RIGHT"},"dispatch_workflow":{"max":1,"workflow_files":{"haiku-printer":".yml"},"workflows":["haiku-printer"]},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"remove_labels":{"allowed":["smoke"]},"reply_to_pull_request_review_comment":{"max":5},"send-slack-message":{"description":"Send a message to Slack (stub for testing)","inputs":{"message":{"description":"The message to send","required":false,"type":"string"}},"output":"Slack message stub executed!"},"set_issue_type":{},"submit_pull_request_review":{"max":1}}
- GH_AW_SAFE_OUTPUTS_CONFIG_8c3103569671ea37_EOF
+ GH_AW_SAFE_OUTPUTS_CONFIG_6351b36318a2ecfb_EOF
- name: Write Safe Outputs Tools
run: |
- cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_6ba81623fc072ff3_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/tools_meta.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_META_753e8d4c202f9446_EOF'
{
"description_suffixes": {
"add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added.",
@@ -629,8 +634,8 @@ jobs:
}
]
}
- GH_AW_SAFE_OUTPUTS_TOOLS_META_6ba81623fc072ff3_EOF
- cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_4cb5c46d855d3c50_EOF'
+ GH_AW_SAFE_OUTPUTS_TOOLS_META_753e8d4c202f9446_EOF
+ cat > ${RUNNER_TEMP}/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_d2a901799ebcae84_EOF'
{
"add_comment": {
"defaultMax": 1,
@@ -900,7 +905,7 @@ jobs:
}
}
}
- GH_AW_SAFE_OUTPUTS_VALIDATION_4cb5c46d855d3c50_EOF
+ GH_AW_SAFE_OUTPUTS_VALIDATION_d2a901799ebcae84_EOF
node ${RUNNER_TEMP}/gh-aw/actions/generate_safe_outputs_tools.cjs
- name: Generate Safe Outputs MCP Server Config
id: safe-outputs-config
@@ -945,7 +950,7 @@ jobs:
- name: Setup MCP Scripts Config
run: |
mkdir -p ${RUNNER_TEMP}/gh-aw/mcp-scripts/logs
- cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/tools.json << 'GH_AW_MCP_SCRIPTS_TOOLS_7babc89e6d790778_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/tools.json << 'GH_AW_MCP_SCRIPTS_TOOLS_93b95a6fab4cbabc_EOF'
{
"serverName": "mcpscripts",
"version": "1.0.0",
@@ -1061,8 +1066,8 @@ jobs:
}
]
}
- GH_AW_MCP_SCRIPTS_TOOLS_7babc89e6d790778_EOF
- cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs << 'GH_AW_MCP_SCRIPTS_SERVER_ef1fbc7ce3eca295_EOF'
+ GH_AW_MCP_SCRIPTS_TOOLS_93b95a6fab4cbabc_EOF
+ cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs << 'GH_AW_MCP_SCRIPTS_SERVER_e059f8505a05caf9_EOF'
const path = require("path");
const { startHttpServer } = require("./mcp_scripts_mcp_server_http.cjs");
const configPath = path.join(__dirname, "tools.json");
@@ -1076,12 +1081,12 @@ jobs:
console.error("Failed to start mcp-scripts HTTP server:", error);
process.exit(1);
});
- GH_AW_MCP_SCRIPTS_SERVER_ef1fbc7ce3eca295_EOF
+ GH_AW_MCP_SCRIPTS_SERVER_e059f8505a05caf9_EOF
chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/mcp-server.cjs
- name: Setup MCP Scripts Tool Files
run: |
- cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh << 'GH_AW_MCP_SCRIPTS_SH_GH_5a6688685d632c08_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh << 'GH_AW_MCP_SCRIPTS_SH_GH_14542eaa70e08c01_EOF'
#!/bin/bash
# Auto-generated mcp-script tool: gh
# Execute any gh CLI command. This tool is accessible as 'mcpscripts-gh'. Provide the full command after 'gh' (e.g., args: 'pr list --limit 5'). The tool will run: gh . Use single quotes ' for complex args to avoid shell interpretation issues.
@@ -1092,9 +1097,9 @@ jobs:
echo " token: ${GH_AW_GH_TOKEN:0:6}..."
GH_TOKEN="$GH_AW_GH_TOKEN" gh $INPUT_ARGS
- GH_AW_MCP_SCRIPTS_SH_GH_5a6688685d632c08_EOF
+ GH_AW_MCP_SCRIPTS_SH_GH_14542eaa70e08c01_EOF
chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/gh.sh
- cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-discussion-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_acccc7340415fad4_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-discussion-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_968756eb1dee064f_EOF'
#!/bin/bash
# Auto-generated mcp-script tool: github-discussion-query
# Query GitHub discussions with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.
@@ -1229,9 +1234,9 @@ jobs:
EOF
fi
- GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_acccc7340415fad4_EOF
+ GH_AW_MCP_SCRIPTS_SH_GITHUB-DISCUSSION-QUERY_968756eb1dee064f_EOF
chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-discussion-query.sh
- cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-issue-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_a6eacbb65c40c0ed_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-issue-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_78f5b8f780edb3ad_EOF'
#!/bin/bash
# Auto-generated mcp-script tool: github-issue-query
# Query GitHub issues with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.
@@ -1310,9 +1315,9 @@ jobs:
fi
- GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_a6eacbb65c40c0ed_EOF
+ GH_AW_MCP_SCRIPTS_SH_GITHUB-ISSUE-QUERY_78f5b8f780edb3ad_EOF
chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-issue-query.sh
- cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-pr-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_cba8eb127506e4a8_EOF'
+ cat > ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-pr-query.sh << 'GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_65f8bee389f4452e_EOF'
#!/bin/bash
# Auto-generated mcp-script tool: github-pr-query
# Query GitHub pull requests with jq filtering support. Without --jq, returns schema and data size info. Use --jq '.' to get all data, or specific jq expressions to filter.
@@ -1397,7 +1402,7 @@ jobs:
fi
- GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_cba8eb127506e4a8_EOF
+ GH_AW_MCP_SCRIPTS_SH_GITHUB-PR-QUERY_65f8bee389f4452e_EOF
chmod +x ${RUNNER_TEMP}/gh-aw/mcp-scripts/github-pr-query.sh
- name: Generate MCP Scripts Server Config
@@ -1465,10 +1470,12 @@ jobs:
export DEBUG="*"
export GH_AW_ENGINE="copilot"
+ export GH_AW_MCP_CLI_SERVERS='["agenticworkflows","github","playwright","serena"]'
+ echo 'GH_AW_MCP_CLI_SERVERS=["agenticworkflows","github","playwright","serena"]' >> "$GITHUB_ENV"
export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_MCP_SCRIPTS_PORT -e GH_AW_MCP_SCRIPTS_API_KEY -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -e GH_AW_GH_TOKEN -e GH_DEBUG -e GH_TOKEN -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.2.14'
mkdir -p /home/runner/.copilot
- cat << GH_AW_MCP_CONFIG_8d31e9e79e8b0709_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
+ cat << GH_AW_MCP_CONFIG_16cd234ad530a74b_EOF | bash ${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.sh
{
"mcpServers": {
"agenticworkflows": {
@@ -1588,7 +1595,21 @@ jobs:
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
}
}
- GH_AW_MCP_CONFIG_8d31e9e79e8b0709_EOF
+ GH_AW_MCP_CONFIG_16cd234ad530a74b_EOF
+ - name: Mount MCP servers as CLIs
+ id: mount-mcp-clis
+ continue-on-error: true
+ env:
+ MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
+ MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }}
+ MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
+ uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
+ await main();
- name: Download activation artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
diff --git a/.github/workflows/smoke-copilot.md b/.github/workflows/smoke-copilot.md
index 8c6087573e..351d8b850b 100644
--- a/.github/workflows/smoke-copilot.md
+++ b/.github/workflows/smoke-copilot.md
@@ -44,6 +44,7 @@ tools:
- pelikhan
playwright:
web-fetch:
+ mount-as-clis: true
runtimes:
go:
version: "1.25"
@@ -124,14 +125,30 @@ strict: false
**IMPORTANT: Keep all outputs extremely short and concise. Use single-line responses where possible. No verbose explanations.**
+## Tool Access Overview
+
+This workflow uses `mount-as-clis: true`. The following MCP servers are **NOT available as MCP tools** — they are mounted exclusively as **shell CLI commands** (see `` section above). You **must** use them via the `bash` tool:
+
+- **`github`** — use `github [--param value...]` in bash (e.g. `github pull_request_read --method list ...`)
+- **`playwright`** — use `playwright [--param value...]` in bash (e.g. `playwright browser_navigate --url ...`)
+- **`serena`** — use `serena [--param value...]` in bash (e.g. `serena activate_project --path ...`)
+- **`agenticworkflows`** — use `agenticworkflows [--param value...]` in bash
+
+Run ` --help` to list all available tools for a server, or ` --help` for detailed parameter info.
+
+These are **not** MCP protocol tools — they are bash executables. Call them with the `bash` tool only.
+
## Test Requirements
-1. **GitHub MCP Testing**: Review the last 2 merged pull requests in ${{ github.repository }}
+1. **GitHub CLI Testing**: Use the `github` CLI command (via bash) to list the last 2 merged pull requests in ${{ github.repository }}:
+ ```bash
+ github pull_request_read --method list --owner --repo --state closed --per_page 2
+ ```
2. **MCP Scripts GH CLI Testing**: Use the `mcpscripts-gh` tool to query 2 pull requests from ${{ github.repository }} (use args: "pr list --repo ${{ github.repository }} --limit 2 --json number,title,author")
-3. **Serena MCP Testing**:
- - Use the Serena MCP server tool `activate_project` to initialize the workspace at `${{ github.workspace }}` and verify it succeeds (do NOT use bash to run go commands - use Serena's MCP tools)
- - After initialization, use the `find_symbol` tool to search for symbols (find which tool to call) and verify that at least 3 symbols are found in the results
-4. **Playwright Testing**: Use the playwright tools to navigate to and verify the page title contains "GitHub" (do NOT try to install playwright - use the provided MCP tools)
+3. **Serena CLI Testing**:
+ - Use bash to run `serena activate_project --path ${{ github.workspace }}` to initialize the workspace and verify it succeeds (do NOT use bash to run go commands - use the serena CLI only)
+ - After initialization, use bash to run `serena find_symbol --name_path ` to search for symbols and verify that at least 3 symbols are found in the results
+4. **Playwright CLI Testing**: Use bash to run `playwright browser_navigate --url https://github.com` to navigate to , then `playwright browser_snapshot` to capture the page and verify the title contains "GitHub" (do NOT try to install playwright - use the `playwright` CLI command via bash only)
5. **Web Fetch Testing**: Use the web-fetch tool to fetch https://github.com and verify the response contains "GitHub" (do NOT use bash or playwright for this test - use the web-fetch tool directly)
6. **File Writing Testing**: Create a test file `/tmp/gh-aw/agent/smoke-test-copilot-${{ github.run_id }}.txt` with content "Smoke test passed for Copilot at $(date)" (create the directory if it doesn't exist)
7. **Bash Tool Testing**: Execute bash commands to verify file creation was successful (use `cat` to read the file back)
@@ -142,7 +159,7 @@ strict: false
9. **Build gh-aw**: Run `GOCACHE=/tmp/go-cache GOMODCACHE=/tmp/go-mod make build` to verify the agent can successfully build the gh-aw project (both caches must be set to /tmp because the default cache locations are not writable). If the command fails, mark this test as ❌ and report the failure.
10. **Discussion Creation Testing**: Use the `create_discussion` safe-output tool to create a discussion in the announcements category titled "copilot was here" with the label "ai-generated"
11. **Workflow Dispatch Testing**: Use the `dispatch_workflow` safe output tool to trigger the `haiku-printer` workflow with a haiku as the message input. Create an original, creative haiku about software testing or automation.
-12. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. To test `reply_to_pull_request_review_comment`: use the `pull_request_read` tool (with `method: "get_review_comments"` and `pullNumber: ${{ github.event.pull_request.number }}`) to fetch the PR's existing review comments, then reply to the most recent one using `reply_to_pull_request_review_comment` with its actual numeric `id` as the `comment_id`. Note: `create_pull_request_review_comment` does not return a `comment_id` — you must fetch existing comment IDs from the GitHub API. If the PR has no existing review comments, skip the reply sub-test.
+12. **PR Review Testing**: Review the diff of the current pull request. Leave 1-2 inline `create_pull_request_review_comment` comments on specific lines, then call `submit_pull_request_review` with a brief body summarizing your review and event `COMMENT`. To test `reply_to_pull_request_review_comment`: use bash to run the `github` CLI command (`github pull_request_read --method get_review_comments --owner --repo --pullNumber ${{ github.event.pull_request.number }}`) to fetch the PR's existing review comments, then reply to the most recent one using `reply_to_pull_request_review_comment` with its actual numeric `id` as the `comment_id`. Note: `create_pull_request_review_comment` does not return a `comment_id` — you must fetch existing comment IDs via the `github` CLI. If the PR has no existing review comments, skip the reply sub-test.
## Output
diff --git a/actions/setup/js/mount_mcp_as_cli.cjs b/actions/setup/js/mount_mcp_as_cli.cjs
new file mode 100644
index 0000000000..9ea08b9ecf
--- /dev/null
+++ b/actions/setup/js/mount_mcp_as_cli.cjs
@@ -0,0 +1,485 @@
+// @ts-check
+///
+
+/**
+ * mount_mcp_as_cli.cjs
+ *
+ * Mounts MCP servers as local CLI tools by reading the manifest written by
+ * start_mcp_gateway.sh, querying each server for its tool list, and generating
+ * a standalone bash wrapper script per server in ${RUNNER_TEMP}/gh-aw/mcp-cli/bin/.
+ *
+ * The bin directory is locked (chmod 555) so the agent cannot modify or inject
+ * scripts. The directory is added to PATH via core.addPath().
+ *
+ * Scripts are placed under ${RUNNER_TEMP}/gh-aw/ (not /tmp/gh-aw/) so they are
+ * accessible inside the AWF sandbox, which mounts ${RUNNER_TEMP}/gh-aw read-only.
+ *
+ * Generated CLI wrapper usage:
+ * --help Show all available commands
+ * --help Show help for a specific command
+ * [--param value ...] Execute a command
+ */
+
+const fs = require("fs");
+const http = require("http");
+const path = require("path");
+
+const MANIFEST_FILE = "/tmp/gh-aw/mcp-cli/manifest.json";
+// Use RUNNER_TEMP so the bin and tools directories are inside the AWF sandbox mount
+// (AWF mounts ${RUNNER_TEMP}/gh-aw read-only; /tmp/gh-aw is not accessible inside AWF)
+const RUNNER_TEMP = process.env.RUNNER_TEMP || "/home/runner/work/_temp";
+const CLI_BIN_DIR = `${RUNNER_TEMP}/gh-aw/mcp-cli/bin`;
+const TOOLS_DIR = `${RUNNER_TEMP}/gh-aw/mcp-cli/tools`;
+
+/** MCP servers that are internal infrastructure and should not be user-facing CLIs */
+const INTERNAL_SERVERS = new Set(["safeoutputs", "mcp-scripts", "mcpscripts"]);
+
+/**
+ * Rewrite a raw gateway manifest URL to use the container-accessible domain.
+ *
+ * The manifest stores raw gateway-output URLs (e.g., http://0.0.0.0:80/mcp/server)
+ * that work from the host. Inside the AWF sandbox the gateway is reachable via
+ * MCP_GATEWAY_DOMAIN:MCP_GATEWAY_PORT (typically host.docker.internal:80).
+ *
+ * @param {string} rawUrl - URL from the manifest (host-accessible)
+ * @returns {string} URL suitable for use inside AWF containers
+ */
+function toContainerUrl(rawUrl) {
+ const domain = process.env.MCP_GATEWAY_DOMAIN;
+ const port = process.env.MCP_GATEWAY_PORT;
+ if (domain && port) {
+ return rawUrl.replace(/^https?:\/\/[^/]+\/mcp\//, `http://${domain}:${port}/mcp/`);
+ }
+ return rawUrl;
+}
+
+/**
+ * Make an HTTP POST request with a JSON body and return the parsed response.
+ *
+ * @param {string} urlStr - Full URL to POST to
+ * @param {Record} headers - Request headers
+ * @param {unknown} body - Request body (will be JSON-serialized)
+ * @param {number} [timeoutMs=15000] - Request timeout in milliseconds
+ * @returns {Promise<{statusCode: number, body: unknown, headers: Record}>}
+ */
+function httpPostJSON(urlStr, headers, body, timeoutMs = 15000) {
+ return new Promise((resolve, reject) => {
+ const parsedUrl = new URL(urlStr);
+ const bodyStr = JSON.stringify(body);
+
+ const options = {
+ hostname: parsedUrl.hostname,
+ port: parsedUrl.port || 80,
+ path: parsedUrl.pathname + parsedUrl.search,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(bodyStr),
+ ...headers,
+ },
+ };
+
+ const req = http.request(options, res => {
+ let data = "";
+ res.on("data", chunk => {
+ data += chunk;
+ });
+ res.on("end", () => {
+ let parsed;
+ try {
+ parsed = JSON.parse(data);
+ } catch {
+ parsed = data;
+ }
+ resolve({
+ statusCode: res.statusCode || 0,
+ body: parsed,
+ headers: /** @type {Record} */ res.headers,
+ });
+ });
+ });
+
+ req.on("error", err => {
+ reject(err);
+ });
+
+ req.setTimeout(timeoutMs, () => {
+ req.destroy();
+ reject(new Error(`HTTP request timed out after ${timeoutMs}ms`));
+ });
+
+ req.write(bodyStr);
+ req.end();
+ });
+}
+
+/**
+ * Query the tools list from an MCP server via JSON-RPC.
+ * Follows the standard MCP handshake: initialize → notifications/initialized → tools/list.
+ *
+ * @param {string} serverUrl - HTTP URL of the MCP server endpoint
+ * @param {string} apiKey - Bearer token for gateway authentication
+ * @param {typeof import("@actions/core")} core - GitHub Actions core
+ * @returns {Promise>}
+ */
+async function fetchMCPTools(serverUrl, apiKey, core) {
+ const authHeaders = { Authorization: apiKey };
+
+ // Step 1: initialize – establish the session and capture Mcp-Session-Id if present
+ let sessionHeader = {};
+ try {
+ const initResp = await httpPostJSON(
+ serverUrl,
+ authHeaders,
+ {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ capabilities: {},
+ clientInfo: { name: "mcp-cli-mount", version: "1.0.0" },
+ protocolVersion: "2024-11-05",
+ },
+ },
+ 15000
+ );
+ const sessionId = initResp.headers["mcp-session-id"];
+ if (sessionId && typeof sessionId === "string") {
+ sessionHeader = { "Mcp-Session-Id": sessionId };
+ }
+ } catch (err) {
+ core.warning(` initialize failed for ${serverUrl}: ${err instanceof Error ? err.message : String(err)}`);
+ return [];
+ }
+
+ // Step 2: notifications/initialized – required by MCP spec to complete the handshake.
+ // The server responds with 204 No Content; errors here are non-fatal.
+ try {
+ await httpPostJSON(serverUrl, { ...authHeaders, ...sessionHeader }, { jsonrpc: "2.0", method: "notifications/initialized", params: {} }, 10000);
+ } catch (err) {
+ core.warning(` notifications/initialized failed for ${serverUrl}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ // Step 3: tools/list – get the available tool definitions
+ try {
+ const listResp = await httpPostJSON(serverUrl, { ...authHeaders, ...sessionHeader }, { jsonrpc: "2.0", id: 2, method: "tools/list" }, 15000);
+ const respBody = /** @type {Record} */ listResp.body;
+ if (respBody && respBody.result && typeof respBody.result === "object") {
+ const result = /** @type {Record} */ respBody.result;
+ if (Array.isArray(result.tools)) {
+ return /** @type {Array<{name: string, description?: string, inputSchema?: unknown}>} */ result.tools;
+ }
+ }
+ return [];
+ } catch (err) {
+ core.warning(` tools/list failed for ${serverUrl}: ${err instanceof Error ? err.message : String(err)}`);
+ return [];
+ }
+}
+
+/**
+ * Generate the bash wrapper script content for a given MCP server.
+ * The generated script is a self-contained CLI that delegates all calls
+ * to the MCP gateway via curl, following the proper MCP session protocol:
+ * initialize → notifications/initialized → tools/call.
+ *
+ * The gateway API key is baked directly into the generated script at
+ * generation time because MCP_GATEWAY_API_KEY is excluded from the AWF
+ * sandbox environment (--exclude-env MCP_GATEWAY_API_KEY) and would not
+ * be accessible to the agent at runtime.
+ *
+ * @param {string} serverName - Name of the MCP server
+ * @param {string} serverUrl - HTTP URL of the MCP server endpoint
+ * @param {string} toolsFile - Path to the cached tools JSON file
+ * @param {string} apiKey - Gateway API key, baked into the script at generation time
+ * @returns {string} Content of the bash wrapper script
+ */
+function generateCLIWrapperScript(serverName, serverUrl, toolsFile, apiKey) {
+ // We use a template literal but avoid single quotes in the embedded bash
+ // to keep the heredoc-free approach clean. The API key is embedded directly
+ // because MCP_GATEWAY_API_KEY is excluded from the AWF sandbox environment.
+ return `#!/usr/bin/env bash
+# MCP CLI wrapper for: ${serverName}
+# Auto-generated by gh-aw. Do not modify.
+#
+# Usage:
+# ${serverName} --help Show all available commands
+# ${serverName} --help Show help for a specific command
+# ${serverName} [--param value...] Execute a command
+
+SERVER_NAME="${serverName}"
+SERVER_URL="${serverUrl}"
+TOOLS_FILE="${toolsFile}"
+
+# API key is baked in at generation time; MCP_GATEWAY_API_KEY is not available
+# inside the AWF sandbox (excluded via --exclude-env MCP_GATEWAY_API_KEY).
+API_KEY="${apiKey}"
+
+load_tools() {
+ if [ -f "\$TOOLS_FILE" ]; then
+ cat "\$TOOLS_FILE"
+ else
+ echo "[]"
+ fi
+}
+
+show_help() {
+ local tools
+ tools=\$(load_tools)
+ echo "Usage: \$SERVER_NAME [options]"
+ echo ""
+ echo "Available commands:"
+ if command -v jq &>/dev/null && echo "\$tools" | jq -e "length > 0" >/dev/null 2>&1; then
+ echo "\$tools" | jq -r ".[] | \\" \(.name)\\t\(.description // \\"No description\\")\\"" \\
+ | column -t -s $'\\t' 2>/dev/null \\
+ || echo "\$tools" | jq -r ".[] | \\" \(.name) \(.description // \\"\\")\\""
+ else
+ echo " (tool list unavailable)"
+ fi
+ echo ""
+ echo "Run '\$SERVER_NAME --help' for more information on a command."
+}
+
+show_tool_help() {
+ local tool_name="\$1"
+ local tools tool
+ tools=\$(load_tools)
+ tool=\$(echo "\$tools" | jq -r ".[] | select(.name == \\"\$tool_name\\")" 2>/dev/null || echo "")
+
+ if [ -z "\$tool" ]; then
+ echo "Error: Unknown command '"\$tool_name"'" >&2
+ echo "Run '"\$SERVER_NAME" --help' to see available commands." >&2
+ exit 1
+ fi
+
+ echo "Command: \$tool_name"
+ echo "Description: \$(echo "\$tool" | jq -r ".description // \\"No description\\"")"
+
+ local has_props
+ has_props=\$(echo "\$tool" | jq -r "has(\\"inputSchema\\") and ((.inputSchema.properties // {}) | length > 0)")
+
+ if [ "\$has_props" = "true" ]; then
+ echo ""
+ echo "Options:"
+ echo "\$tool" | jq -r ".inputSchema.properties | to_entries[] | \\" --\(.key) \(.value.description // .value.type // \\"string\\")\\""
+ local required
+ required=\$(echo "\$tool" | jq -r "(.inputSchema.required // []) | join(\\", \\")")
+ if [ -n "\$required" ]; then
+ echo ""
+ echo "Required: \$required"
+ fi
+ fi
+}
+
+call_tool() {
+ local tool_name="\$1"
+ shift
+
+ local args="{}"
+ while [[ \$# -gt 0 ]]; do
+ if [[ "\$1" == --* ]]; then
+ local key="\${1#--}"
+ if [[ \$# -ge 2 && "\$2" != --* ]]; then
+ local val="\$2"
+ args=\$(echo "\$args" | jq --arg k "\$key" --arg v "\$val" ". + {(\$k): \$v}")
+ shift 2
+ else
+ args=\$(echo "\$args" | jq --arg k "\$key" ". + {(\$k): true}")
+ shift 1
+ fi
+ else
+ shift
+ fi
+ done
+
+ # MCP session protocol: initialize → notifications/initialized → tools/call
+ # A separate headers file is used to capture the Mcp-Session-Id without mixing
+ # headers and body (curl -i mixes them, making parsing fragile).
+ local headers_file
+ headers_file=\$(mktemp)
+
+ # Step 1: initialize – establish the session
+ curl -s -D "\$headers_file" --max-time 30 -X POST "\$SERVER_URL" \\
+ -H "Authorization: \$API_KEY" \\
+ -H "Content-Type: application/json" \\
+ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"clientInfo":{"name":"mcp-cli","version":"1.0.0"},"protocolVersion":"2024-11-05"}}' \\
+ >/dev/null 2>/dev/null || true
+
+ local session_id
+ session_id=\$(grep -i "^mcp-session-id:" "\$headers_file" 2>/dev/null | awk "{print \$2}" | tr -d "\\r" || echo "")
+ rm -f "\$headers_file"
+
+ local session_header_args=()
+ if [ -n "\$session_id" ]; then
+ session_header_args=(-H "Mcp-Session-Id: \$session_id")
+ fi
+
+ # Step 2: notifications/initialized – required by MCP spec to complete the handshake.
+ # The server responds with 204 No Content; failures here are non-fatal.
+ curl -s --max-time 10 -X POST "\$SERVER_URL" \\
+ -H "Authorization: \$API_KEY" \\
+ -H "Content-Type: application/json" \\
+ "\${session_header_args[@]}" \\
+ -d '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \\
+ >/dev/null 2>/dev/null || true
+
+ # Step 3: tools/call – execute the tool within the established session
+ local request
+ request=\$(jq -n --arg name "\$tool_name" --argjson args "\$args" \\
+ "{\\"jsonrpc\\":\\"2.0\\",\\"id\\":2,\\"method\\":\\"tools/call\\",\\"params\\":{\\"name\\":\$name,\\"arguments\\":\$args}}")
+
+ local response
+ response=\$(curl -s --max-time 120 -X POST "\$SERVER_URL" \\
+ -H "Authorization: \$API_KEY" \\
+ -H "Content-Type: application/json" \\
+ "\${session_header_args[@]}" \\
+ -d "\$request" \\
+ 2>/dev/null)
+
+ if echo "\$response" | jq -e ".error" >/dev/null 2>&1; then
+ local err_msg err_code
+ err_msg=\$(echo "\$response" | jq -r ".error.message // \\"Unknown error\\"")
+ err_code=\$(echo "\$response" | jq -r ".error.code // \\"\\"" )
+ if [ -n "\$err_code" ]; then
+ echo "Error [\$err_code]: \$err_msg" >&2
+ else
+ echo "Error: \$err_msg" >&2
+ fi
+ exit 1
+ fi
+
+ if echo "\$response" | jq -e ".result.content" >/dev/null 2>&1; then
+ echo "\$response" | jq -r '.result.content[] |
+ if .type == "text" then .text
+ elif .type == "image" then "[image data - \(.mimeType // "unknown")]"
+ else (. | tostring)
+ end'
+ elif echo "\$response" | jq -e ".result" >/dev/null 2>&1; then
+ echo "\$response" | jq -r ".result"
+ else
+ echo "\$response"
+ fi
+}
+
+if [[ \$# -eq 0 || "\$1" == "--help" || "\$1" == "-h" ]]; then
+ show_help
+ exit 0
+fi
+
+COMMAND="\$1"
+shift
+
+if [[ "\$1" == "--help" || "\$1" == "-h" ]]; then
+ show_tool_help "\$COMMAND"
+ exit 0
+fi
+
+call_tool "\$COMMAND" "\$@"
+`;
+}
+
+/**
+ * Mount MCP servers as CLI tools by reading the manifest and generating wrapper scripts.
+ *
+ * @returns {Promise}
+ */
+async function main() {
+ const core = global.core;
+
+ core.info("Mounting MCP servers as CLI tools...");
+
+ if (!fs.existsSync(MANIFEST_FILE)) {
+ core.info("No MCP CLI manifest found, skipping CLI mounting");
+ return;
+ }
+
+ /** @type {{servers: Array<{name: string, url: string}>}} */
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(MANIFEST_FILE, "utf8"));
+ } catch (err) {
+ core.warning(`Failed to read MCP CLI manifest: ${err instanceof Error ? err.message : String(err)}`);
+ return;
+ }
+
+ const servers = (manifest.servers || []).filter(s => !INTERNAL_SERVERS.has(s.name));
+
+ if (servers.length === 0) {
+ core.info("No user-facing MCP servers in manifest, skipping CLI mounting");
+ return;
+ }
+
+ fs.mkdirSync(CLI_BIN_DIR, { recursive: true });
+ fs.mkdirSync(TOOLS_DIR, { recursive: true });
+
+ const apiKey = process.env.MCP_GATEWAY_API_KEY || "";
+ if (!apiKey) {
+ core.warning("MCP_GATEWAY_API_KEY is not set; generated CLI wrappers will not be able to authenticate with the gateway");
+ }
+
+ const gatewayDomain = process.env.MCP_GATEWAY_DOMAIN || "";
+ const gatewayPort = process.env.MCP_GATEWAY_PORT || "";
+ if (!gatewayDomain || !gatewayPort) {
+ core.warning("MCP_GATEWAY_DOMAIN or MCP_GATEWAY_PORT is not set; CLI wrappers will use raw manifest URLs which may not be reachable inside the AWF sandbox");
+ }
+
+ const mountedServers = [];
+
+ for (const server of servers) {
+ const { name, url } = server;
+ // The manifest URL is the host-accessible raw gateway address (e.g., http://0.0.0.0:80/mcp/server).
+ // Rewrite it to the container-accessible URL for the generated CLI wrapper scripts,
+ // which run inside the AWF sandbox where the gateway is reached via MCP_GATEWAY_DOMAIN.
+ const containerUrl = toContainerUrl(url);
+ core.info(`Mounting MCP server '${name}' (host url: ${url}, container url: ${containerUrl})...`);
+
+ const toolsFile = path.join(TOOLS_DIR, `${name}.json`);
+
+ // Query tools from the server using the host-accessible URL (mount step runs on host)
+ const tools = await fetchMCPTools(url, apiKey, core);
+ core.info(` Found ${tools.length} tool(s)`);
+
+ // Cache the tool list
+ try {
+ fs.writeFileSync(toolsFile, JSON.stringify(tools, null, 2), { mode: 0o644 });
+ } catch (err) {
+ core.warning(` Failed to write tools cache for ${name}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ // Write the CLI wrapper script using the container-accessible URL
+ const scriptPath = path.join(CLI_BIN_DIR, name);
+ try {
+ fs.writeFileSync(scriptPath, generateCLIWrapperScript(name, containerUrl, toolsFile, apiKey), { mode: 0o755 });
+ mountedServers.push(name);
+ core.info(` ✓ Mounted as: ${scriptPath}`);
+ } catch (err) {
+ core.warning(` Failed to write CLI wrapper for ${name}: ${err instanceof Error ? err.message : String(err)}`);
+ }
+ }
+
+ if (mountedServers.length === 0) {
+ core.info("No MCP servers were successfully mounted as CLI tools");
+ return;
+ }
+
+ // Lock the bin directory so the agent cannot modify or inject scripts
+ try {
+ fs.chmodSync(CLI_BIN_DIR, 0o555);
+ core.info(`CLI bin directory locked (read-only): ${CLI_BIN_DIR}`);
+ } catch (err) {
+ core.warning(`Failed to lock CLI bin directory: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ // Add the bin directory to PATH for subsequent steps
+ core.addPath(CLI_BIN_DIR);
+
+ core.info("");
+ core.info(`Successfully mounted ${mountedServers.length} MCP server(s) as CLI tools:`);
+ for (const name of mountedServers) {
+ core.info(` - ${name}`);
+ }
+ core.info(`CLI bin directory added to PATH: ${CLI_BIN_DIR}`);
+ core.setOutput("mounted-servers", mountedServers.join(","));
+}
+
+module.exports = { main, fetchMCPTools, generateCLIWrapperScript };
diff --git a/actions/setup/md/mcp_cli_tools_prompt.md b/actions/setup/md/mcp_cli_tools_prompt.md
new file mode 100644
index 0000000000..8a9b6b6d5b
--- /dev/null
+++ b/actions/setup/md/mcp_cli_tools_prompt.md
@@ -0,0 +1,45 @@
+
+## MCP Servers Mounted as Shell CLI Commands
+
+> **IMPORTANT**: The following MCP servers are **NOT available as MCP tools** in your agent context. They have been mounted exclusively as shell (bash) CLI commands. You **must** call them via the shell — do **not** attempt to use them as MCP protocol tools.
+
+The following servers are available as CLI commands on `PATH`:
+
+__GH_AW_MCP_CLI_SERVERS_LIST__
+
+### How to Use
+
+Each server is a standalone executable on your `PATH`. Invoke it from bash like any other shell command:
+
+```bash
+# Discover what tools a server provides
+ --help
+
+# Get detailed help for a specific tool (description + parameters)
+ --help
+
+# Call a tool — pass arguments as --name value pairs
+ --param1 value1 --param2 value2
+```
+
+**Example** — using the `github` CLI:
+```bash
+github --help # list all github tools
+github issue_read --help # show parameters for issue_read
+github issue_read --method get --owner octocat --repo Hello-World --issue_number 1
+```
+
+**Example** — using the `playwright` CLI:
+```bash
+playwright --help # list all browser tools
+playwright browser_navigate --url https://example.com
+playwright browser_snapshot # capture page accessibility tree
+```
+
+### Notes
+
+- All parameters are passed as `--name value` pairs; boolean flags can be set with `--flag` (no value) to mean `true`
+- Output is printed to stdout; errors are printed to stderr with a non-zero exit code
+- Run the CLI commands inside a `bash` tool call — they are shell executables, not MCP tools
+- These CLI commands are read-only and cannot be modified by the agent
+
diff --git a/actions/setup/sh/convert_gateway_config_claude.sh b/actions/setup/sh/convert_gateway_config_claude.sh
index 926d47065e..43294aa1e3 100755
--- a/actions/setup/sh/convert_gateway_config_claude.sh
+++ b/actions/setup/sh/convert_gateway_config_claude.sh
@@ -76,8 +76,9 @@ echo "Target domain: $MCP_GATEWAY_DOMAIN:$MCP_GATEWAY_PORT"
# Build the correct URL prefix using the configured domain and port
URL_PREFIX="http://${MCP_GATEWAY_DOMAIN}:${MCP_GATEWAY_PORT}"
-jq --arg urlPrefix "$URL_PREFIX" '
+jq --arg urlPrefix "$URL_PREFIX" --argjson cliServers "${GH_AW_MCP_CLI_SERVERS:-[]}" '
.mcpServers |= with_entries(
+ select(.key | IN($cliServers[]) | not) |
.value |= (
(.type = "http") |
# Fix the URL to use the correct domain
diff --git a/actions/setup/sh/convert_gateway_config_codex.sh b/actions/setup/sh/convert_gateway_config_codex.sh
index f172c0b206..9cab58b9d9 100755
--- a/actions/setup/sh/convert_gateway_config_codex.sh
+++ b/actions/setup/sh/convert_gateway_config_codex.sh
@@ -84,8 +84,8 @@ persistence = "none"
TOML_EOF
-jq -r --arg urlPrefix "$URL_PREFIX" '
- .mcpServers | to_entries[] |
+jq -r --arg urlPrefix "$URL_PREFIX" --argjson cliServers "${GH_AW_MCP_CLI_SERVERS:-[]}" '
+ .mcpServers | to_entries[] | select(.key | IN($cliServers[]) | not) |
"[mcp_servers.\(.key)]\n" +
"url = \"" + ($urlPrefix + "/mcp/" + .key) + "\"\n" +
"http_headers = { Authorization = \"\(.value.headers.Authorization)\" }\n"
diff --git a/actions/setup/sh/convert_gateway_config_copilot.sh b/actions/setup/sh/convert_gateway_config_copilot.sh
index 5544165dae..c94e6f9a7b 100755
--- a/actions/setup/sh/convert_gateway_config_copilot.sh
+++ b/actions/setup/sh/convert_gateway_config_copilot.sh
@@ -76,8 +76,9 @@ echo "Target domain: $MCP_GATEWAY_DOMAIN:$MCP_GATEWAY_PORT"
# Build the correct URL prefix using the configured domain and port
URL_PREFIX="http://${MCP_GATEWAY_DOMAIN}:${MCP_GATEWAY_PORT}"
-jq --arg urlPrefix "$URL_PREFIX" '
+jq --arg urlPrefix "$URL_PREFIX" --argjson cliServers "${GH_AW_MCP_CLI_SERVERS:-[]}" '
.mcpServers |= with_entries(
+ select(.key | IN($cliServers[]) | not) |
.value |= (
# Add tools field if not present
(if .tools then . else . + {"tools": ["*"]} end) |
diff --git a/actions/setup/sh/convert_gateway_config_gemini.sh b/actions/setup/sh/convert_gateway_config_gemini.sh
index 353060b9c2..046ec3d47a 100644
--- a/actions/setup/sh/convert_gateway_config_gemini.sh
+++ b/actions/setup/sh/convert_gateway_config_gemini.sh
@@ -93,8 +93,9 @@ GEMINI_SETTINGS_FILE="${GEMINI_SETTINGS_DIR}/settings.json"
mkdir -p "$GEMINI_SETTINGS_DIR"
-jq --arg urlPrefix "$URL_PREFIX" '
+jq --arg urlPrefix "$URL_PREFIX" --argjson cliServers "${GH_AW_MCP_CLI_SERVERS:-[]}" '
.mcpServers |= with_entries(
+ select(.key | IN($cliServers[]) | not) |
.value |= (
(del(.type)) |
# Fix the URL to use the correct domain
diff --git a/actions/setup/sh/start_mcp_gateway.sh b/actions/setup/sh/start_mcp_gateway.sh
index 979fb86aa4..0132dd891a 100755
--- a/actions/setup/sh/start_mcp_gateway.sh
+++ b/actions/setup/sh/start_mcp_gateway.sh
@@ -403,9 +403,19 @@ case "$ENGINE_TYPE" in
*)
echo "No agent-specific converter found for engine: $ENGINE_TYPE"
echo "Using gateway output directly"
- # Default fallback - copy to most common location
+ # Default fallback - copy to most common location, filtering out CLI-mounted servers
mkdir -p /home/runner/.copilot
- cp /tmp/gh-aw/mcp-config/gateway-output.json /home/runner/.copilot/mcp-config.json
+ if [ -n "$GH_AW_MCP_CLI_SERVERS" ]; then
+ if ! jq --argjson cliServers "$GH_AW_MCP_CLI_SERVERS" \
+ '.mcpServers |= with_entries(select(.key | IN($cliServers[]) | not))' \
+ /tmp/gh-aw/mcp-config/gateway-output.json > /home/runner/.copilot/mcp-config.json; then
+ echo "ERROR: Failed to filter CLI-mounted servers from agent MCP config"
+ echo "Falling back to unfiltered config"
+ cp /tmp/gh-aw/mcp-config/gateway-output.json /home/runner/.copilot/mcp-config.json
+ fi
+ else
+ cp /tmp/gh-aw/mcp-config/gateway-output.json /home/runner/.copilot/mcp-config.json
+ fi
cat /home/runner/.copilot/mcp-config.json
;;
esac
@@ -438,6 +448,22 @@ else
fi
echo ""
+# Save CLI manifest for mount_mcp_as_cli.sh before gateway config is deleted
+# The manifest contains server names and their local localhost URLs
+echo "Saving MCP CLI manifest..."
+mkdir -p /tmp/gh-aw/mcp-cli
+if [ -f /tmp/gh-aw/mcp-config/gateway-output.json ] && \
+ jq -e '.mcpServers' /tmp/gh-aw/mcp-config/gateway-output.json >/dev/null 2>&1; then
+ jq '{servers: [.mcpServers | to_entries[] | select(.value.url != null) | {name: .key, url: .value.url}]}' \
+ /tmp/gh-aw/mcp-config/gateway-output.json > /tmp/gh-aw/mcp-cli/manifest.json
+ chmod 600 /tmp/gh-aw/mcp-cli/manifest.json
+ SERVER_COUNT=$(jq '.servers | length' /tmp/gh-aw/mcp-cli/manifest.json)
+ echo "CLI manifest saved with ${SERVER_COUNT} server(s)"
+else
+ echo "WARNING: No mcpServers in gateway output, CLI manifest not created"
+fi
+echo ""
+
# Delete gateway configuration file after conversion and checks are complete
echo "Cleaning up gateway configuration file..."
if [ -f /tmp/gh-aw/mcp-config/gateway-output.json ]; then
@@ -462,4 +488,5 @@ echo ""
echo "gateway-pid=$GATEWAY_PID"
echo "gateway-port=${MCP_GATEWAY_PORT}"
echo "gateway-api-key=${MCP_GATEWAY_API_KEY}"
+ echo "gateway-domain=${MCP_GATEWAY_DOMAIN}"
} >> "$GITHUB_OUTPUT"
diff --git a/actions/setup/sh/validate_prompt_placeholders.sh b/actions/setup/sh/validate_prompt_placeholders.sh
index 847e2b07a8..7e1b953673 100755
--- a/actions/setup/sh/validate_prompt_placeholders.sh
+++ b/actions/setup/sh/validate_prompt_placeholders.sh
@@ -14,10 +14,13 @@ fi
echo "🔍 Validating prompt placeholders..."
# Check for unreplaced environment variable placeholders (format: __GH_AW_*__)
-if grep -q "__GH_AW_" "$PROMPT_FILE"; then
+# Strip inline code spans (`...`) before checking, since backtick-quoted occurrences
+# are documentation/code examples (e.g., in PR descriptions) and not actual placeholders.
+STRIPPED_PROMPT=$(sed 's/`[^`]*`//g' "$PROMPT_FILE")
+if echo "$STRIPPED_PROMPT" | grep -q "__GH_AW_"; then
echo "❌ Error: Found unreplaced placeholders in prompt file:"
echo ""
- grep -n "__GH_AW_" "$PROMPT_FILE" | head -20
+ grep -n "__GH_AW_" "$PROMPT_FILE" | grep -v '`[^`]*__GH_AW_' | head -20
echo ""
echo "These placeholders should have been replaced with their actual values."
echo "This indicates a problem with the placeholder substitution step."
diff --git a/actions/setup/sh/validate_prompt_placeholders_test.sh b/actions/setup/sh/validate_prompt_placeholders_test.sh
index 5e74405d97..b3407475bb 100755
--- a/actions/setup/sh/validate_prompt_placeholders_test.sh
+++ b/actions/setup/sh/validate_prompt_placeholders_test.sh
@@ -94,4 +94,27 @@ else
fi
echo ""
+# Test 5: Prompt with placeholder name in backtick code span (should pass - it's documentation)
+echo "Test 5: Prompt with placeholder in backtick code span (should pass)"
+cat > "$TEST_DIR/prompt_backtick.txt" << 'EOF'
+
+# System Instructions
+You are a helpful assistant.
+
+
+# User Task
+This is a PR description that mentions `__GH_AW_MCP_CLI_SERVERS_LIST__` as documentation.
+The value has already been substituted but the *name* appears in code formatting.
+Also mentions `__GH_AW_GITHUB_ACTOR__` in inline code (safe).
+EOF
+
+export GH_AW_PROMPT="$TEST_DIR/prompt_backtick.txt"
+if bash "$SCRIPT_PATH"; then
+ echo "✅ Test 5 passed: Backtick-quoted placeholder names accepted"
+else
+ echo "❌ Test 5 failed: Backtick-quoted placeholder names incorrectly rejected"
+ exit 1
+fi
+echo ""
+
echo "🎉 All validation tests passed!"
diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json
index ea62464d83..5b8df13a6f 100644
--- a/pkg/parser/schemas/main_workflow_schema.json
+++ b/pkg/parser/schemas/main_workflow_schema.json
@@ -4286,6 +4286,11 @@
}
]
]
+ },
+ "mount-as-clis": {
+ "type": "boolean",
+ "description": "When true, each user-facing MCP server is mounted as a standalone CLI tool on PATH. The agent can then call MCP servers via shell commands (e.g. 'github issue_read --method get ...'). CLI-mounted servers remain in the MCP gateway config so their containers can start, and are removed only from the agent's final config during convert_gateway_config_*.sh processing. Default: false.",
+ "examples": [true]
}
},
"additionalProperties": {
diff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go
index 1d466596ad..a4441b822f 100644
--- a/pkg/workflow/compiler_yaml_main_job.go
+++ b/pkg/workflow/compiler_yaml_main_job.go
@@ -285,6 +285,9 @@ func (c *Compiler) generateMainJobSteps(yaml *strings.Builder, data *WorkflowDat
return fmt.Errorf("failed to generate MCP setup: %w", err)
}
+ // Mount MCP servers as CLI tools (runs after gateway is started)
+ c.generateMCPCLIMountStep(yaml, data)
+
// Stop-time safety checks are now handled by a dedicated job (stop_time_check)
// No longer generated in the main job steps
diff --git a/pkg/workflow/mcp_cli_mount.go b/pkg/workflow/mcp_cli_mount.go
new file mode 100644
index 0000000000..d5bd367acb
--- /dev/null
+++ b/pkg/workflow/mcp_cli_mount.go
@@ -0,0 +1,136 @@
+package workflow
+
+import (
+ "fmt"
+ "slices"
+ "sort"
+ "strings"
+
+ "github.com/github/gh-aw/pkg/constants"
+)
+
+// mcp_cli_mount.go generates a workflow step that mounts MCP servers as local CLI tools
+// and produces the prompt section that informs the agent about these tools.
+//
+// After the MCP gateway is started, this step runs mount_mcp_as_cli.cjs via
+// actions/github-script which:
+// - Reads the CLI manifest saved by start_mcp_gateway.sh
+// - Queries each server for its tools/list via JSON-RPC
+// - Writes a standalone CLI wrapper script for each server to ${RUNNER_TEMP}/gh-aw/mcp-cli/bin/
+// - Locks the bin directory (chmod 555) so the agent cannot modify the scripts
+// - Adds the directory to PATH via core.addPath()
+
+// internalMCPServerNames lists the MCP servers that are internal infrastructure and
+// should not be exposed as user-facing CLI tools.
+// Include both config-key and rendered server-ID variants where they differ.
+var internalMCPServerNames = map[string]bool{
+ "safeoutputs": true,
+ "mcp-scripts": true,
+ "mcpscripts": true,
+}
+
+// getMCPCLIServerNames returns the sorted list of MCP server names that will be
+// mounted as CLI tools. It includes standard MCP tools (github, playwright, etc.)
+// and custom MCP servers, but excludes internal infrastructure servers.
+// Returns nil if tools.mount-as-clis is not set to true.
+func getMCPCLIServerNames(data *WorkflowData) []string {
+ if data == nil {
+ return nil
+ }
+
+ // Only mount if tools.mount-as-clis: true is set.
+ // Also returns nil when tools configuration is missing entirely.
+ if data.ParsedTools == nil || !data.ParsedTools.MountAsCLIs {
+ return nil
+ }
+
+ var servers []string
+
+ // Collect user-facing standard MCP tools from the raw Tools map
+ for toolName, toolValue := range data.Tools {
+ if toolValue == false {
+ continue
+ }
+ // Only include tools that have MCP servers (skip bash, web-fetch, web-search, edit, cache-memory, etc.)
+ switch toolName {
+ case "github", "playwright", "qmd":
+ servers = append(servers, toolName)
+ case "agentic-workflows":
+ // The gateway and manifest use "agenticworkflows" (no hyphen) as the server ID.
+ // Using the gateway ID here ensures GH_AW_MCP_CLI_SERVERS matches the manifest entries.
+ servers = append(servers, constants.AgenticWorkflowsMCPServerID.String())
+ default:
+ // Include custom MCP servers (not in the internal list)
+ if !internalMCPServerNames[toolName] {
+ if mcpConfig, ok := toolValue.(map[string]any); ok {
+ if hasMcp, _ := hasMCPConfig(mcpConfig); hasMcp {
+ servers = append(servers, toolName)
+ }
+ }
+ }
+ }
+ }
+
+ // Also check ParsedTools.Custom for custom MCP servers
+ if data.ParsedTools != nil {
+ for name := range data.ParsedTools.Custom {
+ if !internalMCPServerNames[name] && !slices.Contains(servers, name) {
+ servers = append(servers, name)
+ }
+ }
+ }
+
+ sort.Strings(servers)
+ return servers
+}
+
+// generateMCPCLIMountStep generates the "Mount MCP servers as CLIs" workflow step.
+// This step runs after the MCP gateway is started and creates executable CLI wrapper
+// scripts for each MCP server in a read-only directory on $PATH.
+func (c *Compiler) generateMCPCLIMountStep(yaml *strings.Builder, data *WorkflowData) {
+ servers := getMCPCLIServerNames(data)
+ if len(servers) == 0 {
+ return
+ }
+
+ yaml.WriteString(" - name: Mount MCP servers as CLIs\n")
+ yaml.WriteString(" id: mount-mcp-clis\n")
+ yaml.WriteString(" continue-on-error: true\n")
+ yaml.WriteString(" env:\n")
+ yaml.WriteString(" MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}\n")
+ yaml.WriteString(" MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }}\n")
+ yaml.WriteString(" MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}\n")
+ fmt.Fprintf(yaml, " uses: %s\n", GetActionPin("actions/github-script"))
+ yaml.WriteString(" with:\n")
+ yaml.WriteString(" script: |\n")
+ yaml.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n")
+ yaml.WriteString(" setupGlobals(core, github, context, exec, io);\n")
+ yaml.WriteString(" const { main } = require('" + SetupActionDestination + "/mount_mcp_as_cli.cjs');\n")
+ yaml.WriteString(" await main();\n")
+}
+
+// buildMCPCLIPromptSection returns a PromptSection describing the CLI tools available
+// to the agent, or nil if there are no servers to mount.
+// The prompt is loaded from actions/setup/md/mcp_cli_tools_prompt.md at runtime,
+// with the __GH_AW_MCP_CLI_SERVERS_LIST__ placeholder substituted by the substitution step.
+func buildMCPCLIPromptSection(data *WorkflowData) *PromptSection {
+ servers := getMCPCLIServerNames(data)
+ if len(servers) == 0 {
+ return nil
+ }
+
+ // Build the human-readable list of servers with example usage
+ var listLines []string
+ for _, name := range servers {
+ listLines = append(listLines, fmt.Sprintf("- `%s` — run `%s --help` to see available tools", name, name))
+ }
+ serversList := strings.Join(listLines, "\n")
+
+ return &PromptSection{
+ Content: mcpCLIToolsPromptFile,
+ IsFile: true,
+ EnvVars: map[string]string{
+ "GH_AW_MCP_CLI_SERVERS_LIST": serversList,
+ },
+ }
+}
diff --git a/pkg/workflow/mcp_rendering.go b/pkg/workflow/mcp_rendering.go
index 714c794989..60ee999d69 100644
--- a/pkg/workflow/mcp_rendering.go
+++ b/pkg/workflow/mcp_rendering.go
@@ -104,6 +104,14 @@ func renderStandardJSONMCPConfig(
) error {
mcpRenderingLog.Printf("Rendering standard JSON MCP config: config_path=%s tools=%d mcp_tools=%d", configPath, len(tools), len(mcpTools))
createRenderer := buildMCPRendererFactory(workflowData, "json", includeCopilotFields, inlineArgs)
+
+ // CLI-mounted servers are NOT excluded from the gateway config.
+ // The gateway must start their Docker containers so that:
+ // 1. The CLI manifest (saved by start_mcp_gateway.sh) includes them.
+ // 2. mount_mcp_as_cli.cjs can query their tool lists and create wrappers.
+ // Exclusion from the agent's final MCP config happens inside each
+ // convert_gateway_config_*.sh script via GH_AW_MCP_CLI_SERVERS.
+
return RenderJSONMCPConfig(yaml, tools, mcpTools, workflowData, JSONMCPConfigOptions{
ConfigPath: configPath,
GatewayConfig: buildMCPGatewayConfig(workflowData),
diff --git a/pkg/workflow/mcp_setup_generator.go b/pkg/workflow/mcp_setup_generator.go
index 6e691d44fd..59622d1cc8 100644
--- a/pkg/workflow/mcp_setup_generator.go
+++ b/pkg/workflow/mcp_setup_generator.go
@@ -571,6 +571,20 @@ func (c *Compiler) generateMCPSetup(yaml *strings.Builder, tools map[string]any,
// Export engine type
yaml.WriteString(" export GH_AW_ENGINE=\"" + engine.GetID() + "\"\n")
+ // Export the list of CLI-mounted server names (JSON array) so that conversion scripts
+ // can exclude them from the agent's final MCP config while still letting the gateway
+ // start their Docker containers (needed to populate the CLI manifest).
+ // The variable must be persisted to $GITHUB_ENV (not just exported) because
+ // convert_gateway_config_*.sh runs in a subsequent step and would otherwise see an
+ // empty variable, causing no servers to be filtered from the agent's MCP config.
+ if cliServers := getMCPCLIServerNames(workflowData); len(cliServers) > 0 {
+ cliServersJSON, err := json.Marshal(cliServers)
+ if err == nil {
+ yaml.WriteString(" export GH_AW_MCP_CLI_SERVERS='" + string(cliServersJSON) + "'\n")
+ yaml.WriteString(" echo 'GH_AW_MCP_CLI_SERVERS=" + string(cliServersJSON) + "' >> \"$GITHUB_ENV\"\n")
+ }
+ }
+
// For Copilot engine with GitHub remote MCP, export GITHUB_PERSONAL_ACCESS_TOKEN
// This is needed because the MCP gateway validates ${VAR} references in headers at config load time
// and the Copilot MCP config uses ${GITHUB_PERSONAL_ACCESS_TOKEN} in the Authorization header
diff --git a/pkg/workflow/prompt_constants.go b/pkg/workflow/prompt_constants.go
index 6e56b519ee..dac69f2b80 100644
--- a/pkg/workflow/prompt_constants.go
+++ b/pkg/workflow/prompt_constants.go
@@ -27,6 +27,7 @@ const (
agenticWorkflowsGuideFile = "agentic_workflows_guide.md"
githubMCPToolsPromptFile = "github_mcp_tools_prompt.md"
githubMCPToolsWithSafeOutputsPromptFile = "github_mcp_tools_with_safeoutputs_prompt.md"
+ mcpCLIToolsPromptFile = "mcp_cli_tools_prompt.md"
)
// GitHub context prompt is kept embedded because it contains GitHub Actions expressions
diff --git a/pkg/workflow/tools_parser.go b/pkg/workflow/tools_parser.go
index 05b0a8bff6..ddeaea44a3 100644
--- a/pkg/workflow/tools_parser.go
+++ b/pkg/workflow/tools_parser.go
@@ -147,6 +147,14 @@ func NewTools(toolsMap map[string]any) *Tools {
tools.StartupTimeout = parseStartupTimeoutTool(val)
}
+ if val, exists := toolsMap["mount-as-clis"]; exists {
+ if b, ok := val.(bool); ok {
+ tools.MountAsCLIs = b
+ } else {
+ toolsParserLog.Printf("Warning: mount-as-clis must be a boolean (true/false), ignoring value: %v", val)
+ }
+ }
+
// Extract custom MCP tools (anything not in the known list)
knownTools := map[string]bool{
"github": true,
@@ -162,6 +170,7 @@ func NewTools(toolsMap map[string]any) *Tools {
"safety-prompt": true,
"timeout": true,
"startup-timeout": true,
+ "mount-as-clis": true,
}
customCount := 0
diff --git a/pkg/workflow/tools_types.go b/pkg/workflow/tools_types.go
index c617263801..7662721e4e 100644
--- a/pkg/workflow/tools_types.go
+++ b/pkg/workflow/tools_types.go
@@ -83,6 +83,14 @@ type ToolsConfig struct {
// Custom MCP tools (anything not in the above list)
Custom map[string]MCPServerConfig `yaml:",inline"`
+ // MountAsCLIs enables mounting MCP servers as standalone CLI tools on PATH.
+ // When true, each user-facing MCP server gets a bash wrapper script placed in
+ // a read-only directory added to PATH. The servers remain in the MCP gateway
+ // config, but are filtered out of the agent's final MCP config so the agent
+ // uses the CLI instead of the MCP protocol.
+ // Default is false.
+ MountAsCLIs bool `yaml:"mount-as-clis,omitempty"`
+
// Raw map for backwards compatibility
raw map[string]any
}
diff --git a/pkg/workflow/unified_prompt_step.go b/pkg/workflow/unified_prompt_step.go
index 3de3729550..67d233b04f 100644
--- a/pkg/workflow/unified_prompt_step.go
+++ b/pkg/workflow/unified_prompt_step.go
@@ -194,6 +194,13 @@ func (c *Compiler) collectPromptSections(data *WorkflowData) []PromptSection {
// Per-tool sections: opening tag + tools list (inline), tool instruction files, closing tag
sections = append(sections, buildSafeOutputsSections(data.SafeOutputs)...)
}
+
+ // 8a. MCP CLI tools instructions (if any MCP servers are mounted as CLIs)
+ if section := buildMCPCLIPromptSection(data); section != nil {
+ unifiedPromptLog.Printf("Adding MCP CLI tools section: servers=%v", getMCPCLIServerNames(data))
+ sections = append(sections, *section)
+ }
+
// 9. GitHub context (if GitHub tool is enabled)
if hasGitHubTool(data.ParsedTools) {
unifiedPromptLog.Print("Adding GitHub context section")