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 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
@@ -0,0 +1,6 @@
1
+ """Allow running the connector with ``python -m github_mcp``."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ github-mcp = github_mcp.server:main
3
+ github-mcp-connector = github_mcp.server:main
@@ -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