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")