Add GitHub Actions MCP server for viewing workflow results (#231)

* actions server

* tmp

* Replace view_actions_results with additional_permissions input

- Changed input from boolean view_actions_results to a more flexible additional_permissions format
- Uses newline-separated colon format similar to claude_env (e.g., "actions: read")
- Maintains permission checking to warn users when their token lacks required permissions
- Updated all tests to use the new format

This allows for future extensibility while currently supporting only "actions: read" permission.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Update GitHub Actions MCP server with RUNNER_TEMP and status filtering

- Use RUNNER_TEMP environment variable for log storage directory (defaults to /tmp)
- Add status parameter to get_ci_status tool to filter workflow runs
- Supported statuses: completed, action_required, cancelled, failure, neutral, skipped, stale, success, timed_out, in_progress, queued, requested, waiting, pending
- Pass RUNNER_TEMP from install-mcp-server.ts to the MCP server environment

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add GitHub Actions MCP tools to allowed tools when actions:read is granted

- Automatically include github_ci MCP server tools in allowed tools list when actions:read permission is granted
- Added mcp__github_ci__get_ci_status, mcp__github_ci__get_workflow_run_details, mcp__github_ci__download_job_log
- Simplified permission checking to avoid duplicate parsing logic
- Added tests for the new functionality

This ensures Claude can use the Actions tools when the server is enabled.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor additional permissions parsing to parseGitHubContext

- Moved additional permissions parsing from individual functions to centralized parseGitHubContext
- Added parseAdditionalPermissions function to handle newline-separated colon format
- Removed redundant additionalPermissions parameter from prepareMcpConfig
- Updated tests to use permissions from context instead of passing as parameter
- Added comprehensive tests for parseAdditionalPermissions function

This centralizes all input parsing logic in one place for better maintainability.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Remove unnecessary hasActionsReadPermission parameter from createPrompt

- Removed hasActionsReadPermission parameter since createPrompt has access to context
- Calculate hasActionsReadPermission directly from context.inputs.additionalPermissions inside createPrompt
- Simplified prepare.ts by removing intermediate permission check

This completes the refactoring to centralize all permission handling through the context object.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: Add documentation for additional_permissions feature

- Document the new additional_permissions input that replaces view_actions_results
- Add dedicated section explaining CI/CD integration with actions:read permission
- Include example workflow showing how to grant GitHub token permissions
- Update main workflow example to show optional additional_permissions usage

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* roadmap

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Ashwin Bhat
2025-07-03 18:58:02 -07:00
committed by GitHub
parent 3c739a8cf3
commit 23fae74fdb
14 changed files with 772 additions and 26 deletions

119
README.md
View File

@@ -74,33 +74,37 @@ jobs:
# API_URL: https://api.example.com # API_URL: https://api.example.com
# Optional: limit the number of conversation turns # Optional: limit the number of conversation turns
# max_turns: "5" # max_turns: "5"
# Optional: grant additional permissions (requires corresponding GitHub token permissions)
# additional_permissions: |
# actions: read
``` ```
## Inputs ## Inputs
| Input | Description | Required | Default | | Input | Description | Required | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- | | ------------------------ | -------------------------------------------------------------------------------------------------------------------- | -------- | --------- |
| `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - | | `anthropic_api_key` | Anthropic API key (required for direct API, not needed for Bedrock/Vertex) | No\* | - |
| `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - | | `direct_prompt` | Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows) | No | - |
| `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - | | `base_branch` | The base branch to use for creating new branches (e.g., 'main', 'develop') | No | - |
| `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - | | `max_turns` | Maximum number of conversation turns Claude can take (limits back-and-forth exchanges) | No | - |
| `timeout_minutes` | Timeout in minutes for execution | No | `30` | | `timeout_minutes` | Timeout in minutes for execution | No | `30` |
| `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` | | `use_sticky_comment` | Use just one comment to deliver PR comments (only applies for pull_request event workflows) | No | `false` |
| `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - | | `github_token` | GitHub token for Claude to operate with. **Only include this if you're connecting a custom GitHub app of your own!** | No | - |
| `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - | | `model` | Model to use (provider-specific format required for Bedrock/Vertex) | No | - |
| `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - | | `fallback_model` | Enable automatic fallback to specified model when primary model is unavailable | No | - |
| `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - | | `anthropic_model` | **DEPRECATED**: Use `model` instead. Kept for backward compatibility. | No | - |
| `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` | | `use_bedrock` | Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API | No | `false` |
| `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` | | `use_vertex` | Use Google Vertex AI with OIDC authentication instead of direct Anthropic API | No | `false` |
| `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" | | `allowed_tools` | Additional tools for Claude to use (the base GitHub tools will always be included) | No | "" |
| `disallowed_tools` | Tools that Claude should never use | No | "" | | `disallowed_tools` | Tools that Claude should never use | No | "" |
| `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" | | `custom_instructions` | Additional custom instructions to include in the prompt for Claude | No | "" |
| `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" | | `mcp_config` | Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers | No | "" |
| `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - | | `assignee_trigger` | The assignee username that triggers the action (e.g. @claude). Only used for issue assignment | No | - |
| `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - | | `label_trigger` | The label name that triggers the action when applied to an issue (e.g. "claude") | No | - |
| `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` | | `trigger_phrase` | The trigger phrase to look for in comments, issue/PR bodies, and issue titles | No | `@claude` |
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | | `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
| `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" | | `claude_env` | Custom environment variables to pass to Claude Code execution (YAML format) | No | "" |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
\*Required when using direct Anthropic API (default and when not using Bedrock or Vertex) \*Required when using direct Anthropic API (default and when not using Bedrock or Vertex)
@@ -339,6 +343,75 @@ This action is built on top of [`anthropics/claude-code-base-action`](https://gi
## Advanced Configuration ## Advanced Configuration
### Additional Permissions for CI/CD Integration
The `additional_permissions` input allows Claude to access GitHub Actions workflow information when you grant the necessary permissions. This is particularly useful for analyzing CI/CD failures and debugging workflow issues.
#### Enabling GitHub Actions Access
To allow Claude to view workflow run results, job logs, and CI status:
1. **Grant the necessary permission to your GitHub token**:
- When using the default `GITHUB_TOKEN`, add the `actions: read` permission to your workflow:
```yaml
permissions:
contents: write
pull-requests: write
issues: write
actions: read # Add this line
```
2. **Configure the action with additional permissions**:
```yaml
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: |
actions: read
# ... other inputs
```
3. **Claude will automatically get access to CI/CD tools**:
When you enable `actions: read`, Claude can use the following MCP tools:
- `mcp__github_ci__get_ci_status` - View workflow run statuses
- `mcp__github_ci__get_workflow_run_details` - Get detailed workflow information
- `mcp__github_ci__download_job_log` - Download and analyze job logs
#### Example: Debugging Failed CI Runs
```yaml
name: Claude CI Helper
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
actions: read # Required for CI access
jobs:
claude-ci-helper:
runs-on: ubuntu-latest
steps:
- uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: |
actions: read
# Now Claude can respond to "@claude why did the CI fail?"
```
**Important Notes**:
- The GitHub token must have the `actions: read` permission in your workflow
- If the permission is missing, Claude will warn you and suggest adding it
- Currently, only `actions: read` is supported, but the format allows for future extensions
### Custom Environment Variables ### Custom Environment Variables
You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables: You can pass custom environment variables to Claude Code execution using the `claude_env` input. This is useful for CI/test setups that require specific environment variables:

View File

@@ -4,7 +4,7 @@ Thank you for trying out the beta of our GitHub Action! This document outlines o
## Path to 1.0 ## Path to 1.0
- **Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like. - ~**Ability to see GitHub Action CI results** - This will enable Claude to look at CI failures and make updates to PRs to fix test failures, lint errors, and the like.~
- **Cross-repo support** - Enable Claude to work across multiple repositories in a single session - **Cross-repo support** - Enable Claude to work across multiple repositories in a single session
- **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files - **Ability to modify workflow files** - Let Claude update GitHub Actions workflows and other CI configuration files
- **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services - **Support for workflow_dispatch and repository_dispatch events** - Dispatch Claude on events triggered via API from other workflows or from other services

View File

@@ -52,6 +52,10 @@ inputs:
default: "" default: ""
mcp_config: mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers" description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
additional_permissions:
description: "Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results"
required: false
default: ""
claude_env: claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)" description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false required: false
@@ -124,6 +128,8 @@ runs:
OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }} OVERRIDE_GITHUB_TOKEN: ${{ inputs.github_token }}
GITHUB_RUN_ID: ${{ github.run_id }} GITHUB_RUN_ID: ${{ github.run_id }}
USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }} USE_STICKY_COMMENT: ${{ inputs.use_sticky_comment }}
ACTIONS_TOKEN: ${{ github.token }}
ADDITIONAL_PERMISSIONS: ${{ inputs.additional_permissions }}
- name: Run Claude Code - name: Run Claude Code
id: claude-code id: claude-code

View File

@@ -36,9 +36,21 @@ const BASE_ALLOWED_TOOLS = [
]; ];
const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"]; const DISALLOWED_TOOLS = ["WebSearch", "WebFetch"];
export function buildAllowedToolsString(customAllowedTools?: string[]): string { export function buildAllowedToolsString(
customAllowedTools?: string[],
includeActionsTools: boolean = false,
): string {
let baseTools = [...BASE_ALLOWED_TOOLS]; let baseTools = [...BASE_ALLOWED_TOOLS];
// Add GitHub Actions MCP tools if enabled
if (includeActionsTools) {
baseTools.push(
"mcp__github_ci__get_ci_status",
"mcp__github_ci__get_workflow_run_details",
"mcp__github_ci__download_job_log",
);
}
let allAllowedTools = baseTools.join(","); let allAllowedTools = baseTools.join(",");
if (customAllowedTools && customAllowedTools.length > 0) { if (customAllowedTools && customAllowedTools.length > 0) {
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`; allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
@@ -665,8 +677,12 @@ export async function createPrompt(
); );
// Set allowed tools // Set allowed tools
const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read" &&
context.isPR;
const allAllowedTools = buildAllowedToolsString( const allAllowedTools = buildAllowedToolsString(
context.inputs.allowedTools, context.inputs.allowedTools,
hasActionsReadPermission,
); );
const allDisallowedTools = buildDisallowedToolsString( const allDisallowedTools = buildDisallowedToolsString(
context.inputs.disallowedTools, context.inputs.disallowedTools,

View File

@@ -94,6 +94,7 @@ async function run() {
additionalMcpConfig, additionalMcpConfig,
claudeCommentId: commentId.toString(), claudeCommentId: commentId.toString(),
allowedTools: context.inputs.allowedTools, allowedTools: context.inputs.allowedTools,
context,
}); });
core.setOutput("mcp_config", mcpConfig); core.setOutput("mcp_config", mcpConfig);
} catch (error) { } catch (error) {

View File

@@ -37,6 +37,7 @@ export type ParsedGitHubContext = {
baseBranch?: string; baseBranch?: string;
branchPrefix: string; branchPrefix: string;
useStickyComment: boolean; useStickyComment: boolean;
additionalPermissions: Map<string, string>;
}; };
}; };
@@ -64,6 +65,9 @@ export function parseGitHubContext(): ParsedGitHubContext {
baseBranch: process.env.BASE_BRANCH, baseBranch: process.env.BASE_BRANCH,
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/", branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
useStickyComment: process.env.USE_STICKY_COMMENT === "true", useStickyComment: process.env.USE_STICKY_COMMENT === "true",
additionalPermissions: parseAdditionalPermissions(
process.env.ADDITIONAL_PERMISSIONS ?? "",
),
}, },
}; };
@@ -125,6 +129,25 @@ export function parseMultilineInput(s: string): string[] {
.filter((tool) => tool.length > 0); .filter((tool) => tool.length > 0);
} }
export function parseAdditionalPermissions(s: string): Map<string, string> {
const permissions = new Map<string, string>();
if (!s || !s.trim()) {
return permissions;
}
const lines = s.trim().split("\n");
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine) {
const [key, value] = trimmedLine.split(":").map((part) => part.trim());
if (key && value) {
permissions.set(key, value);
}
}
}
return permissions;
}
export function isIssuesEvent( export function isIssuesEvent(
context: ParsedGitHubContext, context: ParsedGitHubContext,
): context is ParsedGitHubContext & { payload: IssuesEvent } { ): context is ParsedGitHubContext & { payload: IssuesEvent } {

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { mkdir, writeFile } from "fs/promises";
import { Octokit } from "@octokit/rest";
const REPO_OWNER = process.env.REPO_OWNER;
const REPO_NAME = process.env.REPO_NAME;
const PR_NUMBER = process.env.PR_NUMBER;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const RUNNER_TEMP = process.env.RUNNER_TEMP || "/tmp";
if (!REPO_OWNER || !REPO_NAME || !PR_NUMBER || !GITHUB_TOKEN) {
console.error(
"[GitHub CI Server] Error: REPO_OWNER, REPO_NAME, PR_NUMBER, and GITHUB_TOKEN environment variables are required",
);
process.exit(1);
}
const server = new McpServer({
name: "GitHub CI Server",
version: "0.0.1",
});
console.error("[GitHub CI Server] MCP Server instance created");
server.tool(
"get_ci_status",
"Get CI status summary for this PR",
{
status: z
.enum([
"completed",
"action_required",
"cancelled",
"failure",
"neutral",
"skipped",
"stale",
"success",
"timed_out",
"in_progress",
"queued",
"requested",
"waiting",
"pending",
])
.optional()
.describe("Filter workflow runs by status"),
},
async ({ status }) => {
try {
const client = new Octokit({
auth: GITHUB_TOKEN,
});
// Get the PR to find the head SHA
const { data: prData } = await client.pulls.get({
owner: REPO_OWNER!,
repo: REPO_NAME!,
pull_number: parseInt(PR_NUMBER!, 10),
});
const headSha = prData.head.sha;
const { data: runsData } = await client.actions.listWorkflowRunsForRepo({
owner: REPO_OWNER!,
repo: REPO_NAME!,
head_sha: headSha,
...(status && { status }),
});
// Process runs to create summary
const runs = runsData.workflow_runs || [];
const summary = {
total_runs: runs.length,
failed: 0,
passed: 0,
pending: 0,
};
const processedRuns = runs.map((run: any) => {
// Update summary counts
if (run.status === "completed") {
if (run.conclusion === "success") {
summary.passed++;
} else if (run.conclusion === "failure") {
summary.failed++;
}
} else {
summary.pending++;
}
return {
id: run.id,
name: run.name,
status: run.status,
conclusion: run.conclusion,
html_url: run.html_url,
created_at: run.created_at,
};
});
const result = {
summary,
runs: processedRuns,
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
server.tool(
"get_workflow_run_details",
"Get job and step details for a workflow run",
{
run_id: z.number().describe("The workflow run ID"),
},
async ({ run_id }) => {
try {
const client = new Octokit({
auth: GITHUB_TOKEN,
});
// Get jobs for this workflow run
const { data: jobsData } = await client.actions.listJobsForWorkflowRun({
owner: REPO_OWNER!,
repo: REPO_NAME!,
run_id,
});
const processedJobs = jobsData.jobs.map((job: any) => {
// Extract failed steps
const failedSteps = (job.steps || [])
.filter((step: any) => step.conclusion === "failure")
.map((step: any) => ({
name: step.name,
number: step.number,
}));
return {
id: job.id,
name: job.name,
conclusion: job.conclusion,
html_url: job.html_url,
failed_steps: failedSteps,
};
});
const result = {
jobs: processedJobs,
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
server.tool(
"download_job_log",
"Download job logs to disk",
{
job_id: z.number().describe("The job ID"),
},
async ({ job_id }) => {
try {
const client = new Octokit({
auth: GITHUB_TOKEN,
});
const response = await client.actions.downloadJobLogsForWorkflowRun({
owner: REPO_OWNER!,
repo: REPO_NAME!,
job_id,
});
const logsText = response.data as unknown as string;
const logsDir = `${RUNNER_TEMP}/github-ci-logs`;
await mkdir(logsDir, { recursive: true });
const logPath = `${logsDir}/job-${job_id}.log`;
await writeFile(logPath, logsText, "utf-8");
const result = {
path: logPath,
size_bytes: Buffer.byteLength(logsText, "utf-8"),
};
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
error: errorMessage,
isError: true,
};
}
},
);
async function runServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
process.on("exit", () => {
server.close();
});
} catch (error) {
throw error;
}
}
runServer().catch(() => {
process.exit(1);
});

View File

@@ -1,5 +1,7 @@
import * as core from "@actions/core"; import * as core from "@actions/core";
import { GITHUB_API_URL } from "../github/api/config"; import { GITHUB_API_URL } from "../github/api/config";
import type { ParsedGitHubContext } from "../github/context";
import { Octokit } from "@octokit/rest";
type PrepareConfigParams = { type PrepareConfigParams = {
githubToken: string; githubToken: string;
@@ -9,8 +11,41 @@ type PrepareConfigParams = {
additionalMcpConfig?: string; additionalMcpConfig?: string;
claudeCommentId?: string; claudeCommentId?: string;
allowedTools: string[]; allowedTools: string[];
context: ParsedGitHubContext;
}; };
async function checkActionsReadPermission(
token: string,
owner: string,
repo: string,
): Promise<boolean> {
try {
const client = new Octokit({ auth: token });
// Try to list workflow runs - this requires actions:read
// We use per_page=1 to minimize the response size
await client.actions.listWorkflowRunsForRepo({
owner,
repo,
per_page: 1,
});
return true;
} catch (error: any) {
// Check if it's a permission error
if (
error.status === 403 &&
error.message?.includes("Resource not accessible")
) {
return false;
}
// For other errors (network issues, etc), log but don't fail
core.debug(`Failed to check actions permission: ${error.message}`);
return false;
}
}
export async function prepareMcpConfig( export async function prepareMcpConfig(
params: PrepareConfigParams, params: PrepareConfigParams,
): Promise<string> { ): Promise<string> {
@@ -22,6 +57,7 @@ export async function prepareMcpConfig(
additionalMcpConfig, additionalMcpConfig,
claudeCommentId, claudeCommentId,
allowedTools, allowedTools,
context,
} = params; } = params;
try { try {
const allowedToolsList = allowedTools || []; const allowedToolsList = allowedTools || [];
@@ -53,6 +89,42 @@ export async function prepareMcpConfig(
}, },
}; };
// Only add CI server if we have actions:read permission and we're in a PR context
const hasActionsReadPermission =
context.inputs.additionalPermissions.get("actions") === "read";
if (context.isPR && hasActionsReadPermission) {
// Verify the token actually has actions:read permission
const actuallyHasPermission = await checkActionsReadPermission(
process.env.ACTIONS_TOKEN || "",
owner,
repo,
);
if (!actuallyHasPermission) {
core.warning(
"The github_ci MCP server requires 'actions: read' permission. " +
"Please ensure your GitHub token has this permission. " +
"See: https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token",
);
}
baseMcpConfig.mcpServers.github_ci = {
command: "bun",
args: [
"run",
`${process.env.GITHUB_ACTION_PATH}/src/mcp/github-actions-server.ts`,
],
env: {
// Use workflow github token, not app token
GITHUB_TOKEN: process.env.ACTIONS_TOKEN,
REPO_OWNER: owner,
REPO_NAME: repo,
PR_NUMBER: context.entityNumber.toString(),
RUNNER_TEMP: process.env.RUNNER_TEMP || "/tmp",
},
};
}
if (hasGitHubMcpTools) { if (hasGitHubMcpTools) {
baseMcpConfig.mcpServers.github = { baseMcpConfig.mcpServers.github = {
command: "docker", command: "docker",

View File

@@ -743,6 +743,36 @@ describe("buildAllowedToolsString", () => {
expect(basePlusCustom).toContain("Tool2"); expect(basePlusCustom).toContain("Tool2");
expect(basePlusCustom).toContain("Tool3"); expect(basePlusCustom).toContain("Tool3");
}); });
test("should include GitHub Actions tools when includeActionsTools is true", () => {
const result = buildAllowedToolsString([], true);
// Base tools should be present
expect(result).toContain("Edit");
expect(result).toContain("Glob");
// GitHub Actions tools should be included
expect(result).toContain("mcp__github_ci__get_ci_status");
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
expect(result).toContain("mcp__github_ci__download_job_log");
});
test("should include both custom and Actions tools when both provided", () => {
const customTools = ["Tool1", "Tool2"];
const result = buildAllowedToolsString(customTools, true);
// Base tools should be present
expect(result).toContain("Edit");
// Custom tools should be included
expect(result).toContain("Tool1");
expect(result).toContain("Tool2");
// GitHub Actions tools should be included
expect(result).toContain("mcp__github_ci__get_ci_status");
expect(result).toContain("mcp__github_ci__get_workflow_run_details");
expect(result).toContain("mcp__github_ci__download_job_log");
});
}); });
describe("buildDisallowedToolsString", () => { describe("buildDisallowedToolsString", () => {

View File

@@ -1,5 +1,8 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { parseMultilineInput } from "../../src/github/context"; import {
parseMultilineInput,
parseAdditionalPermissions,
} from "../../src/github/context";
describe("parseMultilineInput", () => { describe("parseMultilineInput", () => {
it("should parse a comma-separated string", () => { it("should parse a comma-separated string", () => {
@@ -55,3 +58,58 @@ Bash(bun typecheck)
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
}); });
describe("parseAdditionalPermissions", () => {
it("should parse single permission", () => {
const input = "actions: read";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
it("should parse multiple permissions", () => {
const input = `actions: read
packages: write
contents: read`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.get("contents")).toBe("read");
expect(result.size).toBe(3);
});
it("should handle empty string", () => {
const input = "";
const result = parseAdditionalPermissions(input);
expect(result.size).toBe(0);
});
it("should handle whitespace and empty lines", () => {
const input = `
actions: read
packages: write
`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should ignore lines without colon separator", () => {
const input = `actions: read
invalid line
packages: write`;
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.get("packages")).toBe("write");
expect(result.size).toBe(2);
});
it("should trim whitespace around keys and values", () => {
const input = " actions : read ";
const result = parseAdditionalPermissions(input);
expect(result.get("actions")).toBe("read");
expect(result.size).toBe(1);
});
});

View File

@@ -1,6 +1,7 @@
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { prepareMcpConfig } from "../src/mcp/install-mcp-server"; import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
import * as core from "@actions/core"; import * as core from "@actions/core";
import type { ParsedGitHubContext } from "../src/github/context";
describe("prepareMcpConfig", () => { describe("prepareMcpConfig", () => {
let consoleInfoSpy: any; let consoleInfoSpy: any;
@@ -8,6 +9,41 @@ describe("prepareMcpConfig", () => {
let setFailedSpy: any; let setFailedSpy: any;
let processExitSpy: any; let processExitSpy: any;
// Create a mock context for tests
const mockContext: ParsedGitHubContext = {
runId: "test-run-id",
eventName: "issue_comment",
eventAction: "created",
repository: {
owner: "test-owner",
repo: "test-repo",
full_name: "test-owner/test-repo",
},
actor: "test-actor",
payload: {} as any,
entityNumber: 123,
isPR: false,
inputs: {
triggerPhrase: "@claude",
assigneeTrigger: "",
labelTrigger: "",
allowedTools: [],
disallowedTools: [],
customInstructions: "",
directPrompt: "",
branchPrefix: "",
useStickyComment: false,
additionalPermissions: new Map(),
},
};
const mockPRContext: ParsedGitHubContext = {
...mockContext,
eventName: "pull_request",
isPR: true,
entityNumber: 456,
};
beforeEach(() => { beforeEach(() => {
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {}); consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {}); consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
@@ -15,6 +51,11 @@ describe("prepareMcpConfig", () => {
processExitSpy = spyOn(process, "exit").mockImplementation(() => { processExitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process exit"); throw new Error("Process exit");
}); });
// Set up required environment variables
if (!process.env.GITHUB_ACTION_PATH) {
process.env.GITHUB_ACTION_PATH = "/test/action/path";
}
}); });
afterEach(() => { afterEach(() => {
@@ -31,6 +72,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -57,6 +99,7 @@ describe("prepareMcpConfig", () => {
"mcp__github__create_issue", "mcp__github__create_issue",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -78,6 +121,7 @@ describe("prepareMcpConfig", () => {
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
"mcp__github_file_ops__update_claude_comment", "mcp__github_file_ops__update_claude_comment",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -93,6 +137,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: ["Edit", "Read", "Write"], allowedTools: ["Edit", "Read", "Write"],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -109,6 +154,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: "", additionalMcpConfig: "",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -126,6 +172,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: " \n\t ", additionalMcpConfig: " \n\t ",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -158,6 +205,7 @@ describe("prepareMcpConfig", () => {
"mcp__github__create_issue", "mcp__github__create_issue",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -195,6 +243,7 @@ describe("prepareMcpConfig", () => {
"mcp__github__create_issue", "mcp__github__create_issue",
"mcp__github_file_ops__commit_files", "mcp__github_file_ops__commit_files",
], ],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -232,6 +281,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: additionalConfig, additionalMcpConfig: additionalConfig,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -251,6 +301,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: invalidJson, additionalMcpConfig: invalidJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -271,6 +322,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: nonObjectJson, additionalMcpConfig: nonObjectJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -294,6 +346,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: nullJson, additionalMcpConfig: nullJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -317,6 +370,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: arrayJson, additionalMcpConfig: arrayJson,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -363,6 +417,7 @@ describe("prepareMcpConfig", () => {
branch: "test-branch", branch: "test-branch",
additionalMcpConfig: additionalConfig, additionalMcpConfig: additionalConfig,
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -384,6 +439,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -404,6 +460,7 @@ describe("prepareMcpConfig", () => {
repo: "test-repo", repo: "test-repo",
branch: "test-branch", branch: "test-branch",
allowedTools: [], allowedTools: [],
context: mockContext,
}); });
const parsed = JSON.parse(result); const parsed = JSON.parse(result);
@@ -411,4 +468,132 @@ describe("prepareMcpConfig", () => {
process.env.GITHUB_WORKSPACE = oldEnv; process.env.GITHUB_WORKSPACE = oldEnv;
}); });
test("should include github_ci server when context.isPR is true and actions:read permission is granted", async () => {
const oldEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
expect(parsed.mcpServers.github_ci.env.PR_NUMBER).toBe("456");
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.ACTIONS_TOKEN = oldEnv;
});
test("should not include github_ci server when context.isPR is false", async () => {
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: mockContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
});
test("should not include github_ci server when actions:read permission is not granted", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: mockPRContext,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).not.toBeDefined();
expect(parsed.mcpServers.github_file_ops).toBeDefined();
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
test("should parse additional_permissions with multiple lines correctly", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "workflow-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([
["actions", "read"],
["future", "permission"],
]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(parsed.mcpServers.github_ci.env.GITHUB_TOKEN).toBe("workflow-token");
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
test("should warn when actions:read is requested but token lacks permission", async () => {
const oldTokenEnv = process.env.ACTIONS_TOKEN;
process.env.ACTIONS_TOKEN = "invalid-token";
const contextWithPermissions = {
...mockPRContext,
inputs: {
...mockPRContext.inputs,
additionalPermissions: new Map([["actions", "read"]]),
},
};
const result = await prepareMcpConfig({
githubToken: "test-token",
owner: "test-owner",
repo: "test-repo",
branch: "test-branch",
allowedTools: [],
context: contextWithPermissions,
});
const parsed = JSON.parse(result);
expect(parsed.mcpServers.github_ci).toBeDefined();
expect(consoleWarningSpy).toHaveBeenCalledWith(
expect.stringContaining(
"The github_ci MCP server requires 'actions: read' permission",
),
);
process.env.ACTIONS_TOKEN = oldTokenEnv;
});
}); });

View File

@@ -21,6 +21,7 @@ const defaultInputs = {
timeoutMinutes: 30, timeoutMinutes: 30,
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map<string, string>(),
}; };
const defaultRepository = { const defaultRepository = {

View File

@@ -69,6 +69,7 @@ describe("checkWritePermissions", () => {
directPrompt: "", directPrompt: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });

View File

@@ -37,6 +37,7 @@ describe("checkContainsTrigger", () => {
customInstructions: "", customInstructions: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -66,6 +67,7 @@ describe("checkContainsTrigger", () => {
customInstructions: "", customInstructions: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);
@@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => {
customInstructions: "", customInstructions: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => {
customInstructions: "", customInstructions: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(true); expect(checkContainsTrigger(context)).toBe(true);
@@ -339,6 +343,7 @@ describe("checkContainsTrigger", () => {
customInstructions: "", customInstructions: "",
branchPrefix: "claude/", branchPrefix: "claude/",
useStickyComment: false, useStickyComment: false,
additionalPermissions: new Map(),
}, },
}); });
expect(checkContainsTrigger(context)).toBe(false); expect(checkContainsTrigger(context)).toBe(false);