Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"mcpServers": {
"github-actions": {
"command": "deno",
"args": [
"run",
"--allow-net",
"--allow-env",
"--allow-run=gh",
"./main.ts"
],
"env": {
"MIN_RELEASE_AGE_DAYS": "7"
}
}
}
}
56 changes: 34 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ Add to your Claude Desktop configuration (`claude_desktop_config.json`):
"run",
"--allow-net",
"--allow-env",
"--allow-run=gh",
"/path/to/mcp-github-actions/main.ts"
],
"env": {
Expand All @@ -50,7 +51,7 @@ Add to your Claude Desktop configuration (`claude_desktop_config.json`):
### Setup with Claude Code CLI

```bash
claude mcp add github-actions -- deno run --allow-net --allow-env /path/to/mcp-github-actions/main.ts
claude mcp add github-actions -- deno run --allow-net --allow-env --allow-run=gh /path/to/mcp-github-actions/main.ts
```

### Setup with Docker
Expand Down Expand Up @@ -92,24 +93,6 @@ docker run --rm -i -e GITHUB_TOKEN ghcr.io/tripletex/mcp-github-action:latest
}
```

**MCP Gateway configuration:**

```yaml
mcp_services:
- name: "github-actions"
alias: "github-actions"
type: "stdio"
command:
- docker
- run
- --rm
- -i
- -e
- GITHUB_TOKEN
- ghcr.io/tripletex/mcp-github-action:latest
timeout: 30
```

## Usage

Once configured, ask Claude to look up GitHub Actions:
Expand Down Expand Up @@ -150,18 +133,44 @@ Security Notes:

## Authentication

### Without Token (Default)
The service supports multiple authentication methods, checked in the following
order:

1. **Org-specific tokens** (`GITHUB_TOKEN_<ORG>`) - For multi-org scenarios
2. **Environment variable** (`GITHUB_TOKEN`) - Explicit token configuration
3. **GitHub CLI** (`gh auth token`) - Automatic token from logged-in `gh` CLI
4. **Unauthenticated** - Public repositories only with rate limits

### Without Token (Unauthenticated)

- Works for public repositories only
- Rate limit: 60 requests/hour
- No setup required

### With GitHub CLI (Recommended for Development)

If you have the [GitHub CLI](https://cli.github.com/) installed and
authenticated:

```bash
gh auth login
```

The service will automatically use your `gh` CLI token when no explicit token is
configured. This is convenient for local development and doesn't require
managing separate tokens.

**Permissions note:** The service needs `--allow-run=gh` permission to execute
the `gh` command.

### With Token (Recommended)
### With Environment Token

Set the `GITHUB_TOKEN` environment variable:

- Works for **private repositories**
- Rate limit: 5,000 requests/hour
- Required for organization private actions
- Recommended for production deployments

### Multi-Organization Support

Expand All @@ -180,7 +189,8 @@ GITHUB_TOKEN=ghp_zzz... # Fallback for public repos

1. Org-specific token (`GITHUB_TOKEN_<ORG>`)
2. Fallback token (`GITHUB_TOKEN`)
3. Unauthenticated (public repos only)
3. GitHub CLI token (`gh auth token`)
4. Unauthenticated (public repos only)

**Supported token types and required permissions:**

Expand All @@ -205,6 +215,7 @@ GITHUB_TOKEN=ghp_zzz... # Fallback for public repos
"run",
"--allow-net",
"--allow-env",
"--allow-run=gh",
"/path/to/mcp-github-actions/main.ts"
],
"env": {
Expand Down Expand Up @@ -264,6 +275,7 @@ the latest release's age.
"run",
"--allow-net",
"--allow-env",
"--allow-run=gh",
"/path/to/mcp-github-actions/main.ts"
],
"env": {
Expand Down
6 changes: 3 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"version": "1.0.0",
"exports": "./main.ts",
"tasks": {
"start": "deno run --allow-net --allow-env main.ts",
"dev": "deno run --watch --allow-net --allow-env main.ts",
"compile": "deno compile --allow-net --allow-env -o github-actions-mcp main.ts",
"start": "deno run --allow-net --allow-env --allow-run=gh main.ts",
"dev": "deno run --watch --allow-net --allow-env --allow-run=gh main.ts",
"compile": "deno compile --allow-net --allow-env --allow-run=gh -o github-actions-mcp main.ts",
"check": "deno check main.ts",
"lint": "deno lint",
"fmt": "deno fmt"
Expand Down
34 changes: 26 additions & 8 deletions src/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,38 @@ const GITHUB_API_BASE = "https://api.github.com";

export class GitHubClient {
private token: string | undefined;
private tokenResolved = false;
private tokenPromise: Promise<void> | null = null;
private rateLimitInfo: RateLimitInfo | null = null;
private org: string | undefined;

/**
* Create a GitHub client
* @param org - Organization name for token resolution (optional)
* @param token - Explicit token override (optional, bypasses resolver)
*/
constructor(org?: string, token?: string) {
constructor(org?: string) {
this.org = org;
if (token) {
this.token = token;
} else if (org) {
this.token = tokenResolver.resolveToken(org);
} else {
this.token = Deno.env.get("GITHUB_TOKEN");
}

/**
* Ensure token is resolved before making requests
*/
private async ensureToken(): Promise<void> {
if (this.tokenResolved) {
return;
}

if (this.tokenPromise) {
return this.tokenPromise;
}

this.tokenPromise = (async () => {
this.token = await tokenResolver.resolveToken(this.org ?? "");
this.tokenResolved = true;
this.tokenPromise = null;
})();

return this.tokenPromise;
}

/**
Expand Down Expand Up @@ -84,6 +99,9 @@ export class GitHubClient {
}

private async fetch<T>(url: string): Promise<T> {
// Ensure token is resolved before making request
await this.ensureToken();

const response = await fetch(url, {
headers: this.getHeaders(),
});
Expand Down
71 changes: 64 additions & 7 deletions src/github/token-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
* Supports org-specific tokens via environment variables:
* - GITHUB_TOKEN_<ORG_NAME> for specific orgs (e.g., GITHUB_TOKEN_MY_ORG)
* - GITHUB_TOKEN as fallback
* - gh CLI via `gh auth token` as final fallback
*
* Org names are normalized: hyphens converted to underscores, then uppercased
* Example: "My-Org" -> GITHUB_TOKEN_MY_ORG
Expand All @@ -28,26 +29,82 @@ export class TokenResolver {
return `GITHUB_TOKEN_${this.normalizeOrgName(org)}`;
}

/**
* Try to get a token from gh CLI
* Returns undefined if gh is not available or auth token command fails
*/
private async getGhCliToken(): Promise<string | undefined> {
try {
// Check if gh command exists
const checkCommand = new Deno.Command("gh", {
args: ["--version"],
stdout: "null",
stderr: "null",
});

const checkResult = await checkCommand.output();
if (!checkResult.success) {
return undefined;
}

// Try to get the auth token
const authCommand = new Deno.Command("gh", {
args: ["auth", "token"],
stdout: "piped",
stderr: "null",
});

const authResult = await authCommand.output();
if (!authResult.success) {
return undefined;
}

const token = new TextDecoder().decode(authResult.stdout).trim();
return token.length > 0 ? token : undefined;
} catch (_error) {
// gh command not found or other error
return undefined;
}
}

/**
* Resolve the appropriate token for a given organization
* Returns undefined if no token is found (will use unauthenticated requests)
*
* Resolution order:
* - For specific org: GITHUB_TOKEN_<ORG> -> falls back to default token
* - For default (org=""): GITHUB_TOKEN -> gh auth token
*/
resolveToken(org: string): string | undefined {
async resolveToken(org: string): Promise<string | undefined> {
// Check cache first
if (this.tokenCache.has(org)) {
return this.tokenCache.get(org);
}

// Try org-specific token first
const orgEnvVar = this.getEnvVarName(org);
let token = Deno.env.get(orgEnvVar);
let token: string | undefined;

// Fall back to default GITHUB_TOKEN
// Try org-specific token first if org is specified
if (org !== "") {
token = Deno.env.get(this.getEnvVarName(org));
}

// If no org-specific token, try default token sources
if (!token) {
token = Deno.env.get("GITHUB_TOKEN");
// Check if default token is already cached
if (this.tokenCache.has("")) {
token = this.tokenCache.get("");
} else {
// Resolve and cache default token
token = Deno.env.get("GITHUB_TOKEN");
if (!token) {
token = await this.getGhCliToken();
}
// Cache default token for future use
this.tokenCache.set("", token);
}
}

// Cache the result
// Cache the result for this org
this.tokenCache.set(org, token);

return token;
Expand Down