# Directory Structure ``` ├── .github │ ├── dependabot.yml │ └── workflows │ ├── ci.yml │ └── dependabot-auto-merge.yml ├── .gitignore ├── Dockerfile ├── notes.md ├── package-lock.json ├── package.json ├── README.md ├── run-server.bat ├── smithery.yaml ├── src │ ├── common │ │ ├── errors.ts │ │ ├── types.ts │ │ ├── utils.ts │ │ └── version.ts │ ├── index.ts │ └── operations │ └── actions.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Dependencies node_modules/ npm-debug.log yarn-debug.log yarn-error.log yarn.lock # Build output dist/ build/ out/ .next/ .nuxt/ # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Editor settings .idea/ .vscode/ *.swp *.swo .DS_Store .prettierrc # Test coverage/ .nyc_output/ # Others .cache/ tmp/ temp/ # Claude Crew .claude-crew/ task-analysis/ ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown [](https://mseep.ai/app/cad0f49e-1c4d-4ab1-97e4-2312da835454) [](https://mseep.ai/app/ko1ynnky-github-actions-mcp-server) # GitHub Actions MCP Server [](https://smithery.ai/server/@ko1ynnky/github-actions-mcp-server) > **⚠️ Archive Notice**: This repository will be archived soon as the official GitHub MCP server is adding Actions support. See [github/github-mcp-server#491](https://github.com/github/github-mcp-server/pull/491) for details on the official implementation. MCP Server for the GitHub Actions API, enabling AI assistants to manage and operate GitHub Actions workflows. Compatible with multiple AI coding assistants including Claude Desktop, Codeium, and Windsurf. ### Features - **Complete Workflow Management**: List, view, trigger, cancel, and rerun workflows - **Workflow Run Analysis**: Get detailed information about workflow runs and their jobs - **Comprehensive Error Handling**: Clear error messages with enhanced details - **Flexible Type Validation**: Robust type checking with graceful handling of API variations - **Security-Focused Design**: Timeout handling, rate limiting, and strict URL validation ## Tools 1. `list_workflows` - List workflows in a GitHub repository - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `page` (optional number): Page number for pagination - `perPage` (optional number): Results per page (max 100) - Returns: List of workflows in the repository 2. `get_workflow` - Get details of a specific workflow - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `workflowId` (string or number): The ID of the workflow or filename - Returns: Detailed information about the workflow 3. `get_workflow_usage` - Get usage statistics of a workflow - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `workflowId` (string or number): The ID of the workflow or filename - Returns: Usage statistics including billable minutes 4. `list_workflow_runs` - List all workflow runs for a repository or a specific workflow - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `workflowId` (optional string or number): The ID of the workflow or filename - `actor` (optional string): Filter by user who triggered the workflow - `branch` (optional string): Filter by branch - `event` (optional string): Filter by event type - `status` (optional string): Filter by status - `created` (optional string): Filter by creation date (YYYY-MM-DD) - `excludePullRequests` (optional boolean): Exclude PR-triggered runs - `checkSuiteId` (optional number): Filter by check suite ID - `page` (optional number): Page number for pagination - `perPage` (optional number): Results per page (max 100) - Returns: List of workflow runs matching the criteria 5. `get_workflow_run` - Get details of a specific workflow run - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `runId` (number): The ID of the workflow run - Returns: Detailed information about the specific workflow run 6. `get_workflow_run_jobs` - Get jobs for a specific workflow run - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `runId` (number): The ID of the workflow run - `filter` (optional string): Filter jobs by completion status ('latest', 'all') - `page` (optional number): Page number for pagination - `perPage` (optional number): Results per page (max 100) - Returns: List of jobs in the workflow run 7. `trigger_workflow` - Trigger a workflow run - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `workflowId` (string or number): The ID of the workflow or filename - `ref` (string): The reference to run the workflow on (branch, tag, or SHA) - `inputs` (optional object): Input parameters for the workflow - Returns: Information about the triggered workflow run 8. `cancel_workflow_run` - Cancel a workflow run - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `runId` (number): The ID of the workflow run - Returns: Status of the cancellation operation 9. `rerun_workflow` - Re-run a workflow run - Inputs: - `owner` (string): Repository owner (username or organization) - `repo` (string): Repository name - `runId` (number): The ID of the workflow run - Returns: Status of the re-run operation ### Usage with AI Coding Assistants This MCP server is compatible with multiple AI coding assistants including Claude Desktop, Codeium, and Windsurf. #### Claude Desktop First, make sure you have built the project (see Build section below). Then, add the following to your `claude_desktop_config.json`: ```json { "mcpServers": { "github-actions": { "command": "node", "args": [ "<path-to-mcp-server>/dist/index.js" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>" } } } } ``` #### Codeium Add the following configuration to your Codeium MCP config file (typically at `~/.codeium/windsurf/mcp_config.json` on Unix-based systems or `%USERPROFILE%\.codeium\windsurf\mcp_config.json` on Windows): ```json { "mcpServers": { "github-actions": { "command": "node", "args": [ "<path-to-mcp-server>/dist/index.js" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "<YOUR_TOKEN>" } } } } ``` #### Windsurf Windsurf uses the same configuration format as Codeium. Add the server to your Windsurf MCP configuration as shown above for Codeium. ## Build ### Unix/Linux/macOS Clone the repository and build: ```bash git clone https://github.com/ko1ynnky/github-actions-mcp-server.git cd github-actions-mcp-server npm install npm run build ``` ### Windows For Windows systems, use the Windows-specific build command: ```bash git clone https://github.com/ko1ynnky/github-actions-mcp-server.git cd github-actions-mcp-server npm install npm run build:win ``` Alternatively, you can use the included batch file: ```bash run-server.bat [optional-github-token] ``` This will create the necessary files in the `dist` directory that you'll need to run the MCP server. #### Windows-Specific Instructions **Prerequisites** - Node.js (v14 or higher) - npm (v6 or higher) **Running the Server on Windows** 1. Using the batch file (simplest method): ``` run-server.bat [optional-github-token] ``` This will check if the build exists, build if needed, and start the server. 2. Using npm directly: ``` npm run start ``` **Setting GitHub Personal Access Token on Windows** For full functionality and to avoid rate limiting, you need to set your GitHub Personal Access Token. Options: 1. Pass it as a parameter to the batch file: ``` run-server.bat your_github_token_here ``` 2. Set it as an environment variable: ``` set GITHUB_PERSONAL_ACCESS_TOKEN=your_github_token_here npm run start ``` **Troubleshooting Windows Issues** If you encounter issues: 1. **Build errors**: Make sure TypeScript is installed correctly. ``` npm install -g typescript ``` 2. **Permission issues**: Ensure you're running the commands in a command prompt with appropriate permissions. 3. **Node.js errors**: Verify you're using a compatible Node.js version. ``` node --version ``` ## Usage Examples List workflows in a repository: ```javascript const result = await listWorkflows({ owner: "your-username", repo: "your-repository" }); ``` Trigger a workflow: ```javascript const result = await triggerWorkflow({ owner: "your-username", repo: "your-repository", workflowId: "ci.yml", ref: "main", inputs: { environment: "production" } }); ``` ## Troubleshooting ### Common Issues 1. **Authentication Errors**: - Ensure your GitHub token has the correct permissions - Check that the token is correctly set as an environment variable 2. **Rate Limiting**: - The server implements rate limiting to avoid hitting GitHub API limits - If you encounter rate limit errors, reduce the frequency of requests 3. **Type Validation Errors**: - GitHub API responses might sometimes differ from expected schemas - The server implements flexible validation to handle most variations - If you encounter persistent errors, please open an issue ## License This MCP server is licensed under the MIT License. ``` -------------------------------------------------------------------------------- /src/common/version.ts: -------------------------------------------------------------------------------- ```typescript // Store version here for easy updates export const VERSION = "0.1.0"; ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "strict": true, "outDir": "dist", "declaration": true, "skipLibCheck": true }, "include": ["src/**/*"] } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile FROM node:lts-alpine # Create app directory WORKDIR /app # Install app dependencies COPY package.json package-lock.json tsconfig.json ./ COPY src ./src # Install dependencies and build RUN npm install --ignore-scripts && npm run build # Expose any ports if needed (MCP over stdio, no ports) # Default command to run the MCP server CMD ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: - githubPersonalAccessToken properties: githubPersonalAccessToken: type: string description: GitHub Personal Access Token for API access commandFunction: # A JS function that produces the CLI command based on the given config to start the MCP on stdio. |- (config) => ({ command: 'node', args: ['dist/index.js'], env: { GITHUB_PERSONAL_ACCESS_TOKEN: config.githubPersonalAccessToken } }) exampleConfig: githubPersonalAccessToken: ghp_exampletoken1234567890 ``` -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- ```yaml name: Dependabot auto-merge on: pull_request permissions: pull-requests: write contents: write jobs: dependabot: runs-on: ubuntu-latest if: ${{ github.actor == 'dependabot[bot]' }} steps: - name: Dependabot metadata id: metadata uses: dependabot/fetch-metadata@v1 with: github-token: "${{ secrets.GITHUB_TOKEN }}" - name: Auto-merge for dev dependency updates if: ${{steps.metadata.outputs.dependency-type == 'direct:development' && (steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch')}} run: gh pr merge --auto --merge "$PR_URL" env: PR_URL: ${{github.event.pull_request.html_url}} GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "github-actions-mcp", "version": "0.1.0", "description": "MCP server for using the GitHub Actions API", "license": "MIT", "type": "module", "main": "dist/index.js", "bin": { "github-actions-mcp": "dist/index.js" }, "files": [ "dist" ], "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('./dist/index.js', 0o755)\"", "build:win": "tsc", "start": "node dist/index.js", "dev": "tsc && node dist/index.js", "watch": "tsc --watch", "lint": "tsc --noEmit", "test": "echo No tests specified && exit 0" }, "dependencies": { "@modelcontextprotocol/sdk": "1.12.1", "@types/node": "22.15.29", "node-fetch": "^3.3.2", "@octokit/rest": "^22.0.0", "universal-user-agent": "^7.0.3", "zod": "^3.25.46", "zod-to-json-schema": "^3.24.5" }, "devDependencies": { "typescript": "^5.8.3" } } ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: push: branches: [main] paths-ignore: - "**.md" - "docs/**" pull_request: branches: [main] paths-ignore: - "**.md" - "docs/**" workflow_dispatch: permissions: contents: read pull-requests: read jobs: build: name: Build runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 - name: Generate package-lock.json run: npm install --package-lock-only - name: Install dependencies run: npm ci - name: Build run: npm run build env: CI: true - name: Upload build artifacts uses: actions/upload-artifact@v4 with: name: build path: dist/ retention-days: 7 - name: Upload package-lock.json uses: actions/upload-artifact@v4 with: name: package-lock path: package-lock.json retention-days: 7 ``` -------------------------------------------------------------------------------- /run-server.bat: -------------------------------------------------------------------------------- ``` @echo off echo GitHub Actions MCP Server for Windows echo ---------------------------------- rem Check if Node.js is installed where node >nul 2>nul if %ERRORLEVEL% neq 0 ( echo Error: Node.js is not installed or not in PATH echo Please install Node.js from https://nodejs.org/ exit /b 1 ) rem Check if dist folder exists; if not, build the project if not exist dist ( echo Building project... call npm run build:win if %ERRORLEVEL% neq 0 ( echo Build failed. Please check for errors. exit /b 1 ) ) rem Set GitHub Personal Access Token if provided as command line argument if not "%~1"=="" ( set GITHUB_PERSONAL_ACCESS_TOKEN=%~1 echo Using provided GitHub Personal Access Token ) else ( if defined GITHUB_PERSONAL_ACCESS_TOKEN ( echo Using GitHub Personal Access Token from environment ) else ( echo No GitHub Personal Access Token provided echo Some API calls may be rate-limited ) ) echo Starting MCP server... echo Listening on stdio... echo Press Ctrl+C to stop the server node dist/index.js echo Server stopped ``` -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- ```markdown if you could put questions in this file, I'll monitor it and try to answer them to prevent interactions. --- ### Questions / Actions (2025-04-26) 1. **Full Error Log** – please paste the entire stack-trace or log output that appears when Windsurf shows `failed to initialize: request failed`. I don't know where that is located can you point me to it? 2. **Server Log Confirmation** – after the failure, does `dist/mcp-startup.log` still show the line `Connected via stdio transport.`? Can you not check the log file? [2025-04-26T21:20:03.956Z] [MCP Server Log] Log file cleared/initialized. [2025-04-26T21:20:03.958Z] [MCP Server Log] Initializing GitHub Actions MCP Server... [2025-04-26T21:20:03.958Z] [MCP Server Log] GitHub token found. [2025-04-26T21:20:03.959Z] [MCP Server Log] Octokit initialized. [2025-04-26T21:20:03.960Z] [MCP Server Log] Server initialization complete. Ready for connection. [2025-04-26T21:20:03.961Z] [MCP Server Log] Connected via stdio transport. 3. **SDK Version Mismatch** – in the VS Code terminal, run `npm ls @modelcontextprotocol/sdk` and paste the output so we can verify the client/server versions match. can you not run that? (base) PS E:\code\github-actions-mcp-server> npm ls @modelcontextprotocol/sdk [email protected] E:\code\github-actions-mcp-server `-- @modelcontextprotocol/[email protected] 4. **Proxy / Firewall** – are you running behind a proxy, VPN, or firewall that could block subprocesses or stdio pipes? If yes, please give details. No proxy or VPN, just windows firewall which is open outbound _Add your answers below each question. Feel free to include any other clues._ ``` -------------------------------------------------------------------------------- /src/common/errors.ts: -------------------------------------------------------------------------------- ```typescript export class GitHubError extends Error { constructor( message: string, public readonly status: number, public readonly response: unknown ) { super(message); this.name = "GitHubError"; } } export class GitHubValidationError extends GitHubError { constructor(message: string, status: number, response: unknown) { super(message, status, response); this.name = "GitHubValidationError"; } } export class GitHubResourceNotFoundError extends GitHubError { constructor(resource: string) { super(`Resource not found: ${resource}`, 404, { message: `${resource} not found` }); this.name = "GitHubResourceNotFoundError"; } } export class GitHubAuthenticationError extends GitHubError { constructor(message = "Authentication failed") { super(message, 401, { message }); this.name = "GitHubAuthenticationError"; } } export class GitHubPermissionError extends GitHubError { constructor(message = "Insufficient permissions") { super(message, 403, { message }); this.name = "GitHubPermissionError"; } } export class GitHubRateLimitError extends GitHubError { constructor( message = "Rate limit exceeded", public readonly resetAt: Date ) { super(message, 429, { message, reset_at: resetAt.toISOString() }); this.name = "GitHubRateLimitError"; } } export class GitHubTimeoutError extends GitHubError { constructor( message = "Request timed out", public readonly timeoutMs: number ) { super(message, 408, { message, timeout_ms: timeoutMs }); this.name = "GitHubTimeoutError"; } } export class GitHubNetworkError extends GitHubError { constructor( message = "Network error", public readonly errorCode: string ) { super(message, 500, { message, error_code: errorCode }); this.name = "GitHubNetworkError"; } } export class GitHubConflictError extends GitHubError { constructor(message: string) { super(message, 409, { message }); this.name = "GitHubConflictError"; } } export function isGitHubError(error: unknown): error is GitHubError { return error instanceof GitHubError; } // Add enhanced error factory function export function createEnhancedGitHubError(error: Error & { cause?: { code: string } }): GitHubError { // Handle timeout errors if (error.name === 'AbortError') { return new GitHubTimeoutError(`Request timed out: ${error.message}`, 30000); } // Handle network errors if (error.cause?.code) { return new GitHubNetworkError( `Network error: ${error.message}`, error.cause.code ); } // Handle other errors return new GitHubError(error.message, 500, { message: error.message }); } export function createGitHubError(status: number, response: any): GitHubError { switch (status) { case 401: return new GitHubAuthenticationError(response?.message); case 403: return new GitHubPermissionError(response?.message); case 404: return new GitHubResourceNotFoundError(response?.message || "Resource"); case 409: return new GitHubConflictError(response?.message || "Conflict occurred"); case 422: return new GitHubValidationError( response?.message || "Validation failed", status, response ); case 429: return new GitHubRateLimitError( response?.message, new Date(response?.reset_at || Date.now() + 60000) ); default: return new GitHubError( response?.message || "GitHub API error", status, response ); } } ``` -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; // Base GitHub types export const GitHubAuthorSchema = z.object({ name: z.string(), email: z.string(), date: z.string().optional(), }).passthrough(); // GitHub Workflow Run types export const WorkflowRunSchema = z.object({ id: z.number(), name: z.string().nullable(), node_id: z.string(), head_branch: z.string().nullable(), head_sha: z.string(), path: z.string(), display_title: z.string().nullable(), run_number: z.number(), event: z.string(), status: z.string().nullable(), conclusion: z.string().nullable(), workflow_id: z.number(), check_suite_id: z.number(), check_suite_node_id: z.string(), url: z.string(), html_url: z.string(), created_at: z.string().nullable().optional(), updated_at: z.string().nullable().optional(), run_attempt: z.number(), run_started_at: z.string().nullable().optional(), jobs_url: z.string(), logs_url: z.string(), check_suite_url: z.string(), artifacts_url: z.string(), cancel_url: z.string(), rerun_url: z.string(), previous_attempt_url: z.string().nullable(), workflow_url: z.string(), repository: z.object({ id: z.number(), node_id: z.string(), name: z.string(), full_name: z.string(), owner: z.object({ login: z.string(), id: z.number(), node_id: z.string(), avatar_url: z.string(), url: z.string(), html_url: z.string(), type: z.string(), }).passthrough(), html_url: z.string(), description: z.string().nullable(), fork: z.boolean(), url: z.string(), created_at: z.string().nullable().optional(), updated_at: z.string().nullable().optional(), }).passthrough(), head_repository: z.object({ id: z.number(), node_id: z.string(), name: z.string(), full_name: z.string(), owner: z.object({ login: z.string(), id: z.number(), node_id: z.string(), avatar_url: z.string(), url: z.string(), html_url: z.string(), type: z.string(), }).passthrough(), html_url: z.string(), description: z.string().nullable(), fork: z.boolean(), url: z.string(), created_at: z.string().nullable().optional(), updated_at: z.string().nullable().optional(), }).passthrough(), }).passthrough(); export const WorkflowRunsSchema = z.object({ total_count: z.number(), workflow_runs: z.array(WorkflowRunSchema), }).passthrough(); // GitHub Workflow Job types export const JobSchema = z.object({ id: z.number(), run_id: z.number(), workflow_name: z.string(), head_branch: z.string(), run_url: z.string(), run_attempt: z.number(), node_id: z.string(), head_sha: z.string(), url: z.string(), html_url: z.string(), status: z.string(), conclusion: z.string().nullable(), created_at: z.string(), started_at: z.string(), completed_at: z.string().nullable(), name: z.string(), steps: z.array( z.object({ name: z.string(), status: z.string(), conclusion: z.string().nullable(), number: z.number(), started_at: z.string().nullable(), completed_at: z.string().nullable(), }).passthrough() ), check_run_url: z.string(), labels: z.array(z.string()), runner_id: z.number().nullable(), runner_name: z.string().nullable(), runner_group_id: z.number().nullable(), runner_group_name: z.string().nullable(), }).passthrough(); export const JobsSchema = z.object({ total_count: z.number(), jobs: z.array(JobSchema), }).passthrough(); // GitHub Workflow types export const WorkflowSchema = z.object({ id: z.number(), node_id: z.string(), name: z.string(), path: z.string(), state: z.string(), created_at: z.string(), updated_at: z.string(), url: z.string(), html_url: z.string(), badge_url: z.string(), }).passthrough(); export const WorkflowsSchema = z.object({ total_count: z.number(), workflows: z.array(WorkflowSchema), }).passthrough(); // GitHub Workflow Usage types export const WorkflowUsageSchema = z.object({ billable: z.object({ UBUNTU: z.object({ total_ms: z.number().optional(), jobs: z.number().optional(), }).passthrough().optional(), MACOS: z.object({ total_ms: z.number().optional(), jobs: z.number().optional(), }).passthrough().optional(), WINDOWS: z.object({ total_ms: z.number().optional(), jobs: z.number().optional(), }).passthrough().optional(), }).passthrough().optional(), }).passthrough(); export type WorkflowRun = z.infer<typeof WorkflowRunSchema>; export type WorkflowRunsResponse = z.infer<typeof WorkflowRunsSchema>; export type Job = z.infer<typeof JobSchema>; export type JobsResponse = z.infer<typeof JobsSchema>; export type Workflow = z.infer<typeof WorkflowSchema>; export type WorkflowsResponse = z.infer<typeof WorkflowsSchema>; export type WorkflowUsage = z.infer<typeof WorkflowUsageSchema>; ``` -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- ```typescript import { getUserAgent } from "universal-user-agent"; import { createGitHubError, GitHubTimeoutError, GitHubNetworkError, GitHubError, createEnhancedGitHubError } from "./errors.js"; import { VERSION } from "./version.js"; type RequestOptions = { method?: string; body?: unknown; headers?: Record<string, string>; } async function parseResponseBody(response: Response): Promise<unknown> { const contentType = response.headers.get("content-type"); if (contentType?.includes("application/json")) { try { return await response.json(); } catch (error) { console.error("Error parsing JSON response:", error); throw new Error(`Error parsing JSON response: ${error}`); } } return response.text(); } export function buildUrl(baseUrl: string, params: Record<string, string | number | undefined>): string { const url = new URL(baseUrl); Object.entries(params).forEach(([key, value]) => { if (value !== undefined) { url.searchParams.append(key, value.toString()); } }); return url.toString(); } const USER_AGENT = `github-actions-mcp/v${VERSION} ${getUserAgent()}`; // Default timeout for GitHub API requests (30 seconds) const DEFAULT_TIMEOUT = 30000; // Rate limiting constants const MAX_REQUESTS_PER_MINUTE = 60; // GitHub API rate limit is typically 5000/hour for authenticated requests let requestCount = 0; let requestCountResetTime = Date.now() + 60000; /** * Make a request to the GitHub API with security enhancements * * @param url The URL to send the request to * @param options Request options including method, body, headers, and timeout * @returns The response body */ export async function githubRequest( url: string, options: RequestOptions & { timeout?: number } = {} ): Promise<unknown> { // Implement basic rate limiting if (Date.now() > requestCountResetTime) { requestCount = 0; requestCountResetTime = Date.now() + 60000; } if (requestCount >= MAX_REQUESTS_PER_MINUTE) { const waitTime = requestCountResetTime - Date.now(); throw new Error(`Rate limit exceeded. Please try again in ${Math.ceil(waitTime / 1000)} seconds.`); } requestCount++; // Validate URL to ensure it's a GitHub API URL (security measure) if (!url.startsWith('https://api.github.com/')) { throw new Error('Invalid GitHub API URL. Only https://api.github.com/ URLs are allowed.'); } const headers: Record<string, string> = { "Accept": "application/vnd.github.v3+json", "Content-Type": "application/json", "User-Agent": USER_AGENT, ...options.headers, }; if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) { headers["Authorization"] = `Bearer ${process.env.GITHUB_PERSONAL_ACCESS_TOKEN}`; } // Set up request timeout const timeout = options.timeout || DEFAULT_TIMEOUT; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { method: options.method || "GET", headers, body: options.body ? JSON.stringify(options.body) : undefined, signal: controller.signal }); const responseBody = await parseResponseBody(response); if (!response.ok) { throw createGitHubError(response.status, responseBody); } return responseBody; } catch (error: unknown) { if ((error as Error).name === 'AbortError') { throw new GitHubTimeoutError(`Request timeout after ${timeout}ms`, timeout); } if ((error as { cause?: { code: string } }).cause?.code === 'ENOTFOUND' || (error as { cause?: { code: string } }).cause?.code === 'ECONNREFUSED') { throw new GitHubNetworkError(`Unable to connect to GitHub API`, (error as { cause?: { code: string } }).cause!.code); } if (!(error instanceof GitHubError)) { throw createEnhancedGitHubError(error as Error & { cause?: { code: string } }); } throw error; } finally { clearTimeout(timeoutId); } } export function validateRepositoryName(name: string): string { const sanitized = name.trim().toLowerCase(); if (!sanitized) { throw new Error("Repository name cannot be empty"); } if (!/^[a-z0-9_.-]+$/.test(sanitized)) { throw new Error( "Repository name can only contain lowercase letters, numbers, hyphens, periods, and underscores" ); } if (sanitized.startsWith(".") || sanitized.endsWith(".")) { throw new Error("Repository name cannot start or end with a period"); } return sanitized; } export function validateOwnerName(owner: string): string { const sanitized = owner.trim().toLowerCase(); if (!sanitized) { throw new Error("Owner name cannot be empty"); } if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$/.test(sanitized)) { throw new Error( "Owner name must start with a letter or number and can contain up to 39 characters" ); } return sanitized; } ``` -------------------------------------------------------------------------------- /src/operations/actions.ts: -------------------------------------------------------------------------------- ```typescript import { z } from "zod"; import { githubRequest, buildUrl, validateOwnerName, validateRepositoryName } from "../common/utils.js"; import { WorkflowRunsSchema, WorkflowRunSchema, JobsSchema, WorkflowsSchema, WorkflowSchema, WorkflowUsageSchema } from "../common/types.js"; /** * Schema definitions */ // List workflows schemas export const ListWorkflowsSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), page: z.number().optional().describe("Page number for pagination"), perPage: z.number().optional().describe("Results per page (max 100)"), }); // Get workflow schema export const GetWorkflowSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), workflowId: z.string().describe("The ID of the workflow or filename (string or number)"), }); // Get workflow usage schema export const GetWorkflowUsageSchema = GetWorkflowSchema; // List workflow runs schema export const ListWorkflowRunsSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), workflowId: z.string().optional().describe("The ID of the workflow or filename (string or number)"), actor: z.string().optional().describe("Returns someone's workflow runs. Use the login for the user"), branch: z.string().optional().describe("Returns workflow runs associated with a branch"), event: z.string().optional().describe("Returns workflow runs triggered by the event"), status: z.enum(['completed', 'action_required', 'cancelled', 'failure', 'neutral', 'skipped', 'stale', 'success', 'timed_out', 'in_progress', 'queued', 'requested', 'waiting', 'pending']).optional().describe("Returns workflow runs with the check run status"), created: z.string().optional().describe("Returns workflow runs created within date range (YYYY-MM-DD)"), excludePullRequests: z.boolean().optional().describe("If true, pull requests are omitted from the response"), checkSuiteId: z.number().optional().describe("Returns workflow runs with the check_suite_id"), page: z.number().optional().describe("Page number for pagination"), perPage: z.number().optional().describe("Results per page (max 100)"), }); // Get workflow run schema export const GetWorkflowRunSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), runId: z.number().describe("The ID of the workflow run"), }); // Get workflow run jobs schema export const GetWorkflowRunJobsSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), runId: z.number().describe("The ID of the workflow run"), filter: z.enum(['latest', 'all']).optional().describe("Filter jobs by their completed_at date"), page: z.number().optional().describe("Page number for pagination"), perPage: z.number().optional().describe("Results per page (max 100)"), }); // Trigger workflow schema export const TriggerWorkflowSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), workflowId: z.string().describe("The ID of the workflow or filename (string or number)"), ref: z.string().describe("The reference of the workflow run (branch, tag, or SHA)"), inputs: z.record(z.string(), z.string()).optional().describe("Input parameters for the workflow"), }); // Cancel workflow run schema export const CancelWorkflowRunSchema = z.object({ owner: z.string().describe("Repository owner (username or organization)"), repo: z.string().describe("Repository name"), runId: z.number().describe("The ID of the workflow run"), }); // Rerun workflow schema export const RerunWorkflowSchema = CancelWorkflowRunSchema; /** * Function implementations */ // List workflows in a repository export async function listWorkflows( owner: string, repo: string, page?: number, perPage?: number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = buildUrl(`https://api.github.com/repos/${owner}/${repo}/actions/workflows`, { page: page, per_page: perPage }); const response = await githubRequest(url); return WorkflowsSchema.parse(response); } // Get a workflow export async function getWorkflow( owner: string, repo: string, workflowId: string | number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflowId}`; const response = await githubRequest(url); return WorkflowSchema.parse(response); } // Get workflow usage export async function getWorkflowUsage( owner: string, repo: string, workflowId: string | number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflowId}/timing`; const response = await githubRequest(url); return WorkflowUsageSchema.parse(response); } // List workflow runs export async function listWorkflowRuns( owner: string, repo: string, options: { workflowId?: string | number, actor?: string, branch?: string, event?: string, status?: string, created?: string, excludePullRequests?: boolean, checkSuiteId?: number, page?: number, perPage?: number } = {} ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); let url; if (options.workflowId) { url = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${options.workflowId}/runs`; } else { url = `https://api.github.com/repos/${owner}/${repo}/actions/runs`; } url = buildUrl(url, { actor: options.actor, branch: options.branch, event: options.event, status: options.status, created: options.created, exclude_pull_requests: options.excludePullRequests ? "true" : undefined, check_suite_id: options.checkSuiteId, page: options.page, per_page: options.perPage }); const response = await githubRequest(url); return WorkflowRunsSchema.parse(response); } // Get a workflow run export async function getWorkflowRun( owner: string, repo: string, runId: number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}`; const response = await githubRequest(url); return WorkflowRunSchema.parse(response); } // Get workflow run jobs export async function getWorkflowRunJobs( owner: string, repo: string, runId: number, filter?: 'latest' | 'all', page?: number, perPage?: number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = buildUrl(`https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/jobs`, { filter: filter, page: page, per_page: perPage }); const response = await githubRequest(url); return JobsSchema.parse(response); } // Trigger a workflow run export async function triggerWorkflow( owner: string, repo: string, workflowId: string | number, ref: string, inputs?: Record<string, string> ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflowId}/dispatches`; const body: { ref: string; inputs?: Record<string, string>; } = { ref }; if (inputs && Object.keys(inputs).length > 0) { body.inputs = inputs; } await githubRequest(url, { method: 'POST', body }); // This endpoint doesn't return any data on success return { success: true, message: `Workflow ${workflowId} triggered on ${ref}` }; } // Cancel a workflow run export async function cancelWorkflowRun( owner: string, repo: string, runId: number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/cancel`; await githubRequest(url, { method: 'POST' }); // This endpoint doesn't return any data on success return { success: true, message: `Workflow run ${runId} cancelled` }; } // Rerun a workflow run export async function rerunWorkflowRun( owner: string, repo: string, runId: number ) { owner = validateOwnerName(owner); repo = validateRepositoryName(repo); const url = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/rerun`; await githubRequest(url, { method: 'POST' }); // This endpoint doesn't return any data on success return { success: true, message: `Workflow run ${runId} restarted` }; } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // Use McpServer import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; // Transport for Windsurf import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; // Restore GitHub specific imports import { Octokit } from "@octokit/rest"; import * as actions from './operations/actions.js'; import { GitHubError, isGitHubError, GitHubValidationError, GitHubResourceNotFoundError, GitHubAuthenticationError, GitHubPermissionError, GitHubRateLimitError, GitHubConflictError, GitHubTimeoutError, GitHubNetworkError, } from './common/errors.js'; import { VERSION } from "./common/version.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const logFilePath = path.join(__dirname, '..', 'dist', 'mcp-startup.log'); // Ensure log path points to dist // Simple file logger function logToFile(message: string) { const timestamp = new Date().toISOString(); try { // Ensure dist directory exists before logging const logDir = path.dirname(logFilePath); if (!fs.existsSync(logDir)){ fs.mkdirSync(logDir, { recursive: true }); } fs.appendFileSync(logFilePath, `[${timestamp}] ${message}\n`, 'utf8'); } catch (err: any) { // Use any for broader catch const errorMsg = `[File Log Error] Failed to write to ${logFilePath}: ${err?.message || String(err)}`; console.error(errorMsg); if (err instanceof Error && err.stack) { // Check if error has stack console.error(err.stack); } console.error(`[Original Message] ${message}`); } } // Clear log file on startup // Ensure dist directory exists before logging const logDir = path.dirname(logFilePath); try { if (!fs.existsSync(logDir)){ fs.mkdirSync(logDir, { recursive: true }); } fs.writeFileSync(logFilePath, '', 'utf8'); logToFile('[MCP Server Log] Log file cleared/initialized.'); } catch (err: any) { // Log critical startup error to stderr *only* if file logging setup failed // This is a last resort and might still interfere, but necessary if logging isn't possible. const errorMsg = `[MCP Startup Error] Failed to initialize log file at ${logFilePath}: ${err?.message || String(err)}`; console.error(errorMsg); if (err instanceof Error && err.stack) { console.error(err.stack); } process.exit(1); // Exit if we can't even log } // Add a global handler for uncaught exceptions process.on('uncaughtException', (err, origin) => { let message = `[MCP Server Log] Uncaught Exception. Origin: ${origin}. Error: ${err?.message || String(err)}`; logToFile(message); if (err && err.stack) { logToFile(err.stack); } // Optionally add more context logToFile('[MCP Server Log] Exiting due to uncaught exception.'); process.exit(1); // Exit cleanly }); logToFile('[MCP Server Log] Initializing GitHub Actions MCP Server...'); // Restore auth logic // Allow token via CLI argument `--token=<token>` or fallback to env var let cliToken: string | undefined; for (const arg of process.argv) { if (arg.startsWith('--token=')) { cliToken = arg.substring('--token='.length); break; } } const GITHUB_TOKEN = cliToken || process.env.GITHUB_PERSONAL_ACCESS_TOKEN; // Restore env check if (!GITHUB_TOKEN) { logToFile('FATAL: GITHUB_PERSONAL_ACCESS_TOKEN environment variable is not set.'); process.exit(1); } logToFile('[MCP Server Log] GitHub token found.'); // Restore original log message const octokit = new Octokit({ auth: GITHUB_TOKEN }); logToFile('[MCP Server Log] Octokit initialized.'); const server = new McpServer( { name: "github-actions-mcp-server", version: VERSION, context: { octokit: octokit } } ); // Restore error formatting function function formatGitHubError(error: GitHubError): string { let message = `GitHub API Error: ${error.message}`; if (error instanceof GitHubValidationError) { message = `Validation Error: ${error.message}`; if (error.response) { message += `\nDetails: ${JSON.stringify(error.response)}`; } } else if (error instanceof GitHubResourceNotFoundError) { message = `Not Found: ${error.message}`; } else if (error instanceof GitHubAuthenticationError) { message = `Authentication Failed: ${error.message}`; } else if (error instanceof GitHubPermissionError) { message = `Permission Denied: ${error.message}`; } else if (error instanceof GitHubRateLimitError) { message = `Rate Limit Exceeded: ${error.message}\nResets at: ${error.resetAt.toISOString()}`; } else if (error instanceof GitHubConflictError) { message = `Conflict: ${error.message}`; } else if (error instanceof GitHubTimeoutError) { message = `Timeout: ${error.message}\nTimeout setting: ${error.timeoutMs}ms`; } else if (error instanceof GitHubNetworkError) { message = `Network Error: ${error.message}\nError code: ${error.errorCode}`; } return message; } // Restore ListTools using server.tool() server.tool( "list_workflows", actions.ListWorkflowsSchema.shape, async (request: any) => { logToFile('[MCP Server Log] Received list_workflows request (via server.tool)'); // Args are already parsed by the McpServer using the provided schema const result = await actions.listWorkflows(request.owner, request.repo, request.page, request.perPage); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); // Register other tools using server.tool() server.tool( "get_workflow", actions.GetWorkflowSchema.shape, async (request: any) => { const result = await actions.getWorkflow(request.owner, request.repo, request.workflowId); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "get_workflow_usage", actions.GetWorkflowUsageSchema.shape, async (request: any) => { const result = await actions.getWorkflowUsage(request.owner, request.repo, request.workflowId); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "list_workflow_runs", actions.ListWorkflowRunsSchema.shape, async (request: any) => { const { owner, repo, workflowId, ...options } = request; const result = await actions.listWorkflowRuns(owner, repo, { workflowId, ...options }); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "get_workflow_run", actions.GetWorkflowRunSchema.shape, async (request: any) => { const result = await actions.getWorkflowRun(request.owner, request.repo, request.runId); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "get_workflow_run_jobs", actions.GetWorkflowRunJobsSchema.shape, async (request: any) => { const { owner, repo, runId, filter, page, perPage } = request; const result = await actions.getWorkflowRunJobs(owner, repo, runId, filter, page, perPage); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "trigger_workflow", actions.TriggerWorkflowSchema.shape, async (request: any) => { const { owner, repo, workflowId, ref, inputs } = request; const result = await actions.triggerWorkflow(owner, repo, workflowId, ref, inputs); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "cancel_workflow_run", actions.CancelWorkflowRunSchema.shape, async (request: any) => { const result = await actions.cancelWorkflowRun(request.owner, request.repo, request.runId); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); server.tool( "rerun_workflow", actions.RerunWorkflowSchema.shape, async (request: any) => { const result = await actions.rerunWorkflowRun(request.owner, request.repo, request.runId); return { content: [{ type: "text", text: JSON.stringify(result) }] }; } ); // Wrap server logic in a try/catch for initialization errors try { logToFile('[MCP Server Log] Server initialization complete. Ready for connection.'); // Attach stdio transport so Windsurf can communicate const transport = new StdioServerTransport(); await server.connect(transport); logToFile('[MCP Server Log] Connected via stdio transport.'); } catch (error: any) { // Ensure fatal errors during server setup are logged to the file. logToFile(`[MCP Server Log] FATAL Error during server setup: ${error?.message || String(error)}`); if (error instanceof Error && error.stack) { logToFile(error.stack); } // Do NOT use console.error here as it will interfere with MCP stdio process.exit(1); } // Add other process event handlers // Catch unhandled promise rejections, log them to file, and exit gracefully. process.on('unhandledRejection', (reason, promise) => { // Log unhandled promise rejections to the file. let reasonStr = reason instanceof Error ? reason.message : String(reason); // Including stack trace if available let stack = reason instanceof Error ? `\nStack: ${reason.stack}` : ''; logToFile(`[MCP Server Log] Unhandled Rejection at: ${promise}, reason: ${reasonStr}${stack}`); // Consider exiting depending on the severity or application logic // process.exit(1); // Optionally exit }); process.on('SIGINT', () => { logToFile('[MCP Server Log] Received SIGINT. Exiting gracefully.'); // Add any cleanup logic here process.exit(0); }); process.on('SIGTERM', () => { logToFile('[MCP Server Log] Received SIGTERM. Exiting gracefully.'); // Add any cleanup logic here process.exit(0); }); ```