jaimenbell-github-mcp 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 +0 -0
- github_mcp/client.py +155 -0
- github_mcp/config.py +127 -0
- github_mcp/groups/__init__.py +0 -0
- github_mcp/groups/read.py +259 -0
- github_mcp/groups/write.py +89 -0
- github_mcp/server.py +104 -0
- jaimenbell_github_mcp-0.1.0.dist-info/METADATA +187 -0
- jaimenbell_github_mcp-0.1.0.dist-info/RECORD +12 -0
- jaimenbell_github_mcp-0.1.0.dist-info/WHEEL +5 -0
- jaimenbell_github_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- jaimenbell_github_mcp-0.1.0.dist-info/top_level.txt +1 -0
github_mcp/__init__.py
ADDED
|
File without changes
|
github_mcp/client.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Thin sync httpx wrapper around api.github.com.
|
|
2
|
+
|
|
3
|
+
One function per HTTP verb used by the tool groups (get/post/patch). Auth
|
|
4
|
+
header injection when a token is present; degrades to GitHub's unauthenticated
|
|
5
|
+
60 req/hr tier otherwise. GitHub's 403 primary rate-limit response (hourly
|
|
6
|
+
quota, X-RateLimit-Reset) and secondary rate-limit response (abuse-detection
|
|
7
|
+
heuristics, Retry-After) both surface as a typed `rate_limited` error; any
|
|
8
|
+
other 4xx/5xx surfaces as a typed `github_api_error` -- never a raw
|
|
9
|
+
exception/crash. A dedicated httpx.Client is created per call so tests can
|
|
10
|
+
respx-mock deterministically without managing a shared client lifecycle
|
|
11
|
+
across the process.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json as json_module
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from . import config
|
|
21
|
+
|
|
22
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _headers() -> dict:
|
|
26
|
+
headers = {
|
|
27
|
+
"Accept": "application/vnd.github+json",
|
|
28
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
29
|
+
"User-Agent": "github-mcp",
|
|
30
|
+
}
|
|
31
|
+
token = config.get_token()
|
|
32
|
+
if token:
|
|
33
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
34
|
+
return headers
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _rate_limit_error(tool: str, response: httpx.Response) -> dict:
|
|
38
|
+
reset_header = response.headers.get("X-RateLimit-Reset")
|
|
39
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
40
|
+
retry_after = response.headers.get("Retry-After")
|
|
41
|
+
if reset_header:
|
|
42
|
+
message = f"GitHub API rate limit exceeded. Resets at unix time {reset_header}."
|
|
43
|
+
elif retry_after:
|
|
44
|
+
# GitHub's secondary rate limit: no X-RateLimit-* headers, just Retry-After.
|
|
45
|
+
message = f"GitHub API secondary rate limit exceeded. Retry after {retry_after}s."
|
|
46
|
+
else:
|
|
47
|
+
message = "GitHub API rate limit exceeded."
|
|
48
|
+
return {
|
|
49
|
+
"ok": False,
|
|
50
|
+
"error": {
|
|
51
|
+
"type": "rate_limited",
|
|
52
|
+
"message": message,
|
|
53
|
+
"tool": tool,
|
|
54
|
+
"status_code": response.status_code,
|
|
55
|
+
"reset_time": int(reset_header) if reset_header and reset_header.isdigit() else None,
|
|
56
|
+
"remaining": int(remaining) if remaining and remaining.isdigit() else None,
|
|
57
|
+
"retry_after_s": int(retry_after) if retry_after and retry_after.isdigit() else None,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _api_error(tool: str, response: httpx.Response) -> dict:
|
|
63
|
+
try:
|
|
64
|
+
body = response.json()
|
|
65
|
+
message = body.get("message", response.text)
|
|
66
|
+
except (json_module.JSONDecodeError, ValueError):
|
|
67
|
+
message = response.text or f"HTTP {response.status_code}"
|
|
68
|
+
return {
|
|
69
|
+
"ok": False,
|
|
70
|
+
"error": {
|
|
71
|
+
"type": "github_api_error",
|
|
72
|
+
"message": message,
|
|
73
|
+
"tool": tool,
|
|
74
|
+
"status_code": response.status_code,
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_rate_limit_response(response: httpx.Response) -> bool:
|
|
80
|
+
"""True for GitHub's primary rate limit (403 + X-RateLimit-Remaining: 0)
|
|
81
|
+
and its secondary rate limit (403 + Retry-After, no X-RateLimit-Remaining
|
|
82
|
+
-- triggered by abuse-detection/concurrency/rapid-write heuristics rather
|
|
83
|
+
than the hourly quota)."""
|
|
84
|
+
if response.status_code != 403:
|
|
85
|
+
return False
|
|
86
|
+
if response.headers.get("X-RateLimit-Remaining") == "0":
|
|
87
|
+
return True
|
|
88
|
+
return "Retry-After" in response.headers
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _handle_response(tool: str, response: httpx.Response) -> dict:
|
|
92
|
+
if _is_rate_limit_response(response):
|
|
93
|
+
return _rate_limit_error(tool, response)
|
|
94
|
+
if response.status_code >= 400:
|
|
95
|
+
return _api_error(tool, response)
|
|
96
|
+
try:
|
|
97
|
+
data = response.json() if response.content else {}
|
|
98
|
+
except (json_module.JSONDecodeError, ValueError) as exc:
|
|
99
|
+
return {
|
|
100
|
+
"ok": False,
|
|
101
|
+
"error": {
|
|
102
|
+
"type": "decode_error",
|
|
103
|
+
"message": f"GitHub returned non-JSON content: {exc}",
|
|
104
|
+
"tool": tool,
|
|
105
|
+
"status_code": response.status_code,
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
return {"ok": True, "data": data, "status_code": response.status_code}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def request(
|
|
112
|
+
tool: str,
|
|
113
|
+
method: str,
|
|
114
|
+
path: str,
|
|
115
|
+
*,
|
|
116
|
+
params: dict[str, Any] | None = None,
|
|
117
|
+
json: dict[str, Any] | None = None,
|
|
118
|
+
) -> dict:
|
|
119
|
+
"""Issue one request to api.github.com and return a structured result:
|
|
120
|
+
{"ok": True, "data": ..., "status_code": ...} on success, or
|
|
121
|
+
{"ok": False, "error": {...}} on any 4xx/5xx/decode failure. Network-level
|
|
122
|
+
exceptions (timeout, connection refused, DNS failure) AND malformed-URL
|
|
123
|
+
errors (e.g. a caller-supplied owner/repo/path containing characters that
|
|
124
|
+
make the assembled URL invalid) are also caught and surfaced as a typed
|
|
125
|
+
error rather than propagating -- the tool caller always gets a dict back,
|
|
126
|
+
never an exception. `httpx.InvalidURL` does not subclass `httpx.HTTPError`
|
|
127
|
+
(it's raised during URL construction, before any network I/O), so it is
|
|
128
|
+
listed explicitly -- omitting it would let a bad path/owner/repo value
|
|
129
|
+
crash the call instead of returning a clean error."""
|
|
130
|
+
url = f"{config.GITHUB_API_BASE}{path}"
|
|
131
|
+
try:
|
|
132
|
+
with httpx.Client(timeout=DEFAULT_TIMEOUT_S) as client:
|
|
133
|
+
response = client.request(method, url, headers=_headers(), params=params, json=json)
|
|
134
|
+
except (httpx.HTTPError, httpx.InvalidURL) as exc:
|
|
135
|
+
return {
|
|
136
|
+
"ok": False,
|
|
137
|
+
"error": {
|
|
138
|
+
"type": "network_error",
|
|
139
|
+
"message": str(exc),
|
|
140
|
+
"tool": tool,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
return _handle_response(tool, response)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get(tool: str, path: str, *, params: dict[str, Any] | None = None) -> dict:
|
|
147
|
+
return request(tool, "GET", path, params=params)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def post(tool: str, path: str, *, json: dict[str, Any] | None = None) -> dict:
|
|
151
|
+
return request(tool, "POST", path, json=json)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def patch(tool: str, path: str, *, json: dict[str, Any] | None = None) -> dict:
|
|
155
|
+
return request(tool, "PATCH", path, json=json)
|
github_mcp/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Config/safety layer for github-mcp.
|
|
2
|
+
|
|
3
|
+
Tool-group gating + token loading + structured refusal/error payloads. This
|
|
4
|
+
is the server's own defense-in-depth layer: even if a caller gets past
|
|
5
|
+
harness permission prompts, the server itself refuses write actions unless
|
|
6
|
+
GITHUB_MCP_ENABLE_WRITE=1 is set, and never proceeds with a write call that
|
|
7
|
+
lacks a token -- mirroring desktop-mcp's config.gated pattern.
|
|
8
|
+
|
|
9
|
+
Groups:
|
|
10
|
+
read -- repo/issue/PR/file/user/commit lookups (always on, works
|
|
11
|
+
unauthenticated at GitHub's 60 req/hr tier)
|
|
12
|
+
write -- issue/PR-comment mutations (env-gated, OFF by default, requires
|
|
13
|
+
GITHUB_TOKEN)
|
|
14
|
+
|
|
15
|
+
Env vars:
|
|
16
|
+
GITHUB_MCP_ENABLE_WRITE=1 -- enable the write group
|
|
17
|
+
GITHUB_TOKEN -- fine-grained PAT; read works without it
|
|
18
|
+
(degraded unauth rate), write requires it
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
|
|
24
|
+
GROUP_READ = "read"
|
|
25
|
+
GROUP_WRITE = "write"
|
|
26
|
+
|
|
27
|
+
_ENV_GATES = {
|
|
28
|
+
GROUP_WRITE: "GITHUB_MCP_ENABLE_WRITE",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _env_truthy(name: str) -> bool:
|
|
35
|
+
val = os.environ.get(name, "")
|
|
36
|
+
return val.strip().lower() in ("1", "true", "yes", "on")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def group_enabled(group: str) -> bool:
|
|
40
|
+
"""read is always on; write requires its env gate."""
|
|
41
|
+
if group == GROUP_READ:
|
|
42
|
+
return True
|
|
43
|
+
env_name = _ENV_GATES.get(group)
|
|
44
|
+
if env_name is None:
|
|
45
|
+
return False
|
|
46
|
+
return _env_truthy(env_name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_token() -> str | None:
|
|
50
|
+
"""Fine-grained PAT from GITHUB_TOKEN, or None. Never logged; callers
|
|
51
|
+
must not include this value in any error payload or diagnostic."""
|
|
52
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
53
|
+
return token if token else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def policy_refusal(group: str, tool: str) -> dict:
|
|
57
|
+
"""Structured refusal payload for a disabled tool group."""
|
|
58
|
+
env_name = _ENV_GATES.get(group, f"GITHUB_MCP_ENABLE_{group.upper()}")
|
|
59
|
+
return {
|
|
60
|
+
"ok": False,
|
|
61
|
+
"error": {
|
|
62
|
+
"type": "policy_refusal",
|
|
63
|
+
"message": (
|
|
64
|
+
f"Tool group '{group}' is disabled. Set {env_name}=1 in the "
|
|
65
|
+
f"server's environment to enable it."
|
|
66
|
+
),
|
|
67
|
+
"group": group,
|
|
68
|
+
"tool": tool,
|
|
69
|
+
"required_env": env_name,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def auth_required(tool: str) -> dict:
|
|
75
|
+
"""Structured refusal payload for a write call with no GITHUB_TOKEN.
|
|
76
|
+
Write actions always require a token even when the group is enabled --
|
|
77
|
+
GitHub's write endpoints reject unauthenticated requests, so this is a
|
|
78
|
+
fast, clean local refusal instead of a round-trip 401/403."""
|
|
79
|
+
return {
|
|
80
|
+
"ok": False,
|
|
81
|
+
"error": {
|
|
82
|
+
"type": "auth_required",
|
|
83
|
+
"message": (
|
|
84
|
+
f"Tool '{tool}' requires a GitHub token. Set GITHUB_TOKEN in "
|
|
85
|
+
f"the server's environment (fine-grained PAT with the needed "
|
|
86
|
+
f"repo write scopes)."
|
|
87
|
+
),
|
|
88
|
+
"tool": tool,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check_group(group: str, tool: str) -> dict | None:
|
|
94
|
+
"""Gate check for a tool call. Returns a structured refusal dict if the
|
|
95
|
+
group is disabled, else None (caller proceeds)."""
|
|
96
|
+
if not group_enabled(group):
|
|
97
|
+
return policy_refusal(group, tool)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def check_write_preconditions(tool: str) -> dict | None:
|
|
102
|
+
"""Combined gate for write tools: group must be enabled AND a token must
|
|
103
|
+
be present. Returns a structured refusal dict, else None."""
|
|
104
|
+
refusal = check_group(GROUP_WRITE, tool)
|
|
105
|
+
if refusal is not None:
|
|
106
|
+
return refusal
|
|
107
|
+
if get_token() is None:
|
|
108
|
+
return auth_required(tool)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def gated_write(fn):
|
|
113
|
+
"""Decorator applied directly to write-group module functions so the
|
|
114
|
+
policy gate + token precondition is enforced at the source -- not just in
|
|
115
|
+
the MCP tool wrapper -- and is unit-testable without spinning up the
|
|
116
|
+
fastmcp server or hitting the network."""
|
|
117
|
+
|
|
118
|
+
def wrapper(*args, **kwargs):
|
|
119
|
+
refusal = check_write_preconditions(fn.__name__)
|
|
120
|
+
if refusal is not None:
|
|
121
|
+
return refusal
|
|
122
|
+
return fn(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
wrapper.__name__ = fn.__name__
|
|
125
|
+
wrapper.__doc__ = fn.__doc__
|
|
126
|
+
wrapper.__wrapped__ = fn
|
|
127
|
+
return wrapper
|
|
File without changes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Read group: repo/issue/PR/file/user/commit lookups. Always enabled (no
|
|
2
|
+
env gate) -- these work unauthenticated (GitHub's 60 req/hr tier) and are
|
|
3
|
+
considered safe by default.
|
|
4
|
+
|
|
5
|
+
Every function returns a plain dict ({"ok": True, ...} or a structured
|
|
6
|
+
error) built from `client.get`, never raises. Base64 file-content decoding
|
|
7
|
+
happens here (get_file_content) since it's read-tool-specific, not a
|
|
8
|
+
generic client concern.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import binascii
|
|
14
|
+
|
|
15
|
+
from .. import client
|
|
16
|
+
|
|
17
|
+
MAX_FILE_BYTES = 100_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_repo(owner: str, repo: str) -> dict:
|
|
21
|
+
"""Metadata for a repository: description, language, stars, forks, open
|
|
22
|
+
issues, default branch, archived flag, license, last-push time."""
|
|
23
|
+
result = client.get("get_repo", f"/repos/{owner}/{repo}")
|
|
24
|
+
if not result["ok"]:
|
|
25
|
+
return result
|
|
26
|
+
d = result["data"]
|
|
27
|
+
return {
|
|
28
|
+
"ok": True,
|
|
29
|
+
"full_name": d.get("full_name"),
|
|
30
|
+
"description": d.get("description"),
|
|
31
|
+
"language": d.get("language"),
|
|
32
|
+
"stargazers_count": d.get("stargazers_count"),
|
|
33
|
+
"forks_count": d.get("forks_count"),
|
|
34
|
+
"open_issues_count": d.get("open_issues_count"),
|
|
35
|
+
"default_branch": d.get("default_branch"),
|
|
36
|
+
"archived": d.get("archived"),
|
|
37
|
+
"disabled": d.get("disabled"),
|
|
38
|
+
"license": (d.get("license") or {}).get("spdx_id"),
|
|
39
|
+
"pushed_at": d.get("pushed_at"),
|
|
40
|
+
"html_url": d.get("html_url"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def list_issues(owner: str, repo: str, state: str = "open", limit: int = 20) -> dict:
|
|
45
|
+
"""List issues (pull requests filtered out), most-recently-updated first."""
|
|
46
|
+
capped = max(1, min(limit, 50))
|
|
47
|
+
result = client.get(
|
|
48
|
+
"list_issues",
|
|
49
|
+
f"/repos/{owner}/{repo}/issues",
|
|
50
|
+
params={"state": state, "per_page": capped, "sort": "updated"},
|
|
51
|
+
)
|
|
52
|
+
if not result["ok"]:
|
|
53
|
+
return result
|
|
54
|
+
issues = [item for item in result["data"] if "pull_request" not in item]
|
|
55
|
+
return {
|
|
56
|
+
"ok": True,
|
|
57
|
+
"issues": [
|
|
58
|
+
{
|
|
59
|
+
"number": i.get("number"),
|
|
60
|
+
"title": i.get("title"),
|
|
61
|
+
"state": i.get("state"),
|
|
62
|
+
"user": (i.get("user") or {}).get("login"),
|
|
63
|
+
"labels": [lbl.get("name") for lbl in i.get("labels", [])],
|
|
64
|
+
"comments": i.get("comments"),
|
|
65
|
+
"updated_at": i.get("updated_at"),
|
|
66
|
+
}
|
|
67
|
+
for i in issues
|
|
68
|
+
],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_issue(owner: str, repo: str, issue_number: int) -> dict:
|
|
73
|
+
"""Fetch a single issue's full detail (title, body, state, labels, comments)."""
|
|
74
|
+
result = client.get("get_issue", f"/repos/{owner}/{repo}/issues/{issue_number}")
|
|
75
|
+
if not result["ok"]:
|
|
76
|
+
return result
|
|
77
|
+
d = result["data"]
|
|
78
|
+
return {
|
|
79
|
+
"ok": True,
|
|
80
|
+
"number": d.get("number"),
|
|
81
|
+
"title": d.get("title"),
|
|
82
|
+
"body": d.get("body"),
|
|
83
|
+
"state": d.get("state"),
|
|
84
|
+
"user": (d.get("user") or {}).get("login"),
|
|
85
|
+
"labels": [lbl.get("name") for lbl in d.get("labels", [])],
|
|
86
|
+
"comments": d.get("comments"),
|
|
87
|
+
"created_at": d.get("created_at"),
|
|
88
|
+
"updated_at": d.get("updated_at"),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def list_pull_requests(owner: str, repo: str, state: str = "open", limit: int = 20) -> dict:
|
|
93
|
+
"""List pull requests, most-recently-updated first."""
|
|
94
|
+
capped = max(1, min(limit, 50))
|
|
95
|
+
result = client.get(
|
|
96
|
+
"list_pull_requests",
|
|
97
|
+
f"/repos/{owner}/{repo}/pulls",
|
|
98
|
+
params={"state": state, "per_page": capped, "sort": "updated"},
|
|
99
|
+
)
|
|
100
|
+
if not result["ok"]:
|
|
101
|
+
return result
|
|
102
|
+
return {
|
|
103
|
+
"ok": True,
|
|
104
|
+
"pull_requests": [
|
|
105
|
+
{
|
|
106
|
+
"number": p.get("number"),
|
|
107
|
+
"title": p.get("title"),
|
|
108
|
+
"state": p.get("state"),
|
|
109
|
+
"user": (p.get("user") or {}).get("login"),
|
|
110
|
+
"draft": p.get("draft"),
|
|
111
|
+
"base": (p.get("base") or {}).get("ref"),
|
|
112
|
+
"head": (p.get("head") or {}).get("ref"),
|
|
113
|
+
"updated_at": p.get("updated_at"),
|
|
114
|
+
}
|
|
115
|
+
for p in result["data"]
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_pull_request(owner: str, repo: str, pr_number: int) -> dict:
|
|
121
|
+
"""Fetch a single pull request's full detail, including merge state."""
|
|
122
|
+
result = client.get("get_pull_request", f"/repos/{owner}/{repo}/pulls/{pr_number}")
|
|
123
|
+
if not result["ok"]:
|
|
124
|
+
return result
|
|
125
|
+
d = result["data"]
|
|
126
|
+
return {
|
|
127
|
+
"ok": True,
|
|
128
|
+
"number": d.get("number"),
|
|
129
|
+
"title": d.get("title"),
|
|
130
|
+
"body": d.get("body"),
|
|
131
|
+
"state": d.get("state"),
|
|
132
|
+
"user": (d.get("user") or {}).get("login"),
|
|
133
|
+
"draft": d.get("draft"),
|
|
134
|
+
"mergeable": d.get("mergeable"),
|
|
135
|
+
"merged": d.get("merged"),
|
|
136
|
+
"base": (d.get("base") or {}).get("ref"),
|
|
137
|
+
"head": (d.get("head") or {}).get("ref"),
|
|
138
|
+
"updated_at": d.get("updated_at"),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_file_content(owner: str, repo: str, path: str, ref: str | None = None) -> dict:
|
|
143
|
+
"""Read a UTF-8 text file from a repo at a given path (base64-decoded
|
|
144
|
+
server-side). Returns a structured 'binary' marker instead of decoding
|
|
145
|
+
non-UTF-8 content. Truncates past MAX_FILE_BYTES."""
|
|
146
|
+
params = {"ref": ref} if ref else None
|
|
147
|
+
result = client.get("get_file_content", f"/repos/{owner}/{repo}/contents/{path}", params=params)
|
|
148
|
+
if not result["ok"]:
|
|
149
|
+
return result
|
|
150
|
+
d = result["data"]
|
|
151
|
+
if isinstance(d, list):
|
|
152
|
+
return {
|
|
153
|
+
"ok": False,
|
|
154
|
+
"error": {
|
|
155
|
+
"type": "not_a_file",
|
|
156
|
+
"message": f"'{path}' is a directory, not a file.",
|
|
157
|
+
"tool": "get_file_content",
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
if d.get("encoding") != "base64":
|
|
161
|
+
return {
|
|
162
|
+
"ok": False,
|
|
163
|
+
"error": {
|
|
164
|
+
"type": "unsupported_encoding",
|
|
165
|
+
"message": f"Unsupported content encoding: {d.get('encoding')}",
|
|
166
|
+
"tool": "get_file_content",
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
raw = base64.b64decode(d.get("content", ""))
|
|
170
|
+
try:
|
|
171
|
+
text = raw.decode("utf-8")
|
|
172
|
+
truncated = False
|
|
173
|
+
if len(raw) > MAX_FILE_BYTES:
|
|
174
|
+
text = raw[:MAX_FILE_BYTES].decode("utf-8", errors="ignore")
|
|
175
|
+
truncated = True
|
|
176
|
+
return {
|
|
177
|
+
"ok": True,
|
|
178
|
+
"path": d.get("path"),
|
|
179
|
+
"size": d.get("size"),
|
|
180
|
+
"content": text,
|
|
181
|
+
"truncated": truncated,
|
|
182
|
+
"binary": False,
|
|
183
|
+
}
|
|
184
|
+
except (UnicodeDecodeError, binascii.Error):
|
|
185
|
+
return {
|
|
186
|
+
"ok": True,
|
|
187
|
+
"path": d.get("path"),
|
|
188
|
+
"size": d.get("size"),
|
|
189
|
+
"content": None,
|
|
190
|
+
"truncated": False,
|
|
191
|
+
"binary": True,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def search_repos(query: str, limit: int = 10) -> dict:
|
|
196
|
+
"""Search public repositories by keyword/qualifiers, sorted by best match.
|
|
197
|
+
Subject to GitHub's stricter search rate limit (10 req/min unauthenticated)."""
|
|
198
|
+
capped = max(1, min(limit, 25))
|
|
199
|
+
result = client.get("search_repos", "/search/repositories", params={"q": query, "per_page": capped})
|
|
200
|
+
if not result["ok"]:
|
|
201
|
+
return result
|
|
202
|
+
d = result["data"]
|
|
203
|
+
return {
|
|
204
|
+
"ok": True,
|
|
205
|
+
"total_count": d.get("total_count"),
|
|
206
|
+
"items": [
|
|
207
|
+
{
|
|
208
|
+
"full_name": item.get("full_name"),
|
|
209
|
+
"description": item.get("description"),
|
|
210
|
+
"language": item.get("language"),
|
|
211
|
+
"stargazers_count": item.get("stargazers_count"),
|
|
212
|
+
"html_url": item.get("html_url"),
|
|
213
|
+
}
|
|
214
|
+
for item in d.get("items", [])
|
|
215
|
+
],
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_user(username: str) -> dict:
|
|
220
|
+
"""Public profile for a GitHub user or organization."""
|
|
221
|
+
result = client.get("get_user", f"/users/{username}")
|
|
222
|
+
if not result["ok"]:
|
|
223
|
+
return result
|
|
224
|
+
d = result["data"]
|
|
225
|
+
return {
|
|
226
|
+
"ok": True,
|
|
227
|
+
"login": d.get("login"),
|
|
228
|
+
"name": d.get("name"),
|
|
229
|
+
"bio": d.get("bio"),
|
|
230
|
+
"company": d.get("company"),
|
|
231
|
+
"location": d.get("location"),
|
|
232
|
+
"public_repos": d.get("public_repos"),
|
|
233
|
+
"followers": d.get("followers"),
|
|
234
|
+
"type": d.get("type"),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def list_commits(owner: str, repo: str, limit: int = 20, sha: str | None = None) -> dict:
|
|
239
|
+
"""List commits on a repo's default branch (or `sha` ref), newest first."""
|
|
240
|
+
capped = max(1, min(limit, 50))
|
|
241
|
+
params = {"per_page": capped}
|
|
242
|
+
if sha:
|
|
243
|
+
params["sha"] = sha
|
|
244
|
+
result = client.get("list_commits", f"/repos/{owner}/{repo}/commits", params=params)
|
|
245
|
+
if not result["ok"]:
|
|
246
|
+
return result
|
|
247
|
+
return {
|
|
248
|
+
"ok": True,
|
|
249
|
+
"commits": [
|
|
250
|
+
{
|
|
251
|
+
"sha": c.get("sha"),
|
|
252
|
+
"message": (c.get("commit") or {}).get("message"),
|
|
253
|
+
"author": ((c.get("commit") or {}).get("author") or {}).get("name"),
|
|
254
|
+
"date": ((c.get("commit") or {}).get("author") or {}).get("date"),
|
|
255
|
+
"html_url": c.get("html_url"),
|
|
256
|
+
}
|
|
257
|
+
for c in result["data"]
|
|
258
|
+
],
|
|
259
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Write group: issue/PR mutations. Env-gated behind GITHUB_MCP_ENABLE_WRITE
|
|
2
|
+
(OFF by default) AND requires GITHUB_TOKEN -- both preconditions enforced by
|
|
3
|
+
`config.gated_write` at the source, so a disabled group or missing token
|
|
4
|
+
returns a structured refusal before any network call is attempted.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .. import client, config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@config.gated_write
|
|
12
|
+
def create_issue(owner: str, repo: str, title: str, body: str | None = None, labels: list[str] | None = None) -> dict:
|
|
13
|
+
"""Open a new issue on a repository."""
|
|
14
|
+
payload: dict = {"title": title}
|
|
15
|
+
if body is not None:
|
|
16
|
+
payload["body"] = body
|
|
17
|
+
if labels:
|
|
18
|
+
payload["labels"] = labels
|
|
19
|
+
result = client.post("create_issue", f"/repos/{owner}/{repo}/issues", json=payload)
|
|
20
|
+
if not result["ok"]:
|
|
21
|
+
return result
|
|
22
|
+
d = result["data"]
|
|
23
|
+
return {"ok": True, "number": d.get("number"), "html_url": d.get("html_url"), "state": d.get("state")}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@config.gated_write
|
|
27
|
+
def comment_on_issue(owner: str, repo: str, issue_number: int, body: str) -> dict:
|
|
28
|
+
"""Post a comment on an issue or pull request (PRs are issues in GitHub's
|
|
29
|
+
comments API)."""
|
|
30
|
+
result = client.post(
|
|
31
|
+
"comment_on_issue",
|
|
32
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
|
|
33
|
+
json={"body": body},
|
|
34
|
+
)
|
|
35
|
+
if not result["ok"]:
|
|
36
|
+
return result
|
|
37
|
+
d = result["data"]
|
|
38
|
+
return {"ok": True, "id": d.get("id"), "html_url": d.get("html_url")}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@config.gated_write
|
|
42
|
+
def update_issue_state(owner: str, repo: str, issue_number: int, state: str) -> dict:
|
|
43
|
+
"""Set an issue's state to 'open' or 'closed'."""
|
|
44
|
+
if state not in ("open", "closed"):
|
|
45
|
+
return {
|
|
46
|
+
"ok": False,
|
|
47
|
+
"error": {
|
|
48
|
+
"type": "invalid_input",
|
|
49
|
+
"message": f"state must be 'open' or 'closed', got '{state}'",
|
|
50
|
+
"tool": "update_issue_state",
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
result = client.patch(
|
|
54
|
+
"update_issue_state",
|
|
55
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}",
|
|
56
|
+
json={"state": state},
|
|
57
|
+
)
|
|
58
|
+
if not result["ok"]:
|
|
59
|
+
return result
|
|
60
|
+
d = result["data"]
|
|
61
|
+
return {"ok": True, "number": d.get("number"), "state": d.get("state")}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@config.gated_write
|
|
65
|
+
def add_labels(owner: str, repo: str, issue_number: int, labels: list[str]) -> dict:
|
|
66
|
+
"""Add one or more labels to an issue or pull request."""
|
|
67
|
+
result = client.post(
|
|
68
|
+
"add_labels",
|
|
69
|
+
f"/repos/{owner}/{repo}/issues/{issue_number}/labels",
|
|
70
|
+
json={"labels": labels},
|
|
71
|
+
)
|
|
72
|
+
if not result["ok"]:
|
|
73
|
+
return result
|
|
74
|
+
return {"ok": True, "labels": [lbl.get("name") for lbl in result["data"]]}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@config.gated_write
|
|
78
|
+
def create_pr_review_comment(owner: str, repo: str, pr_number: int, body: str, commit_id: str, path: str, line: int) -> dict:
|
|
79
|
+
"""Create a review comment on a specific line of a pull request's diff."""
|
|
80
|
+
payload = {"body": body, "commit_id": commit_id, "path": path, "line": line}
|
|
81
|
+
result = client.post(
|
|
82
|
+
"create_pr_review_comment",
|
|
83
|
+
f"/repos/{owner}/{repo}/pulls/{pr_number}/comments",
|
|
84
|
+
json=payload,
|
|
85
|
+
)
|
|
86
|
+
if not result["ok"]:
|
|
87
|
+
return result
|
|
88
|
+
d = result["data"]
|
|
89
|
+
return {"ok": True, "id": d.get("id"), "html_url": d.get("html_url")}
|
github_mcp/server.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""github-mcp -- public read+write reference MCP server over the GitHub
|
|
3
|
+
REST API (FastMCP).
|
|
4
|
+
|
|
5
|
+
Reference portfolio implementation, NOT the official GitHub MCP server --
|
|
6
|
+
see README's "what this is / is not" section.
|
|
7
|
+
|
|
8
|
+
Tool groups (see github_mcp.config): read (always on, works unauthenticated
|
|
9
|
+
at GitHub's 60 req/hr tier), write (env-gated GITHUB_MCP_ENABLE_WRITE, OFF
|
|
10
|
+
by default, also requires GITHUB_TOKEN). Every write-group tool enforces its
|
|
11
|
+
own gate + token precondition at the function level
|
|
12
|
+
(github_mcp.config.gated_write), so this file is thin wiring -- the safety
|
|
13
|
+
logic lives in config.py and is unit-tested independently of the MCP
|
|
14
|
+
transport.
|
|
15
|
+
|
|
16
|
+
Run: python run_server.py
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from fastmcp import FastMCP
|
|
21
|
+
|
|
22
|
+
from .groups import read, write
|
|
23
|
+
|
|
24
|
+
SERVER_NAME = "github-mcp"
|
|
25
|
+
|
|
26
|
+
mcp = FastMCP(SERVER_NAME)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---- read (always on) --------------------------------------------------
|
|
30
|
+
|
|
31
|
+
@mcp.tool(name="get_repo", description="Get metadata for a repository: description, language, stars, forks, open issues, default branch, archived flag, license, last-push time.")
|
|
32
|
+
async def get_repo_tool(owner: str, repo: str) -> dict:
|
|
33
|
+
return read.get_repo(owner, repo)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool(name="list_issues", description="List issues for a repository (pull requests filtered out), most-recently-updated first.")
|
|
37
|
+
async def list_issues_tool(owner: str, repo: str, state: str = "open", limit: int = 20) -> dict:
|
|
38
|
+
return read.list_issues(owner, repo, state=state, limit=limit)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@mcp.tool(name="get_issue", description="Fetch a single issue's full detail: title, body, state, labels, comment count.")
|
|
42
|
+
async def get_issue_tool(owner: str, repo: str, issue_number: int) -> dict:
|
|
43
|
+
return read.get_issue(owner, repo, issue_number)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool(name="list_pull_requests", description="List pull requests for a repository, most-recently-updated first.")
|
|
47
|
+
async def list_pull_requests_tool(owner: str, repo: str, state: str = "open", limit: int = 20) -> dict:
|
|
48
|
+
return read.list_pull_requests(owner, repo, state=state, limit=limit)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@mcp.tool(name="get_pull_request", description="Fetch a single pull request's full detail, including merge state.")
|
|
52
|
+
async def get_pull_request_tool(owner: str, repo: str, pr_number: int) -> dict:
|
|
53
|
+
return read.get_pull_request(owner, repo, pr_number)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool(name="get_file_content", description="Read a UTF-8 text file from a repo at a given path (base64-decoded). Reports binary files rather than decoding them.")
|
|
57
|
+
async def get_file_content_tool(owner: str, repo: str, path: str, ref: str | None = None) -> dict:
|
|
58
|
+
return read.get_file_content(owner, repo, path, ref=ref)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool(name="search_repos", description="Search public repositories by keyword/qualifiers, sorted by best match.")
|
|
62
|
+
async def search_repos_tool(query: str, limit: int = 10) -> dict:
|
|
63
|
+
return read.search_repos(query, limit=limit)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool(name="get_user", description="Get a public profile for a GitHub user or organization.")
|
|
67
|
+
async def get_user_tool(username: str) -> dict:
|
|
68
|
+
return read.get_user(username)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool(name="list_commits", description="List commits on a repo's default branch (or a given ref), newest first.")
|
|
72
|
+
async def list_commits_tool(owner: str, repo: str, limit: int = 20, sha: str | None = None) -> dict:
|
|
73
|
+
return read.list_commits(owner, repo, limit=limit, sha=sha)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---- write (env-gated: GITHUB_MCP_ENABLE_WRITE, OFF by default; requires GITHUB_TOKEN) --
|
|
77
|
+
|
|
78
|
+
@mcp.tool(name="create_issue", description="Open a new issue on a repository. Requires GITHUB_MCP_ENABLE_WRITE=1 and GITHUB_TOKEN.")
|
|
79
|
+
async def create_issue_tool(owner: str, repo: str, title: str, body: str | None = None, labels: list[str] | None = None) -> dict:
|
|
80
|
+
return write.create_issue(owner, repo, title, body=body, labels=labels)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@mcp.tool(name="comment_on_issue", description="Post a comment on an issue or pull request. Requires GITHUB_MCP_ENABLE_WRITE=1 and GITHUB_TOKEN.")
|
|
84
|
+
async def comment_on_issue_tool(owner: str, repo: str, issue_number: int, body: str) -> dict:
|
|
85
|
+
return write.comment_on_issue(owner, repo, issue_number, body)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool(name="update_issue_state", description="Set an issue's state to 'open' or 'closed'. Requires GITHUB_MCP_ENABLE_WRITE=1 and GITHUB_TOKEN.")
|
|
89
|
+
async def update_issue_state_tool(owner: str, repo: str, issue_number: int, state: str) -> dict:
|
|
90
|
+
return write.update_issue_state(owner, repo, issue_number, state)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@mcp.tool(name="add_labels", description="Add one or more labels to an issue or pull request. Requires GITHUB_MCP_ENABLE_WRITE=1 and GITHUB_TOKEN.")
|
|
94
|
+
async def add_labels_tool(owner: str, repo: str, issue_number: int, labels: list[str]) -> dict:
|
|
95
|
+
return write.add_labels(owner, repo, issue_number, labels)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@mcp.tool(name="create_pr_review_comment", description="Create a review comment on a specific line of a pull request's diff. Requires GITHUB_MCP_ENABLE_WRITE=1 and GITHUB_TOKEN.")
|
|
99
|
+
async def create_pr_review_comment_tool(owner: str, repo: str, pr_number: int, body: str, commit_id: str, path: str, line: int) -> dict:
|
|
100
|
+
return write.create_pr_review_comment(owner, repo, pr_number, body, commit_id, path, line)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
mcp.run()
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jaimenbell-github-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Public read+write reference MCP server over the GitHub REST API. Env-gated tool groups (read always on, write off by default), fine-grained PAT auth that degrades to the unauthenticated tier, typed rate-limit/error payloads. Reference portfolio implementation, NOT the official GitHub MCP server.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: fastmcp==3.4.2
|
|
9
|
+
Requires-Dist: httpx==0.28.1
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest==9.0.3; extra == "test"
|
|
12
|
+
Requires-Dist: respx==0.23.1; extra == "test"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# github-mcp
|
|
16
|
+
|
|
17
|
+
A public read+write MCP server over the GitHub REST API -- built to the
|
|
18
|
+
[desktop-mcp](https://github.com)/rag-mcp/mcp-factory standard (own
|
|
19
|
+
pyproject, fastmcp server, honest README, real test suite). Env-gated tool
|
|
20
|
+
groups, **write disabled by default**.
|
|
21
|
+
|
|
22
|
+
## What this is / is not
|
|
23
|
+
|
|
24
|
+
This is a **reference portfolio implementation** demonstrating a hardened
|
|
25
|
+
read+write MCP server pattern over a real external SaaS API (GitHub) --
|
|
26
|
+
env-gated tool groups, typed error/rate-limit handling, auth that degrades
|
|
27
|
+
gracefully, a real test suite. It exists to show, concretely, "I build
|
|
28
|
+
read/write MCP servers over external APIs" with a link a client can click.
|
|
29
|
+
|
|
30
|
+
**It is NOT the official GitHub MCP server.** It does not aim for parity
|
|
31
|
+
with GitHub's own MCP offering (GraphQL, Actions, webhooks, GitHub Apps are
|
|
32
|
+
all out of scope -- see below). It started life as a factory-scaffolded
|
|
33
|
+
read-only demo ([mcp-factory](https://github.com)'s
|
|
34
|
+
`generated/github_read_server.py`) and was hand-hardened into this
|
|
35
|
+
standalone read+write server -- the scaffold-then-harden path is itself part
|
|
36
|
+
of the story this repo tells.
|
|
37
|
+
|
|
38
|
+
## Tool groups
|
|
39
|
+
|
|
40
|
+
| Group | Tools | Default state |
|
|
41
|
+
|---|---|---|
|
|
42
|
+
| `read` | `get_repo`, `list_issues`, `get_issue`, `list_pull_requests`, `get_pull_request`, `get_file_content`, `search_repos`, `get_user`, `list_commits` | always on, works unauthenticated (GitHub's 60 req/hr tier) |
|
|
43
|
+
| `write` | `create_issue`, `comment_on_issue`, `update_issue_state`, `add_labels`, `create_pr_review_comment` | env-gated, **OFF by default** -- requires `GITHUB_MCP_ENABLE_WRITE=1` **and** `GITHUB_TOKEN` |
|
|
44
|
+
|
|
45
|
+
A disabled write call returns a structured `policy_refusal` error (never a
|
|
46
|
+
silent no-op, never a crash). A write call with the group enabled but no
|
|
47
|
+
token returns a structured `auth_required` error -- the group gate and the
|
|
48
|
+
token precondition are checked independently, both before any network call.
|
|
49
|
+
|
|
50
|
+
## Write-safety-off-by-default
|
|
51
|
+
|
|
52
|
+
This is defense-in-depth, mirroring desktop-mcp's `input` group: harness-level
|
|
53
|
+
permission prompts are the first gate, but the server itself refuses every
|
|
54
|
+
write tool unless its own environment explicitly opts in with
|
|
55
|
+
`GITHUB_MCP_ENABLE_WRITE=1`, and even then refuses without a `GITHUB_TOKEN`.
|
|
56
|
+
A misconfigured or overly-permissive MCP host cannot turn on GitHub mutations
|
|
57
|
+
this process wasn't deliberately configured to allow. The registration this
|
|
58
|
+
repo ships with (see `~/.claude.json`'s `github-mcp` entry) has the write
|
|
59
|
+
group **absent from env** -- enabling it is a deliberate per-registration
|
|
60
|
+
operator choice, not a code change.
|
|
61
|
+
|
|
62
|
+
## Honest-capabilities table
|
|
63
|
+
|
|
64
|
+
Every claim below maps to the file that implements it and the test(s) that
|
|
65
|
+
verify it -- no capability is asserted without a corresponding implementation
|
|
66
|
+
and test.
|
|
67
|
+
|
|
68
|
+
| Claim | Implementation | Verified by |
|
|
69
|
+
|---|---|---|
|
|
70
|
+
| Repo metadata (stars, language, license, default branch, archived flag...) | `github_mcp/groups/read.py::get_repo` | `tests/test_read.py::TestGetRepo`, live: `tests/test_live_smoke.py::test_live_get_repo_real_json` |
|
|
71
|
+
| List / fetch issues (PRs filtered from list) | `github_mcp/groups/read.py::list_issues`, `get_issue` | `tests/test_read.py::TestListIssues`, `TestGetIssue` |
|
|
72
|
+
| List / fetch pull requests | `github_mcp/groups/read.py::list_pull_requests`, `get_pull_request` | `tests/test_read.py::TestListPullRequests`, `TestGetPullRequest` |
|
|
73
|
+
| Read a repo file's content (base64-decoded, binary detected not decoded) | `github_mcp/groups/read.py::get_file_content` | `tests/test_read.py::TestGetFileContent` |
|
|
74
|
+
| Search public repositories | `github_mcp/groups/read.py::search_repos` | `tests/test_read.py::TestSearchRepos` |
|
|
75
|
+
| Public user/org profile | `github_mcp/groups/read.py::get_user` | `tests/test_read.py::TestGetUser` |
|
|
76
|
+
| List commits on a branch/ref | `github_mcp/groups/read.py::list_commits` | `tests/test_read.py::TestListCommits` |
|
|
77
|
+
| Open an issue | `github_mcp/groups/write.py::create_issue` | `tests/test_write.py::TestCreateIssue` |
|
|
78
|
+
| Comment on an issue/PR | `github_mcp/groups/write.py::comment_on_issue` | `tests/test_write.py::TestCommentOnIssue` |
|
|
79
|
+
| Open/close an issue | `github_mcp/groups/write.py::update_issue_state` | `tests/test_write.py::TestUpdateIssueState` |
|
|
80
|
+
| Add labels to an issue/PR | `github_mcp/groups/write.py::add_labels` | `tests/test_write.py::TestAddLabels` |
|
|
81
|
+
| Create a PR review comment on a diff line | `github_mcp/groups/write.py::create_pr_review_comment` | `tests/test_write.py::TestCreatePrReviewComment` |
|
|
82
|
+
| Write group OFF by default, structured refusal when disabled | `github_mcp/config.py::group_enabled`, `gated_write` | `tests/test_config.py::TestGroupEnabled`, `tests/test_write.py::TestGateDisabledByDefault` |
|
|
83
|
+
| Write tools require a token even when the group is enabled | `github_mcp/config.py::check_write_preconditions` | `tests/test_config.py::TestCheckWritePreconditions`, `tests/test_write.py::TestAuthRequiredWhenGroupEnabled` |
|
|
84
|
+
| Fine-grained PAT auth, degrades to unauthenticated tier when absent | `github_mcp/client.py::_headers` | `tests/test_client.py::TestAuthHeaderInjection`, `tests/test_read.py::TestUnauthDegrade` |
|
|
85
|
+
| GitHub primary rate-limit (403 + `X-RateLimit-Reset`) and secondary rate-limit (403 + `Retry-After`, no `X-RateLimit-Remaining`) both surface as a typed error with reset/retry time, never a crash | `github_mcp/client.py::_rate_limit_error`, `_is_rate_limit_response` | `tests/test_client.py::TestRateLimitError`, `tests/test_client.py::TestRateLimitError::test_secondary_rate_limit_no_ratelimit_headers_retry_after_only`, `tests/test_read.py::TestUnauthDegrade::test_get_repo_rate_limited_without_token_is_typed` |
|
|
86
|
+
| Malformed owner/repo/path (control chars etc.) that would raise `httpx.InvalidURL` surfaces as a typed error, never an uncaught exception | `github_mcp/client.py::request` | `tests/test_client.py::TestNetworkError::test_malformed_path_raises_invalid_url_caught_as_network_error` |
|
|
87
|
+
| Generic 4xx/5xx surfaces as a typed error, never a crash | `github_mcp/client.py::_api_error` | `tests/test_client.py::TestApiError` |
|
|
88
|
+
| Non-JSON / malformed responses and network failures surface as typed errors | `github_mcp/client.py::_handle_response`, `request` | `tests/test_client.py::TestDecodeError`, `TestNetworkError` |
|
|
89
|
+
|
|
90
|
+
## Limitations (read before relying on this)
|
|
91
|
+
|
|
92
|
+
- **REST v1 only.** No GraphQL API coverage.
|
|
93
|
+
- **No webhooks / GitHub App auth.** Fine-grained PAT only.
|
|
94
|
+
- **No Actions/workflow-dispatch tools.** Issue/PR CRUD is the v1 write surface.
|
|
95
|
+
- **Unauthenticated read is rate-limited to 60 req/hr** by GitHub itself (10
|
|
96
|
+
req/min for search) -- expect `rate_limited` errors under sustained
|
|
97
|
+
unauthenticated use; set `GITHUB_TOKEN` (even a read-only fine-grained PAT)
|
|
98
|
+
to raise this considerably.
|
|
99
|
+
- **`get_file_content` truncates past 100KB** and reports (rather than
|
|
100
|
+
decodes) non-UTF-8 files.
|
|
101
|
+
- **No pagination beyond a single page** for list endpoints (`limit`, capped
|
|
102
|
+
per-endpoint, is the only page-size control in v1).
|
|
103
|
+
- **Not registered with the mcp-factory hub.** Ships as a standalone repo
|
|
104
|
+
(own pyproject, system Python312 install), matching the rag-mcp/desktop-mcp
|
|
105
|
+
model.
|
|
106
|
+
|
|
107
|
+
## Env vars
|
|
108
|
+
|
|
109
|
+
| Var | Effect | Default |
|
|
110
|
+
|---|---|---|
|
|
111
|
+
| `GITHUB_MCP_ENABLE_WRITE` | enable the `write` tool group | unset (off) |
|
|
112
|
+
| `GITHUB_TOKEN` | fine-grained PAT; read works without it (degraded unauth rate), write requires it | unset |
|
|
113
|
+
| `GITHUB_MCP_LIVE` | `1` to run the real-network smoke test (see Testing) | unset (skip) |
|
|
114
|
+
|
|
115
|
+
## Usage examples
|
|
116
|
+
|
|
117
|
+
```jsonc
|
|
118
|
+
// A tool call from the MCP host, illustrative -- not a shell command.
|
|
119
|
+
{"tool": "get_repo", "arguments": {"owner": "anthropics", "repo": "anthropic-sdk-python"}}
|
|
120
|
+
// -> {"ok": true, "full_name": "anthropics/anthropic-sdk-python", "stargazers_count": 1234, ...}
|
|
121
|
+
|
|
122
|
+
// write group disabled (default):
|
|
123
|
+
{"tool": "create_issue", "arguments": {"owner": "o", "repo": "r", "title": "bug"}}
|
|
124
|
+
// -> {"ok": false, "error": {"type": "policy_refusal", "group": "write", "required_env": "GITHUB_MCP_ENABLE_WRITE", ...}}
|
|
125
|
+
|
|
126
|
+
// write group enabled, no token set:
|
|
127
|
+
{"tool": "create_issue", "arguments": {"owner": "o", "repo": "r", "title": "bug"}}
|
|
128
|
+
// -> {"ok": false, "error": {"type": "auth_required", "tool": "create_issue", ...}}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Testing
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
# unit suite (respx-mocked api.github.com, no real network touched)
|
|
135
|
+
python -m pytest -q
|
|
136
|
+
|
|
137
|
+
# handshake check -- prints every registered tool name
|
|
138
|
+
python scripts/list_tools.py
|
|
139
|
+
|
|
140
|
+
# real-network read smoke (get_repo against a stable public repo;
|
|
141
|
+
# no write smoke exists anywhere in this suite -- see safety rails above)
|
|
142
|
+
GITHUB_MCP_LIVE=1 python -m pytest -q -k live_get_repo
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Install
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
pip install -r requirements.txt # or: pip install .
|
|
149
|
+
# deps: fastmcp==3.4.2, httpx==0.28.1
|
|
150
|
+
# test-only: pytest==9.0.3, respx==0.23.1
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Setup / connect
|
|
154
|
+
|
|
155
|
+
1. `pip install -r requirements.txt` on Python 3.12+.
|
|
156
|
+
2. (Optional) generate a [fine-grained PAT](https://github.com/settings/tokens?type=beta)
|
|
157
|
+
scoped to the repos you want read+write access to (Issues: read/write,
|
|
158
|
+
Pull requests: read/write, Contents: read is enough for v1). Read tools
|
|
159
|
+
work with **no token at all** -- they just run at GitHub's unauthenticated
|
|
160
|
+
60 req/hr tier.
|
|
161
|
+
3. Add to your MCP host config (e.g. `~/.claude.json`):
|
|
162
|
+
|
|
163
|
+
```jsonc
|
|
164
|
+
{
|
|
165
|
+
"mcpServers": {
|
|
166
|
+
"github-mcp": {
|
|
167
|
+
"command": "C:\\Users\\jaime\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
|
168
|
+
"args": ["C:\\Users\\jaime\\projects\\github-mcp\\run_server.py"],
|
|
169
|
+
"env": {
|
|
170
|
+
"GITHUB_TOKEN": "your-fine-grained-pat-here"
|
|
171
|
+
// GITHUB_MCP_ENABLE_WRITE intentionally absent -- write stays off
|
|
172
|
+
// until you deliberately opt in per-deployment.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
4. To enable write tools for a given deployment, add
|
|
180
|
+
`"GITHUB_MCP_ENABLE_WRITE": "1"` to that entry's `env` block. This is a
|
|
181
|
+
registration-time operator decision, not a code change.
|
|
182
|
+
|
|
183
|
+
Registered in `~/.claude.json` as `github-mcp` (stdio, system Python312,
|
|
184
|
+
`read` group always on, `write` group absent from env -- off).
|
|
185
|
+
|
|
186
|
+
<!-- MCP registry ownership marker -->
|
|
187
|
+
mcp-name: io.github.jaimenbell/github-mcp
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
github_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
github_mcp/client.py,sha256=Uk4kFyStJUq_vDa1j_KP394Zt24G1mOeEZxGb7JpLiY,5846
|
|
3
|
+
github_mcp/config.py,sha256=fY1zoKnrK_cq-BSYP1cQHN3dAJRHiRXDSU0hzZeRbx8,4210
|
|
4
|
+
github_mcp/server.py,sha256=dwC0ZUN7j6THfQ_4FZS9GCWqF-KTAh_WJ0e294N3WoY,5173
|
|
5
|
+
github_mcp/groups/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
github_mcp/groups/read.py,sha256=pmZ_-gFlykAp4paOAY01hrRavqaC7XdKz7XMiegmLoQ,9007
|
|
7
|
+
github_mcp/groups/write.py,sha256=sDAlx4eFXQR9HhamnMXU20I4_Bxmmv3BUezQnUudDGk,3262
|
|
8
|
+
jaimenbell_github_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=X9AtXBdJheXRglho9cH4FH2eK7RwIz0ZJYcyX0iMQ1E,1068
|
|
9
|
+
jaimenbell_github_mcp-0.1.0.dist-info/METADATA,sha256=RPKCId-1b22demfZHe658TtJ-U0Zmhv-qJDpTUjn86M,10902
|
|
10
|
+
jaimenbell_github_mcp-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
jaimenbell_github_mcp-0.1.0.dist-info/top_level.txt,sha256=Oz7KcY8SwRmG0rRos2vB8LycqcG5vapSkveK8aqQi0Q,11
|
|
12
|
+
jaimenbell_github_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaimen Bell
|
|
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
|