mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 14:24: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:
@@ -743,6 +743,36 @@ describe("buildAllowedToolsString", () => {
|
||||
expect(basePlusCustom).toContain("Tool2");
|
||||
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", () => {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { parseMultilineInput } from "../../src/github/context";
|
||||
import {
|
||||
parseMultilineInput,
|
||||
parseAdditionalPermissions,
|
||||
} from "../../src/github/context";
|
||||
|
||||
describe("parseMultilineInput", () => {
|
||||
it("should parse a comma-separated string", () => {
|
||||
@@ -55,3 +58,58 @@ Bash(bun typecheck)
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
||||
import { prepareMcpConfig } from "../src/mcp/install-mcp-server";
|
||||
import * as core from "@actions/core";
|
||||
import type { ParsedGitHubContext } from "../src/github/context";
|
||||
|
||||
describe("prepareMcpConfig", () => {
|
||||
let consoleInfoSpy: any;
|
||||
@@ -8,6 +9,41 @@ describe("prepareMcpConfig", () => {
|
||||
let setFailedSpy: 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(() => {
|
||||
consoleInfoSpy = spyOn(core, "info").mockImplementation(() => {});
|
||||
consoleWarningSpy = spyOn(core, "warning").mockImplementation(() => {});
|
||||
@@ -15,6 +51,11 @@ describe("prepareMcpConfig", () => {
|
||||
processExitSpy = spyOn(process, "exit").mockImplementation(() => {
|
||||
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(() => {
|
||||
@@ -31,6 +72,7 @@ describe("prepareMcpConfig", () => {
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -57,6 +99,7 @@ describe("prepareMcpConfig", () => {
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -78,6 +121,7 @@ describe("prepareMcpConfig", () => {
|
||||
"mcp__github_file_ops__commit_files",
|
||||
"mcp__github_file_ops__update_claude_comment",
|
||||
],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -93,6 +137,7 @@ describe("prepareMcpConfig", () => {
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
allowedTools: ["Edit", "Read", "Write"],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -109,6 +154,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: "",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -126,6 +172,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: " \n\t ",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -158,6 +205,7 @@ describe("prepareMcpConfig", () => {
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -195,6 +243,7 @@ describe("prepareMcpConfig", () => {
|
||||
"mcp__github__create_issue",
|
||||
"mcp__github_file_ops__commit_files",
|
||||
],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -232,6 +281,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -251,6 +301,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: invalidJson,
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -271,6 +322,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: nonObjectJson,
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -294,6 +346,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: nullJson,
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -317,6 +370,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: arrayJson,
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -363,6 +417,7 @@ describe("prepareMcpConfig", () => {
|
||||
branch: "test-branch",
|
||||
additionalMcpConfig: additionalConfig,
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -384,6 +439,7 @@ describe("prepareMcpConfig", () => {
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -404,6 +460,7 @@ describe("prepareMcpConfig", () => {
|
||||
repo: "test-repo",
|
||||
branch: "test-branch",
|
||||
allowedTools: [],
|
||||
context: mockContext,
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(result);
|
||||
@@ -411,4 +468,132 @@ describe("prepareMcpConfig", () => {
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ const defaultInputs = {
|
||||
timeoutMinutes: 30,
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map<string, string>(),
|
||||
};
|
||||
|
||||
const defaultRepository = {
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("checkWritePermissions", () => {
|
||||
directPrompt: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("checkContainsTrigger", () => {
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -66,6 +67,7 @@ describe("checkContainsTrigger", () => {
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
@@ -279,6 +281,7 @@ describe("checkContainsTrigger", () => {
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -309,6 +312,7 @@ describe("checkContainsTrigger", () => {
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(true);
|
||||
@@ -339,6 +343,7 @@ describe("checkContainsTrigger", () => {
|
||||
customInstructions: "",
|
||||
branchPrefix: "claude/",
|
||||
useStickyComment: false,
|
||||
additionalPermissions: new Map(),
|
||||
},
|
||||
});
|
||||
expect(checkContainsTrigger(context)).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user