mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
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:
@@ -36,9 +36,21 @@ const BASE_ALLOWED_TOOLS = [
|
||||
];
|
||||
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];
|
||||
|
||||
// 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(",");
|
||||
if (customAllowedTools && customAllowedTools.length > 0) {
|
||||
allAllowedTools = `${allAllowedTools},${customAllowedTools.join(",")}`;
|
||||
@@ -665,8 +677,12 @@ export async function createPrompt(
|
||||
);
|
||||
|
||||
// Set allowed tools
|
||||
const hasActionsReadPermission =
|
||||
context.inputs.additionalPermissions.get("actions") === "read" &&
|
||||
context.isPR;
|
||||
const allAllowedTools = buildAllowedToolsString(
|
||||
context.inputs.allowedTools,
|
||||
hasActionsReadPermission,
|
||||
);
|
||||
const allDisallowedTools = buildDisallowedToolsString(
|
||||
context.inputs.disallowedTools,
|
||||
|
||||
@@ -94,6 +94,7 @@ async function run() {
|
||||
additionalMcpConfig,
|
||||
claudeCommentId: commentId.toString(),
|
||||
allowedTools: context.inputs.allowedTools,
|
||||
context,
|
||||
});
|
||||
core.setOutput("mcp_config", mcpConfig);
|
||||
} catch (error) {
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ParsedGitHubContext = {
|
||||
baseBranch?: string;
|
||||
branchPrefix: string;
|
||||
useStickyComment: boolean;
|
||||
additionalPermissions: Map<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -64,6 +65,9 @@ export function parseGitHubContext(): ParsedGitHubContext {
|
||||
baseBranch: process.env.BASE_BRANCH,
|
||||
branchPrefix: process.env.BRANCH_PREFIX ?? "claude/",
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
context: ParsedGitHubContext,
|
||||
): context is ParsedGitHubContext & { payload: IssuesEvent } {
|
||||
|
||||
275
src/mcp/github-actions-server.ts
Normal file
275
src/mcp/github-actions-server.ts
Normal 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);
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as core from "@actions/core";
|
||||
import { GITHUB_API_URL } from "../github/api/config";
|
||||
import type { ParsedGitHubContext } from "../github/context";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
|
||||
type PrepareConfigParams = {
|
||||
githubToken: string;
|
||||
@@ -9,8 +11,41 @@ type PrepareConfigParams = {
|
||||
additionalMcpConfig?: string;
|
||||
claudeCommentId?: 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(
|
||||
params: PrepareConfigParams,
|
||||
): Promise<string> {
|
||||
@@ -22,6 +57,7 @@ export async function prepareMcpConfig(
|
||||
additionalMcpConfig,
|
||||
claudeCommentId,
|
||||
allowedTools,
|
||||
context,
|
||||
} = params;
|
||||
try {
|
||||
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) {
|
||||
baseMcpConfig.mcpServers.github = {
|
||||
command: "docker",
|
||||
|
||||
Reference in New Issue
Block a user