diff --git a/.gitignore b/.gitignore index ae62dee..1fafa04 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ dist/*.mcpb dist/*.zip .husky/ +docs/ diff --git a/README.md b/README.md index 3f2d3de..d330f2a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Github-MCP-Server-JS

- 🚀 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. ✨ + 🚀 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. ✨

@@ -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. @@ -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. @@ -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: @@ -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) @@ -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 @@ -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 diff --git a/manifest.json b/manifest.json index 810b4c6..4f69fdf 100644 --- a/manifest.json +++ b/manifest.json @@ -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": { @@ -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": { diff --git a/package.json b/package.json index 6cf4961..96589b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/toolsets/repos.ts b/src/toolsets/repos.ts index 451e4af..d652f03 100644 --- a/src/toolsets/repos.ts +++ b/src/toolsets/repos.ts @@ -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'; @@ -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); + } + }, + ); } } diff --git a/test/unit/toolsets/repos.test.ts b/test/unit/toolsets/repos.test.ts index b2233ec..e2c9119 100644 --- a/test/unit/toolsets/repos.test.ts +++ b/test/unit/toolsets/repos.test.ts @@ -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();