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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ dist/
dist/*.mcpb
dist/*.zip
.husky/
docs/
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<h2 align="center">Github-MCP-Server-JS</h2>

<p align="center">
<strong>🚀 A pure Node.js GitHub MCP server for Claude Desktop and any MCP-compatible client — ⚡ 104 REST tools across 16 toolsets, 🚫 no Docker, 🚫 no Go, 🚫 no python. ✨</strong>
<strong>🚀 A pure Node.js GitHub MCP server for Claude Desktop and any MCP-compatible client — ⚡ 105 REST tools across 16 toolsets, 🚫 no Docker, 🚫 no Go, 🚫 no python. ✨</strong>
</p>

<p align="center">
Expand All @@ -21,7 +21,7 @@ Built exclusively on the two **first-party SDKs from the official providers**

## ✨ Highlights

- 🧰 **Complete surface** — 104 tools across 16 toolsets (issues, pull requests, actions, code security, Copilot admin, ProjectsV2, and more).
- 🧰 **Complete surface** — 105 tools across 16 toolsets (issues, pull requests, actions, code security, Copilot admin, ProjectsV2, and more).
- 🔒 **Secure by default** — flip `GITHUB_PERMISSION=read-only` and every mutating tool is never even registered.
- 📦 **Three install channels** — npm (`npx`), Claude Desktop Extension (`.mcpb`), or unpacked extension (`.zip`).
- ✅ **Signed releases** — every version built by GitHub Actions with npm provenance and Sigstore attestation.
Expand All @@ -40,9 +40,9 @@ Five gaps in the current GitHub-MCP landscape:

- 🐳 **The newer official server needs Docker + Go.** [github/github-mcp-server](https://github.com/github/github-mcp-server) ships as a Docker-run Go binary — often blocked by enterprise policy.

- 🧰 **Broader tool coverage.** 104 tools across 16 toolsets — a superset of the archived `server-github` and typical `gh`-CLI wrappers. See [Toolsets](#toolsets) for the full list.
- 🧰 **Broader tool coverage.** 105 tools across 16 toolsets — a superset of the archived `server-github` and typical `gh`-CLI wrappers. See [Toolsets](#toolsets) for the full list.

- 🔒 **Read-only mode is one env var.** `GITHUB_PERMISSION=read-only` registers only the 76 read tools; the 28 mutating operations (`create_issue`, `merge_pull_request`, `star_repo`, `run_workflow`, …) are never exposed to the model. Same binary, one variable, verified by tests.
- 🔒 **Read-only mode is one env var.** `GITHUB_PERMISSION=read-only` registers only the 76 read tools; the 29 mutating operations (`create_issue`, `merge_pull_request`, `star_repo`, `run_workflow`, …) are never exposed to the model. Same binary, one variable, verified by tests.

**`github-mcp-server-js` fills all five** — pure Node 24+ / TypeScript, single-file bundle, `npx`-installable, shipped as both an npm package and a Claude Desktop Extension.

Expand Down Expand Up @@ -81,10 +81,10 @@ Add this entry (create the file with `{ "mcpServers": {} }` if it doesn't exist)
Only `GITHUB_TOKEN` is required — the other three are shown with their defaults so you can see every knob at a glance. Common adjustments:

- **GitHub Enterprise Server:** `"GITHUB_SERVER_URL": "github.mycompany.com"` (bare hostname is fine; the server appends `/api/v3` automatically).
- **Read-only mode:** `"GITHUB_PERMISSION": "read-only"` — the 28 mutating tools (`create_issue`, `merge_pull_request`, `star_repo`, `run_workflow`, …) are never registered.
- **Read-only mode:** `"GITHUB_PERMISSION": "read-only"` — the 29 mutating tools (`create_issue`, `merge_pull_request`, `star_repo`, `run_workflow`, …) are never registered.
- **Verbose logs:** `"LOG_LEVEL": "debug"` prints every request/response summary to stderr; Claude Desktop surfaces stderr in its MCP log. Every line is already JSON — no separate format flag needed.

Restart Claude Desktop. All 104 tools become available in every new chat.
Restart Claude Desktop. All 105 tools become available in every new chat.

The server can also run standalone from any terminal:

Expand All @@ -101,7 +101,7 @@ No config-file editing required; the token is stored in the OS keychain.
2. Open **Claude Desktop → Settings → Extensions**.
3. **Drag the `.mcpb` file** into the Extensions pane.
4. Fill in your `GITHUB_TOKEN` (masked; stored in the macOS / Windows keychain, never in plaintext). The remaining fields carry sensible defaults.
5. Click **Install**. All 104 tools are immediately available.
5. Click **Install**. All 105 tools are immediately available.

### 🛠️ Path 3 — Claude Desktop unpacked extension (`.zip`, developer mode)

Expand Down Expand Up @@ -136,12 +136,12 @@ Configuration is entirely via environment variables. Claude Desktop sets them fr
|---|---|---|---|
| `GITHUB_TOKEN` | Yes | — | Personal access token used for all GitHub API calls. |
| `GITHUB_SERVER_URL` | No | `github.com` | GitHub host — bare hostname or full API base URL. Set this for GitHub Enterprise Server. |
| `GITHUB_PERMISSION` | No | `read-write` | `read-only` (registers 76 read tools) or `read-write` (all 104). |
| `GITHUB_PERMISSION` | No | `read-write` | `read-only` (registers 76 read tools) or `read-write` (all 105). |
| `LOG_LEVEL` | No | `info` | `debug`, `info`, or `error`. Every tool call logs `tool_call` / `tool_ok` / `tool_error` as one JSON object per stderr line, plus an MCP `notifications/message` for the connected client. |

## 🧰 Toolsets

All 16 toolsets are shipped, exposing **104 tools** total. Write tools are only registered when `GITHUB_PERMISSION=read-write` (the default); `read-only` mode registers the read tools alone. The **Access** column indicates: **R** = registered in read-only mode; **W** = registered only in read-write mode.
All 16 toolsets are shipped, exposing **105 tools** total. Write tools are only registered when `GITHUB_PERMISSION=read-write` (the default); `read-only` mode registers the read tools alone. The **Access** column indicates: **R** = registered in read-only mode; **W** = registered only in read-write mode.

### 📁 `repos` — repositories, branches, commits, tags, file contents

Expand All @@ -155,6 +155,7 @@ All 16 toolsets are shipped, exposing **104 tools** total. Write tools are only
| `get_commit` | R | Get a single commit in a repository. |
| `list_tags` | R | List tags in a repository. |
| `create_or_update_file` | W | Create a new file or update an existing file in a repository. |
| `create_branch` | W | Create a new branch from an existing branch or commit SHA. |

### 🐛 `issues` — issue CRUD, comments, labels, conversation locking

Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"name": "github-mcp-server-js",
"display_name": "GitHub MCP Server (JS)",
"version": "0.1.5",
"description": "MCP server exposing 104 GitHub REST tools across 16 toolsets, built on octokit.js.",
"description": "MCP server exposing 105 GitHub REST tools across 16 toolsets, built on octokit.js.",
"author": { "name": "Qunfei Wu" },
"homepage": "https://github.com/wuqunfei/github-mcp-server-js",
"repository": {
Expand Down Expand Up @@ -42,7 +42,7 @@
"github_permission": {
"type": "string",
"title": "Permission (read-only or read-write)",
"description": "read-only registers only read tools; read-write registers all 104 tools.",
"description": "read-only registers only read tools; read-write registers all 105 tools.",
"default": "read-write"
},
"log_level": {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "github-mcp-server-js",
"version": "0.1.5",
"description": "A GitHub MCP server built on octokit.js and the MCP TypeScript SDK v2 — 104 tools across 16 toolsets, packaged as npm and .mcpb Claude Desktop Extension.",
"description": "A GitHub MCP server built on octokit.js and the MCP TypeScript SDK v2 — 105 tools across 16 toolsets, packaged as npm and .mcpb Claude Desktop Extension.",
"type": "module",
"license": "MIT",
"author": "Qunfei Wu",
Expand Down
39 changes: 39 additions & 0 deletions src/toolsets/repos.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { McpServer } from '@modelcontextprotocol/server';
import type { RequestError } from '@octokit/request-error';
import type { Octokit } from 'octokit';
import { z } from 'zod';
import { ownerRepoSchema, paginationSchema, toToolResult, toToolError } from './common.js';
Expand Down Expand Up @@ -184,5 +185,43 @@ export function registerReposTools(
}
},
);

server.registerTool(
'create_branch',
{
description:
'Create a new branch in a GitHub repository from an existing branch or commit SHA. Docs: https://docs.github.com/en/rest/git/refs#create-a-reference',
inputSchema: z.object({
...ownerRepoSchema,
branch: z.string().describe('Name for the new branch (without the refs/heads/ prefix)'),
from: z.string().describe('Source branch name or commit SHA to create the new branch from'),
}),
},
async ({ owner, repo, branch, from }) => {
try {
let sha: string;
try {
const branchResponse = await octokit.rest.repos.getBranch({ owner, repo, branch: from });
sha = branchResponse.data.commit.sha;
} catch (error) {
const reqError = error as RequestError;
if (reqError.status !== 404) {
throw error;
}
sha = from;
}

const response = await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branch}`,
sha,
});
return toToolResult(response.data);
} catch (error) {
return toToolError(error);
}
},
);
}
}
91 changes: 91 additions & 0 deletions test/unit/toolsets/repos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,97 @@ describe('registerReposTools', () => {
expect(tools.map((tool) => tool.name)).toContain('create_or_update_file');
});

it('creates a branch by resolving "from" as a branch name first', async () => {
nock('https://api.github.com')
.get('/repos/octocat/hello-world/branches/main')
.reply(200, { name: 'main', commit: { sha: 'abc123' } });
nock('https://api.github.com')
.post('/repos/octocat/hello-world/git/refs', {
ref: 'refs/heads/feature-x',
sha: 'abc123',
})
.reply(201, { ref: 'refs/heads/feature-x', object: { sha: 'abc123' } });

const client = await connectedClient(registerReposTools, 'read-write');
const result = await client.callTool({
name: 'create_branch',
arguments: { owner: 'octocat', repo: 'hello-world', branch: 'feature-x', from: 'main' },
});

expect(result.isError).toBeFalsy();
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? '';
expect(JSON.parse(text)).toMatchObject({ ref: 'refs/heads/feature-x' });
});

it('falls back to treating "from" as a raw commit SHA when the branch lookup 404s', async () => {
nock('https://api.github.com')
.get('/repos/octocat/hello-world/branches/deadbeef')
.reply(404, { message: 'Branch not found' });
nock('https://api.github.com')
.post('/repos/octocat/hello-world/git/refs', {
ref: 'refs/heads/feature-x',
sha: 'deadbeef',
})
.reply(201, { ref: 'refs/heads/feature-x', object: { sha: 'deadbeef' } });

const client = await connectedClient(registerReposTools, 'read-write');
const result = await client.callTool({
name: 'create_branch',
arguments: { owner: 'octocat', repo: 'hello-world', branch: 'feature-x', from: 'deadbeef' },
});

expect(result.isError).toBeFalsy();
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? '';
expect(JSON.parse(text)).toMatchObject({ ref: 'refs/heads/feature-x' });
});

it('propagates a non-404 error from the branch lookup without falling back to SHA treatment', async () => {
nock('https://api.github.com')
.get('/repos/octocat/hello-world/branches/main')
.reply(500, { message: 'Internal Server Error' });

const client = await connectedClient(registerReposTools, 'read-write');
const result = await client.callTool({
name: 'create_branch',
arguments: { owner: 'octocat', repo: 'hello-world', branch: 'feature-x', from: 'main' },
});

expect(result.isError).toBe(true);
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? '';
expect(text).toContain('Internal Server Error');
});

it('returns a tool error when createRef fails, e.g. branch already exists', async () => {
nock('https://api.github.com')
.get('/repos/octocat/hello-world/branches/main')
.reply(200, { name: 'main', commit: { sha: 'abc123' } });
nock('https://api.github.com')
.post('/repos/octocat/hello-world/git/refs')
.reply(422, { message: 'Reference already exists' });

const client = await connectedClient(registerReposTools, 'read-write');
const result = await client.callTool({
name: 'create_branch',
arguments: { owner: 'octocat', repo: 'hello-world', branch: 'feature-x', from: 'main' },
});

expect(result.isError).toBe(true);
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? '';
expect(text).toContain('Reference already exists');
});

it('does not register create_branch in read-only mode', async () => {
const client = await connectedClient(registerReposTools, 'read-only');
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).not.toContain('create_branch');
});

it('registers create_branch in read-write mode', async () => {
const client = await connectedClient(registerReposTools, 'read-write');
const { tools } = await client.listTools();
expect(tools.map((tool) => tool.name)).toContain('create_branch');
});

it('registers all 7 read-only tools regardless of permission', async () => {
const client = await connectedClient(registerReposTools, 'read-only');
const { tools } = await client.listTools();
Expand Down
Loading