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 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,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,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