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

View File

@@ -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", () => {

View File

@@ -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);
});
});

View File

@@ -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;
});
});

View File

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

View File

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

View File

@@ -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);