github-mcp-connector 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- github_mcp/__init__.py +10 -0
- github_mcp/__main__.py +6 -0
- github_mcp/client.py +140 -0
- github_mcp/config.py +62 -0
- github_mcp/server.py +477 -0
- github_mcp_connector-0.1.0.dist-info/METADATA +316 -0
- github_mcp_connector-0.1.0.dist-info/RECORD +11 -0
- github_mcp_connector-0.1.0.dist-info/WHEEL +5 -0
- github_mcp_connector-0.1.0.dist-info/entry_points.txt +3 -0
- github_mcp_connector-0.1.0.dist-info/licenses/LICENSE +21 -0
- github_mcp_connector-0.1.0.dist-info/top_level.txt +1 -0
github_mcp/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""A Model Context Protocol (MCP) connector for GitHub.
|
|
2
|
+
|
|
3
|
+
Exposes a focused set of GitHub REST API operations as MCP tools so that
|
|
4
|
+
Claude (Desktop, Code, or any MCP client) can read repositories, issues,
|
|
5
|
+
pull requests, commits, and code, and optionally open issues and comments.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
github_mcp/__main__.py
ADDED
github_mcp/client.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""A thin async wrapper around the GitHub REST API.
|
|
2
|
+
|
|
3
|
+
The wrapper is intentionally small: it handles authentication, the standard
|
|
4
|
+
headers GitHub expects, error translation, and a tiny bit of pagination. Each
|
|
5
|
+
MCP tool in :mod:`github_mcp.server` builds on top of these primitives.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
|
|
16
|
+
GITHUB_ACCEPT = "application/vnd.github+json"
|
|
17
|
+
GITHUB_API_VERSION = "2022-11-28"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GitHubError(RuntimeError):
|
|
21
|
+
"""Raised when the GitHub API returns an error response.
|
|
22
|
+
|
|
23
|
+
The message is formatted to be useful when surfaced back to an LLM: it
|
|
24
|
+
includes the HTTP status, the requested method/path, and GitHub's own
|
|
25
|
+
error message when one is available.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status_code: int, method: str, url: str, detail: str) -> None:
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.method = method
|
|
31
|
+
self.url = url
|
|
32
|
+
self.detail = detail
|
|
33
|
+
super().__init__(f"GitHub API {method} {url} failed ({status_code}): {detail}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GitHubClient:
|
|
37
|
+
"""Minimal async GitHub REST client.
|
|
38
|
+
|
|
39
|
+
Use as an async context manager so the underlying connection pool is
|
|
40
|
+
closed cleanly::
|
|
41
|
+
|
|
42
|
+
async with GitHubClient(config) as gh:
|
|
43
|
+
user = await gh.get("/user")
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self, config: Config, *, transport: httpx.BaseTransport | None = None
|
|
48
|
+
) -> None:
|
|
49
|
+
self._config = config
|
|
50
|
+
headers = {
|
|
51
|
+
"Accept": GITHUB_ACCEPT,
|
|
52
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
|
53
|
+
"User-Agent": config.user_agent,
|
|
54
|
+
}
|
|
55
|
+
if config.token:
|
|
56
|
+
headers["Authorization"] = f"Bearer {config.token}"
|
|
57
|
+
# `transport` is an injection seam used by tests to mock the API; in
|
|
58
|
+
# normal operation it is None and httpx uses its default transport.
|
|
59
|
+
self._client = httpx.AsyncClient(
|
|
60
|
+
base_url=config.api_url,
|
|
61
|
+
headers=headers,
|
|
62
|
+
timeout=config.timeout,
|
|
63
|
+
follow_redirects=True,
|
|
64
|
+
transport=transport,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def __aenter__(self) -> "GitHubClient":
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
71
|
+
await self.aclose()
|
|
72
|
+
|
|
73
|
+
async def aclose(self) -> None:
|
|
74
|
+
await self._client.aclose()
|
|
75
|
+
|
|
76
|
+
# -- core request helpers -------------------------------------------------
|
|
77
|
+
|
|
78
|
+
async def _request(
|
|
79
|
+
self,
|
|
80
|
+
method: str,
|
|
81
|
+
path: str,
|
|
82
|
+
*,
|
|
83
|
+
params: dict[str, Any] | None = None,
|
|
84
|
+
json: dict[str, Any] | None = None,
|
|
85
|
+
accept: str | None = None,
|
|
86
|
+
) -> httpx.Response:
|
|
87
|
+
headers = {"Accept": accept} if accept else None
|
|
88
|
+
clean_params = (
|
|
89
|
+
{k: v for k, v in params.items() if v is not None} if params else None
|
|
90
|
+
)
|
|
91
|
+
try:
|
|
92
|
+
response = await self._client.request(
|
|
93
|
+
method, path, params=clean_params, json=json, headers=headers
|
|
94
|
+
)
|
|
95
|
+
except httpx.HTTPError as exc: # network/timeout errors
|
|
96
|
+
raise GitHubError(0, method, path, f"request error: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
if response.status_code >= 400:
|
|
99
|
+
raise GitHubError(
|
|
100
|
+
response.status_code,
|
|
101
|
+
method,
|
|
102
|
+
str(response.request.url),
|
|
103
|
+
_extract_error_detail(response),
|
|
104
|
+
)
|
|
105
|
+
return response
|
|
106
|
+
|
|
107
|
+
async def get(
|
|
108
|
+
self, path: str, *, params: dict[str, Any] | None = None
|
|
109
|
+
) -> Any:
|
|
110
|
+
response = await self._request("GET", path, params=params)
|
|
111
|
+
if not response.content:
|
|
112
|
+
return None
|
|
113
|
+
return response.json()
|
|
114
|
+
|
|
115
|
+
async def get_raw(
|
|
116
|
+
self, path: str, *, params: dict[str, Any] | None = None, accept: str
|
|
117
|
+
) -> str:
|
|
118
|
+
response = await self._request("GET", path, params=params, accept=accept)
|
|
119
|
+
return response.text
|
|
120
|
+
|
|
121
|
+
async def post(self, path: str, *, json: dict[str, Any]) -> Any:
|
|
122
|
+
response = await self._request("POST", path, json=json)
|
|
123
|
+
if not response.content:
|
|
124
|
+
return None
|
|
125
|
+
return response.json()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _extract_error_detail(response: httpx.Response) -> str:
|
|
129
|
+
"""Pull the most useful human-readable error message out of a response."""
|
|
130
|
+
try:
|
|
131
|
+
payload = response.json()
|
|
132
|
+
except ValueError:
|
|
133
|
+
return response.text[:500] or response.reason_phrase
|
|
134
|
+
if isinstance(payload, dict):
|
|
135
|
+
message = payload.get("message", "")
|
|
136
|
+
errors = payload.get("errors")
|
|
137
|
+
if errors:
|
|
138
|
+
return f"{message}: {errors}"
|
|
139
|
+
return message or str(payload)
|
|
140
|
+
return str(payload)
|
github_mcp/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Configuration for the GitHub MCP connector.
|
|
2
|
+
|
|
3
|
+
All configuration is read from environment variables so the server can be
|
|
4
|
+
launched by an MCP client (Claude Desktop/Code) with nothing but an ``env``
|
|
5
|
+
block. See ``.env.example`` for the full list.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://api.github.com"
|
|
14
|
+
|
|
15
|
+
# Environment variable names, in priority order, that may hold the token.
|
|
16
|
+
_TOKEN_ENV_VARS = (
|
|
17
|
+
"GITHUB_TOKEN",
|
|
18
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
19
|
+
"GH_TOKEN",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_token() -> str | None:
|
|
24
|
+
for name in _TOKEN_ENV_VARS:
|
|
25
|
+
value = os.environ.get(name)
|
|
26
|
+
if value:
|
|
27
|
+
return value.strip()
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_bool(name: str, default: bool = False) -> bool:
|
|
32
|
+
raw = os.environ.get(name)
|
|
33
|
+
if raw is None:
|
|
34
|
+
return default
|
|
35
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Config:
|
|
40
|
+
"""Resolved runtime configuration."""
|
|
41
|
+
|
|
42
|
+
token: str | None
|
|
43
|
+
api_url: str
|
|
44
|
+
read_only: bool
|
|
45
|
+
timeout: float
|
|
46
|
+
user_agent: str
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_env(cls) -> "Config":
|
|
50
|
+
api_url = os.environ.get("GITHUB_API_URL", DEFAULT_API_URL).rstrip("/")
|
|
51
|
+
timeout_raw = os.environ.get("GITHUB_MCP_TIMEOUT", "30")
|
|
52
|
+
try:
|
|
53
|
+
timeout = float(timeout_raw)
|
|
54
|
+
except ValueError:
|
|
55
|
+
timeout = 30.0
|
|
56
|
+
return cls(
|
|
57
|
+
token=_read_token(),
|
|
58
|
+
api_url=api_url,
|
|
59
|
+
read_only=_read_bool("GITHUB_MCP_READ_ONLY", default=False),
|
|
60
|
+
timeout=timeout,
|
|
61
|
+
user_agent=os.environ.get("GITHUB_MCP_USER_AGENT", "github-mcp-connector"),
|
|
62
|
+
)
|
github_mcp/server.py
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""The GitHub MCP connector server.
|
|
2
|
+
|
|
3
|
+
Defines the FastMCP server and the GitHub tools it exposes. Run it with::
|
|
4
|
+
|
|
5
|
+
python -m github_mcp # stdio transport (Claude Desktop/Code)
|
|
6
|
+
python -m github_mcp --http # streamable HTTP transport
|
|
7
|
+
|
|
8
|
+
Write tools (creating issues, commenting) are disabled when the environment
|
|
9
|
+
variable ``GITHUB_MCP_READ_ONLY`` is truthy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from mcp.server.fastmcp import FastMCP
|
|
18
|
+
|
|
19
|
+
from .client import GitHubClient, GitHubError
|
|
20
|
+
from .config import Config
|
|
21
|
+
|
|
22
|
+
config = Config.from_env()
|
|
23
|
+
|
|
24
|
+
INSTRUCTIONS = """\
|
|
25
|
+
This server connects Claude to GitHub via the REST API.
|
|
26
|
+
|
|
27
|
+
Use it to look up repositories, read files and commit history, triage issues,
|
|
28
|
+
and review pull requests. Always pass `owner` and `repo` separately (for the
|
|
29
|
+
repo `octocat/hello-world`, owner is `octocat` and repo is `hello-world`).
|
|
30
|
+
|
|
31
|
+
For free-text discovery use the `search_*` tools, which accept GitHub search
|
|
32
|
+
qualifiers (e.g. `repo:owner/name`, `is:open`, `label:bug`, `language:python`).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
mcp = FastMCP("github", instructions=INSTRUCTIONS)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# helpers
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _require_token() -> None:
|
|
44
|
+
if not config.token:
|
|
45
|
+
raise GitHubError(
|
|
46
|
+
401,
|
|
47
|
+
"AUTH",
|
|
48
|
+
config.api_url,
|
|
49
|
+
"No GitHub token configured. Set GITHUB_TOKEN (or "
|
|
50
|
+
"GITHUB_PERSONAL_ACCESS_TOKEN) in the connector environment.",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _require_write() -> None:
|
|
55
|
+
if config.read_only:
|
|
56
|
+
raise GitHubError(
|
|
57
|
+
403,
|
|
58
|
+
"WRITE",
|
|
59
|
+
config.api_url,
|
|
60
|
+
"This connector is running in read-only mode "
|
|
61
|
+
"(GITHUB_MCP_READ_ONLY is set); write operations are disabled.",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _summarize_repo(repo: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
return {
|
|
67
|
+
"full_name": repo.get("full_name"),
|
|
68
|
+
"description": repo.get("description"),
|
|
69
|
+
"private": repo.get("private"),
|
|
70
|
+
"fork": repo.get("fork"),
|
|
71
|
+
"default_branch": repo.get("default_branch"),
|
|
72
|
+
"language": repo.get("language"),
|
|
73
|
+
"stars": repo.get("stargazers_count"),
|
|
74
|
+
"forks": repo.get("forks_count"),
|
|
75
|
+
"open_issues": repo.get("open_issues_count"),
|
|
76
|
+
"html_url": repo.get("html_url"),
|
|
77
|
+
"updated_at": repo.get("updated_at"),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _summarize_issue(issue: dict[str, Any]) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"number": issue.get("number"),
|
|
84
|
+
"title": issue.get("title"),
|
|
85
|
+
"state": issue.get("state"),
|
|
86
|
+
"user": (issue.get("user") or {}).get("login"),
|
|
87
|
+
"labels": [label.get("name") for label in issue.get("labels", [])],
|
|
88
|
+
"comments": issue.get("comments"),
|
|
89
|
+
"is_pull_request": "pull_request" in issue,
|
|
90
|
+
"created_at": issue.get("created_at"),
|
|
91
|
+
"updated_at": issue.get("updated_at"),
|
|
92
|
+
"html_url": issue.get("html_url"),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _summarize_pull(pull: dict[str, Any]) -> dict[str, Any]:
|
|
97
|
+
return {
|
|
98
|
+
"number": pull.get("number"),
|
|
99
|
+
"title": pull.get("title"),
|
|
100
|
+
"state": pull.get("state"),
|
|
101
|
+
"draft": pull.get("draft"),
|
|
102
|
+
"user": (pull.get("user") or {}).get("login"),
|
|
103
|
+
"head": (pull.get("head") or {}).get("ref"),
|
|
104
|
+
"base": (pull.get("base") or {}).get("ref"),
|
|
105
|
+
"merged": pull.get("merged"),
|
|
106
|
+
"mergeable": pull.get("mergeable"),
|
|
107
|
+
"comments": pull.get("comments"),
|
|
108
|
+
"review_comments": pull.get("review_comments"),
|
|
109
|
+
"changed_files": pull.get("changed_files"),
|
|
110
|
+
"additions": pull.get("additions"),
|
|
111
|
+
"deletions": pull.get("deletions"),
|
|
112
|
+
"created_at": pull.get("created_at"),
|
|
113
|
+
"html_url": pull.get("html_url"),
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _summarize_commit(commit: dict[str, Any]) -> dict[str, Any]:
|
|
118
|
+
detail = commit.get("commit", {})
|
|
119
|
+
author = detail.get("author", {})
|
|
120
|
+
return {
|
|
121
|
+
"sha": commit.get("sha"),
|
|
122
|
+
"message": detail.get("message"),
|
|
123
|
+
"author": author.get("name"),
|
|
124
|
+
"date": author.get("date"),
|
|
125
|
+
"html_url": commit.get("html_url"),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# read tools
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool()
|
|
135
|
+
async def get_authenticated_user() -> dict[str, Any]:
|
|
136
|
+
"""Return the GitHub account the connector's token authenticates as.
|
|
137
|
+
|
|
138
|
+
Useful as a connection/health check and to confirm which identity and
|
|
139
|
+
permissions the connector is operating with.
|
|
140
|
+
"""
|
|
141
|
+
_require_token()
|
|
142
|
+
async with GitHubClient(config) as gh:
|
|
143
|
+
user = await gh.get("/user")
|
|
144
|
+
return {
|
|
145
|
+
"login": user.get("login"),
|
|
146
|
+
"name": user.get("name"),
|
|
147
|
+
"type": user.get("type"),
|
|
148
|
+
"public_repos": user.get("public_repos"),
|
|
149
|
+
"html_url": user.get("html_url"),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@mcp.tool()
|
|
154
|
+
async def search_repositories(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
155
|
+
"""Search GitHub for repositories matching a query.
|
|
156
|
+
|
|
157
|
+
`query` accepts GitHub search syntax, e.g. `language:python topic:mcp` or
|
|
158
|
+
`org:anthropics stars:>100`. Returns up to `limit` (max 50) repositories.
|
|
159
|
+
"""
|
|
160
|
+
_require_token()
|
|
161
|
+
limit = max(1, min(limit, 50))
|
|
162
|
+
async with GitHubClient(config) as gh:
|
|
163
|
+
result = await gh.get(
|
|
164
|
+
"/search/repositories", params={"q": query, "per_page": limit}
|
|
165
|
+
)
|
|
166
|
+
return [_summarize_repo(item) for item in result.get("items", [])]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@mcp.tool()
|
|
170
|
+
async def get_repository(owner: str, repo: str) -> dict[str, Any]:
|
|
171
|
+
"""Get metadata for a single repository (description, default branch, stars, etc.)."""
|
|
172
|
+
_require_token()
|
|
173
|
+
async with GitHubClient(config) as gh:
|
|
174
|
+
data = await gh.get(f"/repos/{owner}/{repo}")
|
|
175
|
+
return _summarize_repo(data)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@mcp.tool()
|
|
179
|
+
async def list_branches(owner: str, repo: str, limit: int = 30) -> list[dict[str, Any]]:
|
|
180
|
+
"""List branches in a repository, with the head commit SHA for each."""
|
|
181
|
+
_require_token()
|
|
182
|
+
limit = max(1, min(limit, 100))
|
|
183
|
+
async with GitHubClient(config) as gh:
|
|
184
|
+
branches = await gh.get(
|
|
185
|
+
f"/repos/{owner}/{repo}/branches", params={"per_page": limit}
|
|
186
|
+
)
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
"name": branch.get("name"),
|
|
190
|
+
"sha": (branch.get("commit") or {}).get("sha"),
|
|
191
|
+
"protected": branch.get("protected"),
|
|
192
|
+
}
|
|
193
|
+
for branch in branches
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@mcp.tool()
|
|
198
|
+
async def get_file_contents(
|
|
199
|
+
owner: str, repo: str, path: str, ref: str | None = None
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""Read a file or list a directory in a repository.
|
|
202
|
+
|
|
203
|
+
`path` is the path within the repo (e.g. `src/app.py`). `ref` is an
|
|
204
|
+
optional branch, tag, or commit SHA (defaults to the default branch). For a
|
|
205
|
+
file the decoded text content is returned; for a directory a listing is
|
|
206
|
+
returned instead.
|
|
207
|
+
"""
|
|
208
|
+
_require_token()
|
|
209
|
+
async with GitHubClient(config) as gh:
|
|
210
|
+
data = await gh.get(
|
|
211
|
+
f"/repos/{owner}/{repo}/contents/{path}", params={"ref": ref}
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if isinstance(data, list):
|
|
215
|
+
return {
|
|
216
|
+
"type": "directory",
|
|
217
|
+
"path": path,
|
|
218
|
+
"entries": [
|
|
219
|
+
{"name": e.get("name"), "path": e.get("path"), "type": e.get("type")}
|
|
220
|
+
for e in data
|
|
221
|
+
],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
encoding = data.get("encoding")
|
|
225
|
+
raw = data.get("content", "")
|
|
226
|
+
if encoding == "base64":
|
|
227
|
+
try:
|
|
228
|
+
text = base64.b64decode(raw).decode("utf-8")
|
|
229
|
+
except UnicodeDecodeError:
|
|
230
|
+
return {
|
|
231
|
+
"type": "file",
|
|
232
|
+
"path": data.get("path"),
|
|
233
|
+
"size": data.get("size"),
|
|
234
|
+
"binary": True,
|
|
235
|
+
"message": "File is binary and cannot be displayed as text.",
|
|
236
|
+
"html_url": data.get("html_url"),
|
|
237
|
+
}
|
|
238
|
+
else:
|
|
239
|
+
text = raw
|
|
240
|
+
return {
|
|
241
|
+
"type": "file",
|
|
242
|
+
"path": data.get("path"),
|
|
243
|
+
"size": data.get("size"),
|
|
244
|
+
"sha": data.get("sha"),
|
|
245
|
+
"content": text,
|
|
246
|
+
"html_url": data.get("html_url"),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@mcp.tool()
|
|
251
|
+
async def list_commits(
|
|
252
|
+
owner: str,
|
|
253
|
+
repo: str,
|
|
254
|
+
sha: str | None = None,
|
|
255
|
+
path: str | None = None,
|
|
256
|
+
limit: int = 20,
|
|
257
|
+
) -> list[dict[str, Any]]:
|
|
258
|
+
"""List recent commits on a repository.
|
|
259
|
+
|
|
260
|
+
Optionally narrow to a branch/SHA via `sha` or to commits touching a single
|
|
261
|
+
file via `path`. Returns up to `limit` (max 50) commits.
|
|
262
|
+
"""
|
|
263
|
+
_require_token()
|
|
264
|
+
limit = max(1, min(limit, 50))
|
|
265
|
+
async with GitHubClient(config) as gh:
|
|
266
|
+
commits = await gh.get(
|
|
267
|
+
f"/repos/{owner}/{repo}/commits",
|
|
268
|
+
params={"sha": sha, "path": path, "per_page": limit},
|
|
269
|
+
)
|
|
270
|
+
return [_summarize_commit(commit) for commit in commits]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@mcp.tool()
|
|
274
|
+
async def list_issues(
|
|
275
|
+
owner: str,
|
|
276
|
+
repo: str,
|
|
277
|
+
state: str = "open",
|
|
278
|
+
labels: str | None = None,
|
|
279
|
+
limit: int = 20,
|
|
280
|
+
) -> list[dict[str, Any]]:
|
|
281
|
+
"""List issues in a repository.
|
|
282
|
+
|
|
283
|
+
`state` is one of `open`, `closed`, or `all`. `labels` is an optional
|
|
284
|
+
comma-separated list of label names to filter by. Note: GitHub's issues
|
|
285
|
+
endpoint also returns pull requests; check `is_pull_request` on each item.
|
|
286
|
+
"""
|
|
287
|
+
_require_token()
|
|
288
|
+
limit = max(1, min(limit, 50))
|
|
289
|
+
async with GitHubClient(config) as gh:
|
|
290
|
+
issues = await gh.get(
|
|
291
|
+
f"/repos/{owner}/{repo}/issues",
|
|
292
|
+
params={"state": state, "labels": labels, "per_page": limit},
|
|
293
|
+
)
|
|
294
|
+
return [_summarize_issue(issue) for issue in issues]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@mcp.tool()
|
|
298
|
+
async def get_issue(owner: str, repo: str, issue_number: int) -> dict[str, Any]:
|
|
299
|
+
"""Get a single issue including its full body text."""
|
|
300
|
+
_require_token()
|
|
301
|
+
async with GitHubClient(config) as gh:
|
|
302
|
+
issue = await gh.get(f"/repos/{owner}/{repo}/issues/{issue_number}")
|
|
303
|
+
summary = _summarize_issue(issue)
|
|
304
|
+
summary["body"] = issue.get("body")
|
|
305
|
+
return summary
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@mcp.tool()
|
|
309
|
+
async def list_pull_requests(
|
|
310
|
+
owner: str, repo: str, state: str = "open", limit: int = 20
|
|
311
|
+
) -> list[dict[str, Any]]:
|
|
312
|
+
"""List pull requests in a repository.
|
|
313
|
+
|
|
314
|
+
`state` is one of `open`, `closed`, or `all`. Returns up to `limit`
|
|
315
|
+
(max 50) pull requests.
|
|
316
|
+
"""
|
|
317
|
+
_require_token()
|
|
318
|
+
limit = max(1, min(limit, 50))
|
|
319
|
+
async with GitHubClient(config) as gh:
|
|
320
|
+
pulls = await gh.get(
|
|
321
|
+
f"/repos/{owner}/{repo}/pulls",
|
|
322
|
+
params={"state": state, "per_page": limit},
|
|
323
|
+
)
|
|
324
|
+
return [_summarize_pull(pull) for pull in pulls]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@mcp.tool()
|
|
328
|
+
async def get_pull_request(
|
|
329
|
+
owner: str, repo: str, pull_number: int
|
|
330
|
+
) -> dict[str, Any]:
|
|
331
|
+
"""Get a single pull request including its body and merge status."""
|
|
332
|
+
_require_token()
|
|
333
|
+
async with GitHubClient(config) as gh:
|
|
334
|
+
pull = await gh.get(f"/repos/{owner}/{repo}/pulls/{pull_number}")
|
|
335
|
+
summary = _summarize_pull(pull)
|
|
336
|
+
summary["body"] = pull.get("body")
|
|
337
|
+
return summary
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@mcp.tool()
|
|
341
|
+
async def get_pull_request_diff(
|
|
342
|
+
owner: str, repo: str, pull_number: int, max_chars: int = 20000
|
|
343
|
+
) -> dict[str, Any]:
|
|
344
|
+
"""Get the unified diff for a pull request.
|
|
345
|
+
|
|
346
|
+
The diff is truncated to `max_chars` characters to stay within context
|
|
347
|
+
limits; `truncated` indicates whether anything was cut off.
|
|
348
|
+
"""
|
|
349
|
+
_require_token()
|
|
350
|
+
async with GitHubClient(config) as gh:
|
|
351
|
+
diff = await gh.get_raw(
|
|
352
|
+
f"/repos/{owner}/{repo}/pulls/{pull_number}",
|
|
353
|
+
accept="application/vnd.github.diff",
|
|
354
|
+
)
|
|
355
|
+
truncated = len(diff) > max_chars
|
|
356
|
+
return {
|
|
357
|
+
"pull_number": pull_number,
|
|
358
|
+
"truncated": truncated,
|
|
359
|
+
"diff": diff[:max_chars],
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@mcp.tool()
|
|
364
|
+
async def search_issues(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
365
|
+
"""Search issues and pull requests across GitHub using search qualifiers.
|
|
366
|
+
|
|
367
|
+
Example queries: `repo:octocat/hello-world is:open label:bug`,
|
|
368
|
+
`is:pr author:octocat is:merged`. Returns up to `limit` (max 50) results.
|
|
369
|
+
"""
|
|
370
|
+
_require_token()
|
|
371
|
+
limit = max(1, min(limit, 50))
|
|
372
|
+
async with GitHubClient(config) as gh:
|
|
373
|
+
result = await gh.get(
|
|
374
|
+
"/search/issues",
|
|
375
|
+
params={"q": query, "per_page": limit},
|
|
376
|
+
)
|
|
377
|
+
return [_summarize_issue(item) for item in result.get("items", [])]
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@mcp.tool()
|
|
381
|
+
async def search_code(query: str, limit: int = 10) -> list[dict[str, Any]]:
|
|
382
|
+
"""Search for code across GitHub.
|
|
383
|
+
|
|
384
|
+
Example: `addClass in:file language:js repo:jquery/jquery`. A `repo:`,
|
|
385
|
+
`org:`, or `user:` qualifier is usually required by GitHub's code search.
|
|
386
|
+
Returns up to `limit` (max 50) matching files.
|
|
387
|
+
"""
|
|
388
|
+
_require_token()
|
|
389
|
+
limit = max(1, min(limit, 50))
|
|
390
|
+
async with GitHubClient(config) as gh:
|
|
391
|
+
result = await gh.get(
|
|
392
|
+
"/search/code",
|
|
393
|
+
params={"q": query, "per_page": limit},
|
|
394
|
+
)
|
|
395
|
+
return [
|
|
396
|
+
{
|
|
397
|
+
"name": item.get("name"),
|
|
398
|
+
"path": item.get("path"),
|
|
399
|
+
"repository": (item.get("repository") or {}).get("full_name"),
|
|
400
|
+
"html_url": item.get("html_url"),
|
|
401
|
+
}
|
|
402
|
+
for item in result.get("items", [])
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# ---------------------------------------------------------------------------
|
|
407
|
+
# write tools (disabled in read-only mode)
|
|
408
|
+
# ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@mcp.tool()
|
|
412
|
+
async def create_issue(
|
|
413
|
+
owner: str,
|
|
414
|
+
repo: str,
|
|
415
|
+
title: str,
|
|
416
|
+
body: str | None = None,
|
|
417
|
+
labels: list[str] | None = None,
|
|
418
|
+
) -> dict[str, Any]:
|
|
419
|
+
"""Create a new issue in a repository.
|
|
420
|
+
|
|
421
|
+
Disabled when the connector runs in read-only mode. Requires a token with
|
|
422
|
+
write access to the repository.
|
|
423
|
+
"""
|
|
424
|
+
_require_token()
|
|
425
|
+
_require_write()
|
|
426
|
+
payload: dict[str, Any] = {"title": title}
|
|
427
|
+
if body is not None:
|
|
428
|
+
payload["body"] = body
|
|
429
|
+
if labels:
|
|
430
|
+
payload["labels"] = labels
|
|
431
|
+
async with GitHubClient(config) as gh:
|
|
432
|
+
issue = await gh.post(f"/repos/{owner}/{repo}/issues", json=payload)
|
|
433
|
+
summary = _summarize_issue(issue)
|
|
434
|
+
summary["body"] = issue.get("body")
|
|
435
|
+
return summary
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@mcp.tool()
|
|
439
|
+
async def add_issue_comment(
|
|
440
|
+
owner: str, repo: str, issue_number: int, body: str
|
|
441
|
+
) -> dict[str, Any]:
|
|
442
|
+
"""Add a comment to an issue or pull request.
|
|
443
|
+
|
|
444
|
+
Disabled when the connector runs in read-only mode. Requires a token with
|
|
445
|
+
write access to the repository.
|
|
446
|
+
"""
|
|
447
|
+
_require_token()
|
|
448
|
+
_require_write()
|
|
449
|
+
async with GitHubClient(config) as gh:
|
|
450
|
+
comment = await gh.post(
|
|
451
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
|
452
|
+
json={"body": body},
|
|
453
|
+
)
|
|
454
|
+
return {
|
|
455
|
+
"id": comment.get("id"),
|
|
456
|
+
"user": (comment.get("user") or {}).get("login"),
|
|
457
|
+
"html_url": comment.get("html_url"),
|
|
458
|
+
"created_at": comment.get("created_at"),
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def main(argv: list[str] | None = None) -> None:
|
|
463
|
+
"""Console entry point. Selects the transport from CLI args/env."""
|
|
464
|
+
import argparse
|
|
465
|
+
|
|
466
|
+
parser = argparse.ArgumentParser(prog="github-mcp", description=__doc__)
|
|
467
|
+
parser.add_argument(
|
|
468
|
+
"--http",
|
|
469
|
+
action="store_true",
|
|
470
|
+
help="Serve over streamable HTTP instead of stdio.",
|
|
471
|
+
)
|
|
472
|
+
args = parser.parse_args(argv)
|
|
473
|
+
mcp.run(transport="streamable-http" if args.http else "stdio")
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
if __name__ == "__main__":
|
|
477
|
+
main()
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-mcp-connector
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Model Context Protocol (MCP) connector for GitHub, for use with Claude.
|
|
5
|
+
Author: winnerlose2026
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/winnerlose2026/Github-mcp
|
|
8
|
+
Project-URL: Repository, https://github.com/winnerlose2026/Github-mcp
|
|
9
|
+
Project-URL: Issues, https://github.com/winnerlose2026/Github-mcp/issues
|
|
10
|
+
Keywords: mcp,github,claude,model-context-protocol,connector
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: mcp>=1.2.0
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# GitHub MCP Connector
|
|
32
|
+
|
|
33
|
+
A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that
|
|
34
|
+
connects **Claude** to **GitHub**. It exposes a focused set of GitHub REST API
|
|
35
|
+
operations as MCP tools, so Claude (Desktop, Code, or any MCP client) can read
|
|
36
|
+
repositories, browse files and commit history, triage issues, review pull
|
|
37
|
+
requests, and—optionally—open issues and post comments.
|
|
38
|
+
|
|
39
|
+
It's a small, dependency-light Python package (`mcp` + `httpx`) that you point
|
|
40
|
+
at a GitHub token. It supports both stdio (the default for Claude Desktop/Code)
|
|
41
|
+
and streamable HTTP transports, and works against github.com or GitHub
|
|
42
|
+
Enterprise Server.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- 🔍 **Search** repositories, issues/PRs, and code with GitHub's query syntax
|
|
47
|
+
- 📦 **Repositories** — metadata, branches, file contents, directory listings
|
|
48
|
+
- 🧾 **Commits** — recent history, optionally filtered to a branch or file path
|
|
49
|
+
- 🐛 **Issues** — list, read, and (optionally) create issues and comments
|
|
50
|
+
- 🔀 **Pull requests** — list, read, and fetch unified diffs
|
|
51
|
+
- 🔒 **Read-only mode** — flip one env var to disable every write tool
|
|
52
|
+
- 🏢 **Enterprise-friendly** — set `GITHUB_API_URL` for GitHub Enterprise Server
|
|
53
|
+
|
|
54
|
+
## Tools
|
|
55
|
+
|
|
56
|
+
| Tool | Description | Write |
|
|
57
|
+
|------|-------------|:-----:|
|
|
58
|
+
| `get_authenticated_user` | Identity/health check for the configured token | |
|
|
59
|
+
| `search_repositories` | Search repositories by query | |
|
|
60
|
+
| `get_repository` | Repository metadata | |
|
|
61
|
+
| `list_branches` | Branches with head commit SHAs | |
|
|
62
|
+
| `get_file_contents` | Read a file (decoded) or list a directory | |
|
|
63
|
+
| `list_commits` | Recent commits, optional branch/path filter | |
|
|
64
|
+
| `list_issues` | Issues by state/labels | |
|
|
65
|
+
| `get_issue` | A single issue with full body | |
|
|
66
|
+
| `list_pull_requests` | Pull requests by state | |
|
|
67
|
+
| `get_pull_request` | A single PR with body and merge status | |
|
|
68
|
+
| `get_pull_request_diff` | Unified diff for a PR (truncated) | |
|
|
69
|
+
| `search_issues` | Search issues and PRs across GitHub | |
|
|
70
|
+
| `search_code` | Search code across GitHub | |
|
|
71
|
+
| `create_issue` | Open a new issue | ✅ |
|
|
72
|
+
| `add_issue_comment` | Comment on an issue or PR | ✅ |
|
|
73
|
+
|
|
74
|
+
Tools marked **Write** are disabled when `GITHUB_MCP_READ_ONLY` is set.
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
- Python 3.10+
|
|
79
|
+
- A GitHub personal access token. The connector applies **no repository
|
|
80
|
+
restrictions of its own** — it can reach exactly the repositories your token
|
|
81
|
+
can, so token scope is what controls access:
|
|
82
|
+
- **All your repositories (recommended for general use):** create a *classic*
|
|
83
|
+
PAT with the `repo` scope, or a *fine-grained* PAT whose "Repository access"
|
|
84
|
+
is set to **All repositories**. This lets the connector see every repo your
|
|
85
|
+
account can access (public and private).
|
|
86
|
+
- **Only specific repositories:** use a fine-grained PAT and select just those
|
|
87
|
+
repos under "Repository access".
|
|
88
|
+
- **Permissions:** read access is enough for the read tools; to use the write
|
|
89
|
+
tools (`create_issue`, `add_issue_comment`) the token also needs issue
|
|
90
|
+
write access (classic: `repo`; fine-grained: *Issues → Read and write*).
|
|
91
|
+
|
|
92
|
+
## Install from PyPI (recommended)
|
|
93
|
+
|
|
94
|
+
The connector is published to PyPI as
|
|
95
|
+
[`github-mcp-connector`](https://pypi.org/project/github-mcp-connector/), so you
|
|
96
|
+
can install or run it by name — no clone, no git, no build step. This is the
|
|
97
|
+
most reliable option on Windows, where launching from a git URL requires Git on
|
|
98
|
+
the spawned process's `PATH`.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uvx github-mcp-connector # run on demand with uv (nothing to install)
|
|
102
|
+
pipx run github-mcp-connector # same, with pipx
|
|
103
|
+
pip install github-mcp-connector # or install it permanently
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Wire it into Claude by pointing the command at the published package:
|
|
107
|
+
|
|
108
|
+
**Claude Code:**
|
|
109
|
+
```bash
|
|
110
|
+
claude mcp add-json github '{
|
|
111
|
+
"command": "uvx",
|
|
112
|
+
"args": ["github-mcp-connector"],
|
|
113
|
+
"env": { "GITHUB_TOKEN": "github_pat_your_token_here" }
|
|
114
|
+
}'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Claude Desktop** (`claude_desktop_config.json`):
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"mcpServers": {
|
|
121
|
+
"github": {
|
|
122
|
+
"command": "uvx",
|
|
123
|
+
"args": ["github-mcp-connector"],
|
|
124
|
+
"env": { "GITHUB_TOKEN": "github_pat_your_token_here" }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
On Windows, use the full path to `uvx.exe` (run `where.exe uvx` to find it), e.g.
|
|
131
|
+
`C:\\Users\\you\\.local\\bin\\uvx.exe`.
|
|
132
|
+
|
|
133
|
+
## Quick start (no clone, no venv)
|
|
134
|
+
|
|
135
|
+
If the package isn't published yet (or you want to track an unreleased commit),
|
|
136
|
+
`uvx` can also fetch, build, and run the connector straight from GitHub. This
|
|
137
|
+
path requires Git to be available to the process that launches it.
|
|
138
|
+
|
|
139
|
+
**Claude Code — one command:**
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
claude mcp add-json github '{
|
|
143
|
+
"command": "uvx",
|
|
144
|
+
"args": ["--from", "git+https://github.com/winnerlose2026/Github-mcp.git", "github-mcp"],
|
|
145
|
+
"env": { "GITHUB_TOKEN": "github_pat_your_token_here" }
|
|
146
|
+
}'
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Add `--scope user` to make it available in every project. Verify with
|
|
150
|
+
`claude mcp list` (should show `github` connected).
|
|
151
|
+
|
|
152
|
+
**Claude Code — project-scoped, shareable:** this repo ships a [`.mcp.json`](.mcp.json)
|
|
153
|
+
that reads `GITHUB_TOKEN` from your environment. Drop the same file in any project
|
|
154
|
+
(or copy it from here), export your token, and Claude Code auto-detects it:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
export GITHUB_TOKEN=github_pat_your_token_here
|
|
158
|
+
claude # prompts once to approve the project MCP server
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Claude Desktop:** point the `command` at `uvx` so there's no interpreter path to
|
|
162
|
+
manage:
|
|
163
|
+
|
|
164
|
+
```json
|
|
165
|
+
{
|
|
166
|
+
"mcpServers": {
|
|
167
|
+
"github": {
|
|
168
|
+
"command": "uvx",
|
|
169
|
+
"args": ["--from", "git+https://github.com/winnerlose2026/Github-mcp.git", "github-mcp"],
|
|
170
|
+
"env": { "GITHUB_TOKEN": "github_pat_your_token_here" }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Prefer pipx?** `pipx run --spec git+https://github.com/winnerlose2026/Github-mcp.git github-mcp`
|
|
177
|
+
works the same way; use that as the `command`/`args` instead.
|
|
178
|
+
|
|
179
|
+
## Installation (from source)
|
|
180
|
+
|
|
181
|
+
For development, or if you don't use `uv`/`pipx`:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
git clone https://github.com/winnerlose2026/Github-mcp.git
|
|
185
|
+
cd Github-mcp
|
|
186
|
+
python -m venv .venv && source .venv/bin/activate
|
|
187
|
+
pip install -e .
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Or, without installing, from the repo root:
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
pip install -r requirements.txt
|
|
194
|
+
python -m github_mcp
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Configuration
|
|
198
|
+
|
|
199
|
+
All configuration comes from environment variables (see [`.env.example`](.env.example)):
|
|
200
|
+
|
|
201
|
+
| Variable | Required | Default | Description |
|
|
202
|
+
|----------|:--------:|---------|-------------|
|
|
203
|
+
| `GITHUB_TOKEN` | yes | — | GitHub token. `GITHUB_PERSONAL_ACCESS_TOKEN` and `GH_TOKEN` are also accepted. |
|
|
204
|
+
| `GITHUB_API_URL` | no | `https://api.github.com` | API root; set for GitHub Enterprise Server (e.g. `https://ghe.example.com/api/v3`). |
|
|
205
|
+
| `GITHUB_MCP_READ_ONLY` | no | `false` | When truthy, disables all write tools. |
|
|
206
|
+
| `GITHUB_MCP_TIMEOUT` | no | `30` | Per-request timeout in seconds. |
|
|
207
|
+
| `GITHUB_MCP_USER_AGENT` | no | `github-mcp-connector` | `User-Agent` header sent to GitHub. |
|
|
208
|
+
|
|
209
|
+
## Connecting to Claude (from-source install)
|
|
210
|
+
|
|
211
|
+
If you installed from source (above) instead of using `uvx`/`pipx`, configure
|
|
212
|
+
the client to run the package directly.
|
|
213
|
+
|
|
214
|
+
### Claude Desktop
|
|
215
|
+
|
|
216
|
+
Add the server to `claude_desktop_config.json` (Settings → Developer → Edit
|
|
217
|
+
Config):
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"mcpServers": {
|
|
222
|
+
"github": {
|
|
223
|
+
"command": "python",
|
|
224
|
+
"args": ["-m", "github_mcp"],
|
|
225
|
+
"env": {
|
|
226
|
+
"GITHUB_TOKEN": "ghp_your_token_here"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Use the absolute path to the Python interpreter from the virtualenv where you
|
|
234
|
+
installed the package (e.g. `/path/to/Github-mcp/.venv/bin/python`), or the
|
|
235
|
+
`github-mcp` console script directly. Restart Claude Desktop after editing.
|
|
236
|
+
|
|
237
|
+
### Claude Code
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
claude mcp add github \
|
|
241
|
+
--env GITHUB_TOKEN=ghp_your_token_here \
|
|
242
|
+
-- python -m github_mcp
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Streamable HTTP
|
|
246
|
+
|
|
247
|
+
To run as a standalone HTTP server instead of stdio:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
GITHUB_TOKEN=ghp_your_token_here python -m github_mcp --http
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Example prompts
|
|
254
|
+
|
|
255
|
+
Once connected, you can ask Claude things like:
|
|
256
|
+
|
|
257
|
+
- "What's the open PR backlog on `owner/repo`?"
|
|
258
|
+
- "Read `README.md` from the default branch of `owner/repo` and summarize it."
|
|
259
|
+
- "Show me the diff for PR #42 and summarize the risky parts."
|
|
260
|
+
- "Open an issue titled 'Flaky test in CI' with these reproduction steps…"
|
|
261
|
+
|
|
262
|
+
## Development
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
pip install -e ".[dev]"
|
|
266
|
+
pytest
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The test suite mocks the GitHub API with `httpx.MockTransport`, so it runs
|
|
270
|
+
fully offline and makes no network calls.
|
|
271
|
+
|
|
272
|
+
## Releasing (maintainers)
|
|
273
|
+
|
|
274
|
+
Publishing is automated via GitHub Actions
|
|
275
|
+
([`.github/workflows/publish.yml`](.github/workflows/publish.yml)) using PyPI
|
|
276
|
+
**Trusted Publishing** (OIDC) — no API tokens are stored anywhere.
|
|
277
|
+
|
|
278
|
+
**One-time PyPI setup** (before the first release):
|
|
279
|
+
|
|
280
|
+
1. Sign in at [pypi.org](https://pypi.org) and go to **Your projects → Publishing**
|
|
281
|
+
(or **Account → Publishing** for a project that doesn't exist yet).
|
|
282
|
+
2. Add a **pending publisher** with:
|
|
283
|
+
- PyPI Project Name: `github-mcp-connector`
|
|
284
|
+
- Owner: `winnerlose2026`
|
|
285
|
+
- Repository: `Github-mcp`
|
|
286
|
+
- Workflow name: `publish.yml`
|
|
287
|
+
- Environment name: `pypi`
|
|
288
|
+
3. (Recommended) In the GitHub repo, create an **Environment** named `pypi`
|
|
289
|
+
(Settings → Environments) so the publish job is gated.
|
|
290
|
+
|
|
291
|
+
**Cutting a release:**
|
|
292
|
+
|
|
293
|
+
1. Bump `version` in `pyproject.toml`, commit, and merge to `main`.
|
|
294
|
+
2. Tag and publish a GitHub Release (e.g. `v0.1.0`). Publishing the release
|
|
295
|
+
triggers the workflow, which builds the sdist + wheel, runs `twine check`,
|
|
296
|
+
and uploads to PyPI.
|
|
297
|
+
3. Confirm it's live: `uvx github-mcp-connector@latest --help`.
|
|
298
|
+
|
|
299
|
+
Until the first release is published, install via the
|
|
300
|
+
[git-based quick start](#quick-start-no-clone-no-venv) instead.
|
|
301
|
+
|
|
302
|
+
## Security notes
|
|
303
|
+
|
|
304
|
+
- The connector only has the access your token grants. A broad token (`repo`
|
|
305
|
+
scope / all repositories) gives Claude reach across every repo your account
|
|
306
|
+
can touch — convenient, but treat the token like the credential it is. Prefer
|
|
307
|
+
a fine-grained, repo-limited token if you only need a few repositories.
|
|
308
|
+
- Run with `GITHUB_MCP_READ_ONLY=true` when you only need read access; this is
|
|
309
|
+
enforced server-side, before any write request is sent to GitHub. This pairs
|
|
310
|
+
well with a broad-access token: full visibility, no write risk.
|
|
311
|
+
- Never commit your token. `.env` is git-ignored; `.env.example` is the
|
|
312
|
+
template to copy.
|
|
313
|
+
|
|
314
|
+
## License
|
|
315
|
+
|
|
316
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
github_mcp/__init__.py,sha256=BkynbNaLysjU28VDDaNfFzZeUk3tJxD-mBNar_ulWGE,332
|
|
2
|
+
github_mcp/__main__.py,sha256=p2LRgzcQfYXSOy1lG-RViI1WSPmqF1bXvxaT_8-oork,130
|
|
3
|
+
github_mcp/client.py,sha256=JfKCk5lz5Tw8w-dshUaEJbItENidtDQwFTAyQukt2Kw,4586
|
|
4
|
+
github_mcp/config.py,sha256=dnHvtQaampb41js6VkK_iCa1bs6fywsvGhzsipU0gQE,1679
|
|
5
|
+
github_mcp/server.py,sha256=F6ie5bYnZxCErRzsNr-uyTeOrKS6x1cQ7ZMJHvsWWsA,15289
|
|
6
|
+
github_mcp_connector-0.1.0.dist-info/licenses/LICENSE,sha256=9hlM7Wg0Oxyq7Zu5VoT_Fj81Ng6IAxjXYg9_AOBxBJ8,1071
|
|
7
|
+
github_mcp_connector-0.1.0.dist-info/METADATA,sha256=uMWo_OecgPWi2PlvMkrYhugGrqcscj-QL4UtV30vBSg,11422
|
|
8
|
+
github_mcp_connector-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
github_mcp_connector-0.1.0.dist-info/entry_points.txt,sha256=_gpMCjdQqpecWEuktKFCMPRF427yhKlLSDVei-43N5I,100
|
|
10
|
+
github_mcp_connector-0.1.0.dist-info/top_level.txt,sha256=Oz7KcY8SwRmG0rRos2vB8LycqcG5vapSkveK8aqQi0Q,11
|
|
11
|
+
github_mcp_connector-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 winnerlose2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
github_mcp
|